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 InvalidRepository(String),
1536 Controller(String),
1537}
1538
1539#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1543#[serde(deny_unknown_fields)]
1544pub struct PreflightRepository {
1545 pub id: String,
1546 pub fetch_url: String,
1547 pub default_branch: String,
1548 pub push_urls: Vec<String>,
1549}
1550
1551#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1557#[serde(deny_unknown_fields)]
1558pub struct PreflightNew {
1559 #[serde(default)]
1560 pub dirty_repositories: Vec<String>,
1561 #[serde(default)]
1562 pub remote_repositories: Vec<PreflightRepository>,
1563 pub local_changes_excluded: bool,
1564}
1565
1566#[derive(Debug)]
1573pub enum ClientStateRequest {
1574 Read {
1575 client_id: String,
1576 session_id: String,
1577 reply: tokio::sync::oneshot::Sender<Result<ViewerClientState, String>>,
1578 },
1579 SaveDraft {
1580 client_id: String,
1581 session_id: String,
1582 draft: String,
1583 reply: tokio::sync::oneshot::Sender<Result<(), String>>,
1584 },
1585 MarkWorkspaceRead {
1586 client_id: String,
1587 workspace_id: String,
1588 reply: tokio::sync::oneshot::Sender<Result<(), String>>,
1589 },
1590 History {
1591 session_id: String,
1592 query: String,
1593 scope: String,
1594 reply: tokio::sync::oneshot::Sender<Result<ViewerPromptHistory, String>>,
1595 },
1596}
1597
1598#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1599#[serde(deny_unknown_fields)]
1600pub struct ViewerClientState {
1601 pub draft: String,
1602 pub through_event_ordinal: u64,
1603}
1604
1605#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1606#[serde(deny_unknown_fields)]
1607pub struct ViewerPromptHistory {
1608 pub entries: Vec<String>,
1609 pub truncated: bool,
1612}
1613
1614#[derive(Debug)]
1615pub struct ReadReceiptRequest {
1616 pub client_id: String,
1617 pub session_id: String,
1618 pub through: u64,
1619 pub reply: tokio::sync::oneshot::Sender<Result<(), String>>,
1620}
1621
1622#[derive(Clone)]
1623struct ServerState {
1624 snapshot_rx: watch::Receiver<ViewerSnapshot>,
1625 conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
1626 action_tx: mpsc::Sender<ControllerRequest>,
1627 bundle_tx: mpsc::Sender<BundleRequest>,
1628 receipt_tx: mpsc::Sender<ReadReceiptRequest>,
1629 preflight_tx: mpsc::Sender<PreflightRequest>,
1630 move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
1631 client_state_tx: mpsc::Sender<ClientStateRequest>,
1632 dictation_tx: mpsc::Sender<DictationRequest>,
1633 dictation_permits: Arc<Semaphore>,
1634 dictation_probe_permits: Arc<Semaphore>,
1635 shutdown: CancellationToken,
1636 viewer_code: Arc<str>,
1637 login_token: Arc<str>,
1638 cookie_key: Arc<[u8]>,
1639 session_ttl: Duration,
1640 secure_cookie: bool,
1641 code_guard: Arc<Mutex<CodeGuard>>,
1642}
1643
1644#[derive(Debug, Default)]
1652struct CodeGuard {
1653 failures: u32,
1654 lockouts: u32,
1655 locked_until: Option<Instant>,
1656}
1657
1658impl CodeGuard {
1659 fn locked_at(&mut self, now: Instant) -> bool {
1660 match self.locked_until {
1661 Some(until) if now < until => true,
1662 Some(_) => {
1663 self.locked_until = None;
1666 self.failures = 0;
1667 false
1668 }
1669 None => false,
1670 }
1671 }
1672
1673 fn record_failure_at(&mut self, now: Instant) {
1674 self.failures = self.failures.saturating_add(1);
1675 if self.failures < MAX_CODE_FAILURES {
1676 return;
1677 }
1678 self.failures = 0;
1679 self.lockouts = self.lockouts.saturating_add(1);
1680 self.locked_until = Some(now + code_lockout(self.lockouts));
1681 }
1682}
1683
1684fn code_lockout(lockouts: u32) -> Duration {
1687 let multiplier = 1_u32
1688 .checked_shl(lockouts.saturating_sub(1))
1689 .unwrap_or(u32::MAX);
1690 CODE_LOCKOUT_BASE
1691 .saturating_mul(multiplier)
1692 .min(CODE_LOCKOUT_CAP)
1693}
1694
1695fn router(options: ServerOptions) -> Router {
1696 let state = ServerState {
1697 snapshot_rx: options.snapshot_rx,
1698 conversation_rx: options.conversation_rx,
1699 action_tx: options.action_tx,
1700 bundle_tx: options.bundle_tx,
1701 receipt_tx: options.receipt_tx,
1702 preflight_tx: options.preflight_tx,
1703 move_preparation_tx: options.move_preparation_tx,
1704 client_state_tx: options.client_state_tx,
1705 dictation_tx: options.dictation_tx,
1706 dictation_permits: Arc::new(Semaphore::new(MAX_CONCURRENT_DICTATIONS)),
1707 dictation_probe_permits: Arc::new(Semaphore::new(MAX_CONCURRENT_DICTATIONS)),
1708 shutdown: options.shutdown,
1709 viewer_code: options.viewer_code.into(),
1710 login_token: options.login_token.into(),
1711 cookie_key: options.cookie_key.into(),
1712 session_ttl: options.session_ttl,
1713 secure_cookie: options.secure_cookie,
1714 code_guard: Arc::new(Mutex::new(CodeGuard::default())),
1715 };
1716 let protected = Router::new()
1717 .route("/api/snapshot", get(snapshot))
1718 .route("/api/conversations/{session_id}", get(conversation))
1719 .route(
1720 "/api/conversations/{session_id}/read",
1721 post(mark_conversation_read),
1722 )
1723 .route("/api/events", get(events))
1724 .route("/api/bundles", post(create_bundle))
1725 .route("/api/preflight/new", post(preflight_new))
1726 .route("/api/moves/prepare", post(prepare_move))
1727 .route("/api/sessions/{session_id}/client-state", get(client_state))
1728 .route(
1729 "/api/sessions/{session_id}/dictation",
1730 get(dictation_availability).post(upload_dictation),
1731 )
1732 .route(
1733 "/api/sessions/{session_id}/attachments",
1734 post(upload_attachment).layer(DefaultBodyLimit::max(MAX_ATTACHMENT_UPLOAD_BYTES)),
1735 )
1736 .route(
1737 "/api/sessions/{session_id}/draft",
1738 put(save_draft).layer(DefaultBodyLimit::max(MAX_DRAFT_BYTES)),
1739 )
1740 .route("/api/sessions/{session_id}/history", get(prompt_history))
1741 .route(
1742 "/api/workspaces/{workspace_id}/read",
1743 post(mark_workspace_read),
1744 )
1745 .route(
1746 "/api/actions",
1747 post(action).layer(DefaultBodyLimit::max(MAX_PROMPT_BODY_BYTES)),
1748 )
1749 .route_layer(axum::middleware::from_fn_with_state(
1750 state.clone(),
1751 require_session,
1752 ));
1753 Router::new()
1754 .route("/", get(viewer))
1755 .route("/login", get(viewer))
1756 .route("/viewer.css", get(viewer_css))
1757 .route("/viewer.js", get(viewer_js))
1758 .route("/voice-worklet.js", get(voice_worklet_js))
1759 .route("/voice-worker.js", get(voice_worker_js))
1760 .route("/markdown.js", get(markdown_js))
1761 .route("/tool-output.js", get(tool_output_js))
1762 .route("/manifest.webmanifest", get(manifest))
1763 .route("/service-worker.js", get(service_worker))
1764 .route("/icon.svg", get(icon))
1765 .route("/icon-192.png", get(icon_192))
1766 .route("/icon-512.png", get(icon_512))
1767 .route("/maskable-512.png", get(maskable_512))
1768 .route("/apple-touch-icon.png", get(apple_touch_icon))
1769 .route("/fonts/jetbrains-mono.woff2", get(mono_font))
1770 .route("/auth/session", post(create_session).delete(clear_session))
1771 .route("/auth/login", get(create_session_from_query))
1772 .merge(protected)
1773 .layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
1774 .layer(axum::middleware::from_fn(security_headers))
1775 .with_state(state)
1776}
1777
1778async fn require_session(
1779 State(state): State<ServerState>,
1780 request: Request,
1781 next: Next,
1782) -> Result<Response<Body>, ApiError> {
1783 let cookie = request
1784 .headers()
1785 .get(COOKIE)
1786 .and_then(|value| value.to_str().ok())
1787 .and_then(|header| cookie_value(header, COOKIE_NAME));
1788 if cookie.is_some_and(|value| session_cookie_valid(&state.cookie_key, value, now_unix())) {
1789 Ok(next.run(request).await)
1790 } else {
1791 Err(ApiError::unauthorized())
1792 }
1793}
1794
1795#[derive(Debug, Deserialize)]
1796#[serde(deny_unknown_fields)]
1797struct LoginRequest {
1798 code: String,
1799}
1800
1801#[derive(Debug, Deserialize)]
1802#[serde(deny_unknown_fields)]
1803struct LoginQuery {
1804 token: String,
1805}
1806
1807async fn create_session_from_query(
1808 State(state): State<ServerState>,
1809 Query(query): Query<LoginQuery>,
1810) -> Result<Response<Body>, ApiError> {
1811 if !constant_time_eq(state.login_token.as_bytes(), query.token.trim().as_bytes()) {
1812 return Err(ApiError::unauthorized());
1813 }
1814 let mut response = issue_session_cookie(&state, StatusCode::SEE_OTHER)?;
1815 response
1816 .headers_mut()
1817 .insert(LOCATION, HeaderValue::from_static("/"));
1818 response
1819 .headers_mut()
1820 .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
1821 Ok(response)
1822}
1823
1824async fn create_session(
1825 State(state): State<ServerState>,
1826 Json(request): Json<LoginRequest>,
1827) -> Result<Response<Body>, ApiError> {
1828 if code_locked(&state) {
1829 return Err(ApiError::new(
1830 StatusCode::TOO_MANY_REQUESTS,
1831 "too many incorrect codes; wait and try again",
1832 ));
1833 }
1834 if !constant_time_eq(state.viewer_code.as_bytes(), request.code.trim().as_bytes()) {
1835 record_code_failure(&state);
1836 return Err(ApiError::unauthorized());
1837 }
1838 reset_code_failures(&state);
1839 issue_session_cookie(&state, StatusCode::NO_CONTENT)
1840}
1841
1842fn issue_session_cookie(
1843 state: &ServerState,
1844 status: StatusCode,
1845) -> Result<Response<Body>, ApiError> {
1846 let ephemeral = state.session_ttl.is_zero();
1847 let validity = if ephemeral {
1848 EPHEMERAL_SESSION_TTL
1849 } else {
1850 state.session_ttl
1851 };
1852 let value = signed_cookie_value(
1853 &state.cookie_key,
1854 &generate_viewer_id().map_err(|_| {
1855 ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "cookie creation failed")
1856 })?,
1857 now_unix().saturating_add(validity.as_secs()),
1858 );
1859 let cookie = session_cookie_header(
1860 &value,
1861 (!ephemeral).then_some(validity.as_secs()),
1862 state.secure_cookie,
1863 )?;
1864 let mut response = status.into_response();
1865 response.headers_mut().insert(SET_COOKIE, cookie);
1866 Ok(response)
1867}
1868
1869async fn clear_session(State(state): State<ServerState>) -> Response<Body> {
1870 let mut response = StatusCode::NO_CONTENT.into_response();
1871 response
1872 .headers_mut()
1873 .insert(SET_COOKIE, clear_cookie_header(state.secure_cookie));
1874 response
1875}
1876
1877async fn snapshot(State(state): State<ServerState>) -> Response<Body> {
1878 let mut projection = state.snapshot_rx.borrow().clone();
1879 projection.server_time_ms = hel::clock::epoch_millis();
1882 let mut response = Json(projection).into_response();
1883 response
1884 .headers_mut()
1885 .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
1886 response
1887}
1888
1889async fn upload_attachment(
1894 State(state): State<ServerState>,
1895 Path(session_id): Path<String>,
1896 body: Bytes,
1897) -> Result<Json<ViewerPromptImage>, ApiError> {
1898 validate_public_id(&session_id)?;
1899 let prompt_images_supported = {
1900 let snapshot = state.snapshot_rx.borrow();
1901 require_session_record(&snapshot, &session_id)?.prompt_images_supported
1902 };
1903 if !prompt_images_supported {
1904 return Err(ApiError::bad_request(
1905 "this session does not support image prompts",
1906 ));
1907 }
1908 if body.is_empty() {
1909 return Err(ApiError::bad_request("image upload must not be empty"));
1910 }
1911
1912 let result = tokio::task::spawn_blocking(move || {
1913 let optimized = optimize_image(&body).map_err(|_| {
1914 ApiError::bad_request("unsupported image format or image could not be decoded")
1915 })?;
1916 if optimized.bytes.is_empty() || optimized.bytes.len() > MAX_IMAGE_BYTES {
1917 return Err(ApiError::new(
1918 StatusCode::INTERNAL_SERVER_ERROR,
1919 "the image optimizer returned an invalid image size",
1920 ));
1921 }
1922 let reference = AttachmentRef::new(
1923 &optimized.bytes,
1924 optimized.mime_type.clone(),
1925 optimized.width,
1926 optimized.height,
1927 )
1928 .map_err(|_| {
1929 ApiError::new(
1930 StatusCode::INTERNAL_SERVER_ERROR,
1931 "could not create image attachment",
1932 )
1933 })?;
1934 let store = AttachmentStore::controller(&session_id).map_err(|_| {
1935 ApiError::new(
1936 StatusCode::INTERNAL_SERVER_ERROR,
1937 "could not open the image attachment store",
1938 )
1939 })?;
1940 store.install(&reference, &optimized.bytes).map_err(|_| {
1941 ApiError::new(
1942 StatusCode::INTERNAL_SERVER_ERROR,
1943 "could not store the image attachment",
1944 )
1945 })?;
1946 Ok(ViewerPromptImage {
1947 data_base64: String::new(),
1948 mime_type: reference.mime_type.clone(),
1949 width: reference.width,
1950 height: reference.height,
1951 attachment: Some(reference),
1952 })
1953 })
1954 .await
1955 .map_err(|_| {
1956 ApiError::new(
1957 StatusCode::INTERNAL_SERVER_ERROR,
1958 "the server could not process the image upload",
1959 )
1960 })??;
1961
1962 Ok(Json(result))
1963}
1964
1965async fn action(
1971 State(state): State<ServerState>,
1972 Json(action): Json<ControllerAction>,
1973) -> Result<StatusCode, ApiError> {
1974 validate_action(&action, &state.snapshot_rx.borrow())?;
1975 let action = decode_prompt_images_off_task(action).await?;
1976 let (reply, outcome) = tokio::sync::oneshot::channel();
1977 state
1978 .action_tx
1979 .send(ControllerRequest { action, reply })
1980 .await
1981 .map_err(|_| ApiError::controller_unavailable())?;
1982 let outcome = outcome
1983 .await
1984 .map_err(|_| ApiError::controller_unavailable())?;
1985 match outcome.rejection() {
1986 Some(rejection) => Err(rejection),
1987 None => Ok(StatusCode::ACCEPTED),
1988 }
1989}
1990
1991const MAX_BUNDLE_SOURCE_CHARS: usize = 1024;
1992
1993#[derive(Debug, Deserialize)]
1994#[serde(deny_unknown_fields)]
1995struct CreateBundleRequest {
1996 source: String,
1997}
1998
1999#[derive(Debug, Serialize)]
2000struct CreateBundleResponse {
2001 bundle_id: String,
2002}
2003
2004async fn create_bundle(
2009 State(state): State<ServerState>,
2010 Json(request): Json<CreateBundleRequest>,
2011) -> Result<Json<CreateBundleResponse>, ApiError> {
2012 if request.source.trim().is_empty() {
2013 return Err(ApiError::bad_request("repository source cannot be empty"));
2014 }
2015 if request.source.chars().count() > MAX_BUNDLE_SOURCE_CHARS {
2016 return Err(ApiError::bad_request(
2017 "repository source must contain 1024 characters or fewer",
2018 ));
2019 }
2020 let (reply, result) = tokio::sync::oneshot::channel();
2021 state
2022 .bundle_tx
2023 .send(BundleRequest {
2024 source: request.source,
2025 reply,
2026 })
2027 .await
2028 .map_err(|_| ApiError::controller_unavailable())?;
2029 let bundle_id = result
2030 .await
2031 .map_err(|_| ApiError::controller_unavailable())?
2032 .map_err(|failure| match failure {
2033 BundleFailure::InvalidSource => ApiError::bad_request(
2034 "use a GitHub owner/repository or an existing Git checkout on the controller host",
2035 ),
2036 BundleFailure::Controller => ApiError::new(
2037 StatusCode::INTERNAL_SERVER_ERROR,
2038 "the controller could not create the bundle",
2039 ),
2040 })?;
2041 Ok(Json(CreateBundleResponse { bundle_id }))
2042}
2043
2044#[derive(Debug, Deserialize)]
2045struct ConversationQuery {
2046 after_seq: Option<u64>,
2047}
2048
2049async fn conversation(
2050 State(state): State<ServerState>,
2051 Path(session_id): Path<String>,
2052 Query(query): Query<ConversationQuery>,
2053) -> Result<Json<BrowserTranscript>, ApiError> {
2054 validate_public_id(&session_id)?;
2055 let transitioning = {
2056 let snapshot = state.snapshot_rx.borrow();
2057 require_session_record(&snapshot, &session_id)?.transitioning
2058 };
2059 if transitioning {
2060 return Err(ApiError::new(
2061 StatusCode::CONFLICT,
2062 "conversation unavailable while the session is transitioning",
2063 ));
2064 }
2065 let conversations = state.conversation_rx.borrow();
2066 let transcript = conversations
2067 .get(&session_id)
2068 .ok_or_else(|| ApiError::not_found("conversation unavailable"))?;
2069 let mut response = transcript.clone();
2070 if let Some(after) = query.after_seq {
2071 response.reset = after < response.window_start_seq;
2072 if !response.reset {
2073 response.entries.retain(|entry| entry.updated_seq > after);
2074 }
2075 }
2076 Ok(Json(response))
2077}
2078
2079#[derive(Debug, Deserialize)]
2080#[serde(deny_unknown_fields)]
2081struct ReadRequest {
2082 through: u64,
2083}
2084
2085async fn mark_conversation_read(
2086 State(state): State<ServerState>,
2087 Path(session_id): Path<String>,
2088 headers: HeaderMap,
2089 Json(request): Json<ReadRequest>,
2090) -> Result<StatusCode, ApiError> {
2091 validate_public_id(&session_id)?;
2092 let transitioning = {
2093 let snapshot = state.snapshot_rx.borrow();
2094 require_session_record(&snapshot, &session_id)?.transitioning
2095 };
2096 if transitioning {
2097 return Err(ApiError::new(
2098 StatusCode::CONFLICT,
2099 "conversation unavailable while the session is transitioning",
2100 ));
2101 }
2102 let (reply, result) = tokio::sync::oneshot::channel();
2103 let client_id = viewer_client_id(&state, &headers).ok_or_else(ApiError::unauthorized)?;
2104 state
2105 .receipt_tx
2106 .send(ReadReceiptRequest {
2107 client_id,
2108 session_id,
2109 through: request.through,
2110 reply,
2111 })
2112 .await
2113 .map_err(|_| ApiError::controller_unavailable())?;
2114 result
2115 .await
2116 .map_err(|_| ApiError::controller_unavailable())?
2117 .map_err(|_| ApiError::new(StatusCode::CONFLICT, "read receipt failed"))?;
2118 Ok(StatusCode::NO_CONTENT)
2119}
2120
2121#[derive(Debug, Deserialize)]
2122#[serde(deny_unknown_fields)]
2123struct PreflightNewRequest {
2124 #[serde(default)]
2125 workspace_id: String,
2126 profile_id: String,
2127 bundle_id: String,
2128 target_id: String,
2129 #[serde(default)]
2130 project_directory: Option<PathBuf>,
2131}
2132
2133async fn preflight_new(
2139 State(state): State<ServerState>,
2140 Json(request): Json<PreflightNewRequest>,
2141) -> Result<Json<PreflightNew>, ApiError> {
2142 let project_validation = request.project_directory.is_some();
2143 let action = ControllerAction::New {
2144 workspace_id: request.workspace_id,
2145 profile_id: request.profile_id,
2146 bundle_id: request.bundle_id.clone(),
2147 target_id: request.target_id.clone(),
2148 title: None,
2149 project_directory: request.project_directory.clone(),
2150 dirty_ack: Vec::new(),
2151 };
2152 validate_action(&action, &state.snapshot_rx.borrow())?;
2153 let (reply, result) = tokio::sync::oneshot::channel();
2154 state
2155 .preflight_tx
2156 .send(PreflightRequest {
2157 bundle_id: request.bundle_id,
2158 target_id: request.target_id,
2159 project_directory: request.project_directory,
2160 reply,
2161 })
2162 .await
2163 .map_err(|_| ApiError::controller_unavailable())?;
2164 result
2165 .await
2166 .map_err(|_| ApiError::controller_unavailable())?
2167 .map(Json)
2168 .map_err(|failure| match failure {
2169 PreflightFailure::Validation if project_validation => ApiError::bad_request(
2170 "project validation failed; check that the directory exists, is accessible, and contains a Git repository with a valid HEAD",
2171 ),
2172 PreflightFailure::InvalidRepository(_) => ApiError::bad_request(
2173 "could not resolve the network repository; check its remote URL, authentication, connectivity, and default branch. Repositories without network remotes require a raw local session",
2174 ),
2175 PreflightFailure::Validation => ApiError::bad_request(
2176 "isolated session repositories need a network Git remote; use a raw local target for a local-only checkout",
2177 ),
2178 PreflightFailure::Controller(_) => ApiError::new(
2179 StatusCode::SERVICE_UNAVAILABLE,
2180 "the controller could not check this project",
2181 ),
2182 })
2183}
2184
2185async fn prepare_move(
2190 State(state): State<ServerState>,
2191 Json(selection): Json<MoveSelection>,
2192) -> Result<Json<MovePreparation>, ApiError> {
2193 validate_move_selection(&selection, &state.snapshot_rx.borrow())?;
2194 let (reply, result) = tokio::sync::oneshot::channel();
2195 state
2196 .move_preparation_tx
2197 .send(MovePreparationRequest { selection, reply })
2198 .await
2199 .map_err(|_| ApiError::controller_unavailable())?;
2200 let preparation = result
2201 .await
2202 .map_err(|_| ApiError::controller_unavailable())?
2203 .map_err(|error| {
2204 tracing::debug!(error = %error, "move preparation was rejected");
2205 ApiError::new(
2206 StatusCode::CONFLICT,
2207 "move preparation was rejected; refresh and try again",
2208 )
2209 })?;
2210 Ok(Json(inspector_move_preparation(preparation)))
2211}
2212
2213fn inspector_move_preparation(mut preparation: MovePreparation) -> MovePreparation {
2218 for command in &mut preparation.queued_commands {
2219 for block in &mut command.content {
2220 if block.get("type").and_then(serde_json::Value::as_str) != Some("image") {
2221 continue;
2222 }
2223 let mime = block
2224 .get("mimeType")
2225 .or_else(|| block.get("mime_type"))
2226 .and_then(serde_json::Value::as_str)
2227 .unwrap_or("image");
2228 *block = serde_json::json!({
2229 "type": "text",
2230 "text": format!("[Image attachment: {mime}]")
2231 });
2232 }
2233 }
2234 preparation
2235}
2236
2237async fn ask_client_state<T>(
2239 state: &ServerState,
2240 build: impl FnOnce(tokio::sync::oneshot::Sender<Result<T, String>>) -> ClientStateRequest,
2241) -> Result<T, ApiError> {
2242 let (reply, answer) = tokio::sync::oneshot::channel();
2243 state
2244 .client_state_tx
2245 .send(build(reply))
2246 .await
2247 .map_err(|_| ApiError::controller_unavailable())?;
2248 answer
2249 .await
2250 .map_err(|_| ApiError::controller_unavailable())?
2251 .map_err(|_| {
2252 ApiError::new(
2253 StatusCode::SERVICE_UNAVAILABLE,
2254 "the controller could not reach stored viewer state",
2255 )
2256 })
2257}
2258
2259async fn client_state(
2261 State(state): State<ServerState>,
2262 Path(session_id): Path<String>,
2263 headers: HeaderMap,
2264) -> Result<Json<ViewerClientState>, ApiError> {
2265 validate_public_id(&session_id)?;
2266 require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
2267 let Some(client_id) = viewer_client_id(&state, &headers) else {
2271 return Ok(Json(ViewerClientState::default()));
2272 };
2273 ask_client_state(&state, |reply| ClientStateRequest::Read {
2274 client_id,
2275 session_id,
2276 reply,
2277 })
2278 .await
2279 .map(Json)
2280}
2281
2282#[derive(Debug, Serialize)]
2283struct DictationAvailability {
2284 available: bool,
2285 #[serde(skip_serializing_if = "Option::is_none")]
2286 reason: Option<String>,
2287}
2288
2289#[derive(Debug, Serialize)]
2290struct DictationTranscript {
2291 text: String,
2292}
2293
2294async fn dictation_availability(
2298 State(state): State<ServerState>,
2299 Path(session_id): Path<String>,
2300) -> Result<Json<DictationAvailability>, ApiError> {
2301 validate_public_id(&session_id)?;
2302 require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
2303 let _permit = state
2304 .dictation_probe_permits
2305 .clone()
2306 .try_acquire_owned()
2307 .map_err(|_| ApiError::new(StatusCode::TOO_MANY_REQUESTS, "too many dictation requests"))?;
2308 let result = dispatch_dictation(&state, session_id, DictationOperation::Availability).await?;
2309 match result {
2310 DictationResponse::Availability { available, reason } => {
2311 Ok(Json(DictationAvailability { available, reason }))
2312 }
2313 DictationResponse::Transcript { .. } => Err(ApiError::new(
2314 StatusCode::INTERNAL_SERVER_ERROR,
2315 "the controller returned an invalid dictation response",
2316 )),
2317 }
2318}
2319
2320async fn upload_dictation(
2324 State(state): State<ServerState>,
2325 Path(session_id): Path<String>,
2326 request: Request,
2327) -> Result<Json<DictationTranscript>, ApiError> {
2328 validate_public_id(&session_id)?;
2329 require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
2330 let _permit = state
2331 .dictation_permits
2332 .clone()
2333 .try_acquire_owned()
2334 .map_err(|_| ApiError::new(StatusCode::TOO_MANY_REQUESTS, "too many dictation requests"))?;
2335
2336 if request
2337 .headers()
2338 .get(axum::http::header::CONTENT_LENGTH)
2339 .and_then(|value| value.to_str().ok())
2340 .and_then(|value| value.parse::<u64>().ok())
2341 .is_some_and(|length| length > MAX_AUDIO_BYTES as u64)
2342 {
2343 return Err(ApiError::new(
2344 StatusCode::PAYLOAD_TOO_LARGE,
2345 "audio upload is too large",
2346 ));
2347 }
2348 let body = tokio::select! {
2349 biased;
2350 _ = state.shutdown.cancelled() => return Err(ApiError::controller_unavailable()),
2351 result = tokio::time::timeout(
2352 crate::hel_dictation::DICTATION_TIMEOUT,
2353 to_bytes(request.into_body(), MAX_AUDIO_BYTES),
2354 ) => match result {
2355 Ok(result) => result.map_err(|_| {
2356 ApiError::new(StatusCode::PAYLOAD_TOO_LARGE, "audio upload is too large")
2357 })?,
2358 Err(_) => return Err(ApiError::new(
2359 StatusCode::GATEWAY_TIMEOUT,
2360 "dictation upload timed out",
2361 )),
2362 },
2363 };
2364 let audio = body.clone();
2367 tokio::task::spawn_blocking(move || validate_wav(&audio))
2368 .await
2369 .map_err(|error| {
2370 tracing::warn!(%error, "dictation audio validation task failed");
2371 ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "audio validation failed")
2372 })?
2373 .map_err(dictation_api_error)?;
2374 let result =
2375 dispatch_dictation(&state, session_id, DictationOperation::Transcribe(body)).await?;
2376 match result {
2377 DictationResponse::Transcript { text } => Ok(Json(DictationTranscript { text })),
2378 DictationResponse::Availability { .. } => Err(ApiError::new(
2379 StatusCode::INTERNAL_SERVER_ERROR,
2380 "the controller returned an invalid dictation response",
2381 )),
2382 }
2383}
2384
2385struct DictationCancellationGuard(CancellationToken);
2388
2389impl Drop for DictationCancellationGuard {
2390 fn drop(&mut self) {
2391 self.0.cancel();
2392 }
2393}
2394
2395async fn dispatch_dictation(
2396 state: &ServerState,
2397 session_id: String,
2398 operation: DictationOperation,
2399) -> Result<DictationResponse, ApiError> {
2400 let cancel = CancellationToken::new();
2401 let _guard = DictationCancellationGuard(cancel.clone());
2402 let (reply, answer) = tokio::sync::oneshot::channel();
2403 let request = DictationRequest {
2404 session_id,
2405 operation,
2406 cancel: cancel.clone(),
2407 reply,
2408 };
2409 tokio::select! {
2410 biased;
2411 _ = state.shutdown.cancelled() => return Err(ApiError::controller_unavailable()),
2412 result = state.dictation_tx.send(request) => {
2413 result.map_err(|_| ApiError::controller_unavailable())?;
2414 }
2415 }
2416 let answer = tokio::select! {
2417 biased;
2418 _ = state.shutdown.cancelled() => return Err(ApiError::controller_unavailable()),
2419 result = answer => result.map_err(|_| ApiError::controller_unavailable())?,
2420 };
2421 answer.map_err(dictation_api_error)
2422}
2423
2424fn dictation_api_error(error: DictationError) -> ApiError {
2425 match error {
2426 DictationError::SessionNotFound => ApiError::not_found("unknown session"),
2427 DictationError::CredentialsUnavailable => ApiError::new(
2428 StatusCode::SERVICE_UNAVAILABLE,
2429 "dictation is unavailable because no Codex subscription is signed in",
2430 ),
2431 DictationError::InvalidAudio(message) => ApiError::bad_request(message),
2432 DictationError::Cancelled => {
2433 ApiError::new(StatusCode::REQUEST_TIMEOUT, "dictation cancelled")
2434 }
2435 DictationError::TimedOut => ApiError::new(
2436 StatusCode::GATEWAY_TIMEOUT,
2437 "dictation transcription timed out",
2438 ),
2439 DictationError::CredentialProbe => ApiError::new(
2440 StatusCode::SERVICE_UNAVAILABLE,
2441 "dictation credentials could not be checked",
2442 ),
2443 DictationError::Provider(error) => {
2444 tracing::warn!(%error, "Codex dictation transcription failed");
2445 ApiError::new(StatusCode::BAD_GATEWAY, "dictation transcription failed")
2446 }
2447 }
2448}
2449
2450#[derive(Debug, Deserialize)]
2451#[serde(deny_unknown_fields)]
2452struct DraftRequest {
2453 draft: String,
2454}
2455
2456async fn save_draft(
2457 State(state): State<ServerState>,
2458 Path(session_id): Path<String>,
2459 headers: HeaderMap,
2460 Json(request): Json<DraftRequest>,
2461) -> Result<StatusCode, ApiError> {
2462 validate_public_id(&session_id)?;
2463 require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
2464 if request.draft.len() > MAX_DRAFT_BYTES {
2465 return Err(ApiError::new(
2466 StatusCode::PAYLOAD_TOO_LARGE,
2467 "draft must be 65536 bytes or fewer",
2468 ));
2469 }
2470 let Some(client_id) = viewer_client_id(&state, &headers) else {
2471 return Err(ApiError::new(
2474 StatusCode::CONFLICT,
2475 "this viewer has no stored identity; unlock again to keep drafts",
2476 ));
2477 };
2478 ask_client_state(&state, |reply| ClientStateRequest::SaveDraft {
2479 client_id,
2480 session_id,
2481 draft: request.draft,
2482 reply,
2483 })
2484 .await?;
2485 Ok(StatusCode::NO_CONTENT)
2486}
2487
2488async fn mark_workspace_read(
2492 State(state): State<ServerState>,
2493 Path(workspace_id): Path<String>,
2494 headers: HeaderMap,
2495) -> Result<StatusCode, ApiError> {
2496 validate_public_id(&workspace_id)?;
2497 let Some(client_id) = viewer_client_id(&state, &headers) else {
2498 return Ok(StatusCode::NO_CONTENT);
2499 };
2500 ask_client_state(&state, |reply| ClientStateRequest::MarkWorkspaceRead {
2501 client_id,
2502 workspace_id,
2503 reply,
2504 })
2505 .await?;
2506 Ok(StatusCode::NO_CONTENT)
2507}
2508
2509#[derive(Debug, Deserialize)]
2510struct HistoryQuery {
2511 #[serde(default)]
2512 q: String,
2513 #[serde(default)]
2514 scope: Option<String>,
2515}
2516
2517async fn prompt_history(
2519 State(state): State<ServerState>,
2520 Path(session_id): Path<String>,
2521 Query(query): Query<HistoryQuery>,
2522) -> Result<Json<ViewerPromptHistory>, ApiError> {
2523 validate_public_id(&session_id)?;
2524 require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
2525 if query.q.chars().count() > MAX_TITLE_CHARS {
2526 return Err(ApiError::bad_request("search text is too long"));
2527 }
2528 let scope = query.scope.unwrap_or_else(|| "project".to_owned());
2529 if !matches!(scope.as_str(), "session" | "project" | "all") {
2530 return Err(ApiError::bad_request(
2531 "scope must be session, project or all",
2532 ));
2533 }
2534 ask_client_state(&state, |reply| ClientStateRequest::History {
2535 session_id,
2536 query: query.q,
2537 scope,
2538 reply,
2539 })
2540 .await
2541 .map(Json)
2542}
2543
2544async fn events(State(state): State<ServerState>) -> impl IntoResponse {
2545 let mut snapshots = state.snapshot_rx.clone();
2546 let (tx, rx) = mpsc::channel::<Result<Event, Infallible>>(8);
2547 tokio::spawn(async move {
2548 let initial = snapshots.borrow().revision;
2549 if tx
2550 .send(Ok(Event::default()
2551 .event("revision")
2552 .data(initial.to_string())))
2553 .await
2554 .is_err()
2555 {
2556 return;
2557 }
2558 while snapshots.changed().await.is_ok() {
2559 let revision = snapshots.borrow_and_update().revision;
2560 if tx
2561 .send(Ok(Event::default()
2562 .event("revision")
2563 .data(revision.to_string())))
2564 .await
2565 .is_err()
2566 {
2567 break;
2568 }
2569 }
2570 });
2571 Sse::new(ReceiverStream::new(rx)).keep_alive(KeepAlive::default())
2572}
2573
2574async fn decode_prompt_images_off_task(
2578 action: ControllerAction,
2579) -> Result<ControllerAction, ApiError> {
2580 let ControllerAction::Prompt { images, .. } = &action else {
2581 return Ok(action);
2582 };
2583 if images.is_empty() {
2584 return Ok(action);
2585 }
2586 tokio::task::spawn_blocking(move || {
2587 let mut action = action;
2588 let ControllerAction::Prompt {
2589 session_id, images, ..
2590 } = &action
2591 else {
2592 unreachable!("only prompt actions carry images")
2593 };
2594 validate_prompt_images(images)?;
2595 let store = AttachmentStore::controller(session_id).map_err(|_| {
2596 ApiError::new(
2597 StatusCode::INTERNAL_SERVER_ERROR,
2598 "could not open the image attachment store",
2599 )
2600 })?;
2601 let ControllerAction::Prompt { images, .. } = &mut action else {
2602 unreachable!("only prompt actions carry images")
2603 };
2604 for image in images {
2605 if let Some(reference) = image.attachment.clone() {
2606 store
2610 .read(&reference)
2611 .map_err(|_| ApiError::bad_request("the image attachment is unavailable"))?;
2612 image.data_base64.clear();
2613 image.mime_type = reference.mime_type;
2614 image.width = reference.width;
2615 image.height = reference.height;
2616 } else {
2617 let bytes = base64::engine::general_purpose::STANDARD
2618 .decode(&image.data_base64)
2619 .map_err(|_| ApiError::bad_request("image data must be valid base64"))?;
2620 let optimized = optimize_image(&bytes).map_err(|_| {
2621 ApiError::bad_request("unsupported image format or image could not be decoded")
2622 })?;
2623 let reference = AttachmentRef::new(
2624 &optimized.bytes,
2625 optimized.mime_type.clone(),
2626 optimized.width,
2627 optimized.height,
2628 )
2629 .map_err(|_| {
2630 ApiError::bad_request("the inline image could not become an attachment")
2631 })?;
2632 store.install(&reference, &optimized.bytes).map_err(|_| {
2633 ApiError::new(
2634 StatusCode::INTERNAL_SERVER_ERROR,
2635 "could not store the image attachment",
2636 )
2637 })?;
2638 image.data_base64.clear();
2639 image.attachment = Some(reference);
2640 image.mime_type = optimized.mime_type;
2641 image.width = optimized.width;
2642 image.height = optimized.height;
2643 }
2644 }
2645 Ok(action)
2646 })
2647 .await
2648 .map_err(|_| {
2649 ApiError::new(
2650 StatusCode::INTERNAL_SERVER_ERROR,
2651 "the server could not check the attached images",
2652 )
2653 })?
2654}
2655
2656fn validate_prompt_images(images: &[ViewerPromptImage]) -> Result<(), ApiError> {
2657 if images.len() > MAX_PROMPT_IMAGES {
2658 return Err(ApiError::bad_request(
2659 "a prompt may contain at most 10 images",
2660 ));
2661 }
2662 for image in images {
2663 if !image.mime_type.starts_with("image/") {
2664 return Err(ApiError::bad_request(
2665 "image mime type must start with image/",
2666 ));
2667 }
2668 if image.width == 0 || image.height == 0 {
2669 return Err(ApiError::bad_request(
2670 "image dimensions must be greater than zero",
2671 ));
2672 }
2673 if let Some(reference) = &image.attachment {
2674 if !image.data_base64.is_empty() {
2675 return Err(ApiError::bad_request(
2676 "an image cannot contain both inline data and an attachment",
2677 ));
2678 }
2679 if reference.mime_type != image.mime_type
2680 || reference.width != image.width
2681 || reference.height != image.height
2682 {
2683 return Err(ApiError::bad_request(
2684 "image attachment metadata does not match the prompt",
2685 ));
2686 }
2687 continue;
2688 }
2689 let bytes = base64::engine::general_purpose::STANDARD
2690 .decode(&image.data_base64)
2691 .map_err(|_| ApiError::bad_request("image data must be valid base64"))?;
2692 if bytes.is_empty() {
2693 return Err(ApiError::bad_request("image data must not be empty"));
2694 }
2695 }
2696 Ok(())
2697}
2698
2699const MAX_MOVE_QUEUE_ITEMS: usize = 256;
2700const MAX_MOVE_MOUNTS: usize = 32;
2701
2702fn validate_move_selection(
2703 selection: &MoveSelection,
2704 snapshot: &ViewerSnapshot,
2705) -> Result<(), ApiError> {
2706 validate_public_id(&selection.session_id)?;
2707 if selection.profile_id.is_none() && selection.target_template_id.is_none() {
2708 return Err(ApiError::bad_request(
2709 "move must select a profile, a target, or both",
2710 ));
2711 }
2712 if selection.clear_resource_allocation && selection.resource_allocation.is_some() {
2713 return Err(ApiError::bad_request(
2714 "clear resource sizing cannot be combined with an explicit allocation",
2715 ));
2716 }
2717 if let Some(profile_id) = selection.profile_id.as_deref() {
2718 validate_public_id(profile_id)?;
2719 require_profile(snapshot, profile_id)?;
2720 }
2721 if let Some(target_id) = selection.target_template_id.as_deref() {
2722 validate_public_id(target_id)?;
2723 require_target(snapshot, target_id)?;
2724 let session = require_session_record(snapshot, &selection.session_id)?;
2725 if session
2726 .incompatible_resume_targets
2727 .iter()
2728 .any(|id| id == target_id)
2729 {
2730 return Err(ApiError::bad_request(
2731 "this session cannot resume on that target",
2732 ));
2733 }
2734 } else {
2735 require_session_record(snapshot, &selection.session_id)?;
2736 }
2737 if let Some(mounts) = &selection.additional_mounts {
2738 validate_move_mounts(mounts)?;
2739 }
2740 let session = require_session_record(snapshot, &selection.session_id)?;
2741 let retryable_move = session
2742 .move_recovery
2743 .as_ref()
2744 .is_some_and(|recovery| recovery.checkpoint_retained);
2745 if !session.capabilities.move_session && !retryable_move {
2746 return Err(ApiError::new(
2747 StatusCode::CONFLICT,
2748 "this session cannot be moved now",
2749 ));
2750 }
2751 Ok(())
2752}
2753
2754fn validate_move_mounts(mounts: &[AdditionalMount]) -> Result<(), ApiError> {
2755 if mounts.len() > MAX_MOVE_MOUNTS {
2756 return Err(ApiError::bad_request("a move may carry at most 32 mounts"));
2757 }
2758 for mount in mounts {
2759 for path in [&mount.source, &mount.destination] {
2760 if !path.is_absolute()
2761 || path
2762 .components()
2763 .any(|component| component == Component::ParentDir)
2764 {
2765 return Err(ApiError::bad_request(
2766 "move mount paths must be absolute and must not contain '..'",
2767 ));
2768 }
2769 }
2770 }
2771 Ok(())
2772}
2773
2774fn validate_resume_settings(
2775 additional_mounts: Option<&Vec<AdditionalMount>>,
2776 resource_allocation: Option<&SessionResourceAllocation>,
2777) -> Result<(), ApiError> {
2778 if let Some(mounts) = additional_mounts {
2779 validate_move_mounts(mounts)?;
2780 }
2781 if let Some(allocation) = resource_allocation {
2782 allocation
2783 .validate()
2784 .map_err(|_| ApiError::bad_request("resource allocation is invalid"))?;
2785 }
2786 Ok(())
2787}
2788
2789fn validate_move_request(
2790 request: &MoveSessionRequest,
2791 snapshot: &ViewerSnapshot,
2792) -> Result<(), ApiError> {
2793 let preparation = &request.preparation;
2794 validate_move_selection(&preparation.selection, snapshot)?;
2795 let session = require_session_record(snapshot, &preparation.selection.session_id)?;
2796 if preparation.operation_id.trim().is_empty() || preparation.fingerprint.trim().is_empty() {
2797 return Err(ApiError::bad_request(
2798 "move confirmation is missing its preparation identity",
2799 ));
2800 }
2801 if preparation.queued_commands.len() > MAX_MOVE_QUEUE_ITEMS {
2802 return Err(ApiError::bad_request(
2803 "move queue is too large; prepare again",
2804 ));
2805 }
2806 let active_now = preparation.active
2807 || session.chat_phase == ViewerChatPhase::Running
2808 || !session.active_user_shells.is_empty();
2809 if active_now && !request.acknowledge_interruption {
2810 return Err(ApiError::new(
2811 StatusCode::CONFLICT,
2812 "confirm that the active turn may be interrupted",
2813 ));
2814 }
2815 if !preparation.queued_commands.is_empty() && request.queue.is_none() {
2816 return Err(ApiError::bad_request(
2817 "choose whether queued work is discarded or started after the move",
2818 ));
2819 }
2820 Ok(())
2821}
2822
2823fn validate_action(action: &ControllerAction, snapshot: &ViewerSnapshot) -> Result<(), ApiError> {
2824 match action {
2825 ControllerAction::New {
2826 workspace_id,
2827 profile_id,
2828 bundle_id,
2829 target_id,
2830 title,
2831 project_directory,
2832 dirty_ack,
2833 } => {
2834 if !workspace_id.is_empty() {
2835 validate_public_id(workspace_id)?;
2836 }
2837 validate_public_id(profile_id)?;
2838 validate_public_id(bundle_id)?;
2839 validate_public_id(target_id)?;
2840 if let Some(title) = title {
2841 validate_title(title)?;
2842 }
2843 if dirty_ack.len() > MAX_DIRTY_ACKNOWLEDGEMENTS
2847 || dirty_ack
2848 .iter()
2849 .any(|repository| repository.trim().is_empty() || repository.len() > 256)
2850 {
2851 return Err(ApiError::bad_request(
2852 "dirty acknowledgement must name 0-32 repositories",
2853 ));
2854 }
2855 require_profile(snapshot, profile_id)?;
2856 require_bundle(snapshot, bundle_id)?;
2857 let target = require_target(snapshot, target_id)?;
2858 if target.requires_project_directory != project_directory.is_some() {
2859 return Err(ApiError::bad_request(
2860 "project_directory is required exactly for bare targets",
2861 ));
2862 }
2863 if let Some(directory) = project_directory
2864 && (!directory.is_absolute()
2865 || directory
2866 .components()
2867 .any(|component| component == Component::ParentDir))
2868 {
2869 return Err(ApiError::bad_request(
2870 "project_directory must be an absolute safe path",
2871 ));
2872 }
2873 }
2874 ControllerAction::Resume {
2875 session_id,
2876 workspace_id,
2877 profile_id,
2878 target_id,
2879 additional_mounts,
2880 resource_allocation,
2881 ..
2882 } => {
2883 validate_public_id(session_id)?;
2884 validate_public_id(workspace_id)?;
2885 validate_public_id(profile_id)?;
2886 validate_public_id(target_id)?;
2887 let session = require_session_record(snapshot, session_id)?;
2888 require_workspace(snapshot, workspace_id)?;
2889 require_profile(snapshot, profile_id)?;
2890 require_target(snapshot, target_id)?;
2891 if session
2892 .incompatible_resume_targets
2893 .iter()
2894 .any(|incompatible| incompatible == target_id)
2895 {
2896 return Err(ApiError::bad_request(
2897 "this session cannot resume on that target",
2898 ));
2899 }
2900 validate_resume_settings(additional_mounts.as_ref(), resource_allocation.as_ref())?;
2901 }
2902 ControllerAction::Move { request } => validate_move_request(request, snapshot)?,
2903 ControllerAction::Open { session_id }
2904 | ControllerAction::Close { session_id }
2905 | ControllerAction::Cancel { session_id }
2906 | ControllerAction::StartReview { session_id } => {
2907 validate_public_id(session_id)?;
2908 require_session_record(snapshot, session_id)?;
2909 }
2910 ControllerAction::ResolveReview {
2911 session_id,
2912 resolution,
2913 } => {
2914 validate_public_id(session_id)?;
2915 let session = require_session_record(snapshot, session_id)?;
2916 let Some(resolution) = resolution_from_name(resolution) else {
2917 return Err(ApiError::bad_request(
2918 "a review is resolved by forward, dismiss, or cancel",
2919 ));
2920 };
2921 let Some(review) = session.turn_review.as_ref() else {
2922 return Err(ApiError::bad_request("no review is open for that session"));
2923 };
2924 let allowed = resolution == hel::hel_review::driver::Resolution::Cancelled
2928 || review.verdict.as_ref().is_some_and(|verdict| {
2929 resolution_name(&resolution)
2930 .is_some_and(|name| verdict.allowed.iter().any(|allowed| allowed == name))
2931 });
2932 if !allowed {
2933 return Err(ApiError::bad_request(
2934 "that review cannot be resolved that way yet",
2935 ));
2936 }
2937 }
2938 ControllerAction::Rename { session_id, title } => {
2939 validate_public_id(session_id)?;
2940 validate_title(title)?;
2941 let session = require_session_record(snapshot, session_id)?;
2942 if !session.capabilities.rename {
2943 return Err(ApiError::bad_request("this session cannot be renamed"));
2944 }
2945 }
2946 ControllerAction::CancelTurn { session_id } => {
2947 validate_public_id(session_id)?;
2948 let session = require_session_record(snapshot, session_id)?;
2949 if !session.capabilities.cancel_turn {
2950 return Err(ApiError::new(
2951 StatusCode::CONFLICT,
2952 "this session has no turn to cancel",
2953 ));
2954 }
2955 }
2956 ControllerAction::SetConfig {
2957 session_id,
2958 key,
2959 value,
2960 } => {
2961 validate_public_id(session_id)?;
2962 let session = require_session_record(snapshot, session_id)?;
2963 if !session.capabilities.set_config {
2964 return Err(ApiError::bad_request(
2965 "this session cannot change configuration now",
2966 ));
2967 }
2968 let option = session
2972 .config_options
2973 .iter()
2974 .find(|option| option.key == *key)
2975 .ok_or_else(|| ApiError::bad_request("this agent does not offer that setting"))?;
2976 if !option.choices.iter().any(|choice| choice.value == *value) {
2977 return Err(ApiError::bad_request(
2978 "this agent does not offer that value for that setting",
2979 ));
2980 }
2981 }
2982 ControllerAction::SetPlanMode { session_id, .. } => {
2983 validate_public_id(session_id)?;
2984 let session = require_session_record(snapshot, session_id)?;
2985 if !session.capabilities.set_plan_mode {
2986 return Err(ApiError::bad_request(
2987 "this session cannot change plan mode now",
2988 ));
2989 }
2990 }
2991 ControllerAction::RefreshQuota { profile_id } => {
2992 validate_public_id(profile_id)?;
2993 require_profile(snapshot, profile_id)?;
2994 }
2995 ControllerAction::RefreshCapacity { target_id } => {
2996 validate_public_id(target_id)?;
2997 require_target(snapshot, target_id)?;
2998 }
2999 ControllerAction::Prompt {
3000 session_id,
3001 text,
3002 images,
3003 } => {
3004 validate_public_id(session_id)?;
3005 let session = require_session_record(snapshot, session_id)?;
3006 if images.len() > MAX_PROMPT_IMAGES {
3007 return Err(ApiError::bad_request(
3008 "a prompt may contain at most 10 images",
3009 ));
3010 }
3011 if text.starts_with('!') {
3012 return Err(ApiError::bad_request(
3013 "leading ! is reserved for shell commands",
3014 ));
3015 }
3016 if text.chars().count() > MAX_PROMPT_CHARS {
3017 return Err(ApiError::bad_request(
3018 "prompt must contain 1-65536 characters",
3019 ));
3020 }
3021 if text.trim().is_empty() && images.is_empty() {
3022 return Err(ApiError::bad_request(
3023 "prompt must contain text or an image",
3024 ));
3025 }
3026 if !images.is_empty() && !session.prompt_images_supported {
3027 return Err(ApiError::bad_request(
3028 "this session does not support image prompts",
3029 ));
3030 }
3031 if session.turn_review.is_some() {
3036 return Err(ApiError::bad_request(
3037 crate::hel_review_host::PROMPT_HELD_MESSAGE,
3038 ));
3039 }
3040 }
3041 ControllerAction::RunShell {
3042 session_id,
3043 command,
3044 } => {
3045 validate_public_id(session_id)?;
3046 require_session_record(snapshot, session_id)?;
3047 if command.trim().is_empty() || command.chars().count() > MAX_PROMPT_CHARS {
3048 return Err(ApiError::bad_request(
3049 "shell command must contain 1-65536 characters",
3050 ));
3051 }
3052 }
3053 ControllerAction::CancelShell {
3054 session_id,
3055 shell_command_id,
3056 } => {
3057 validate_public_id(session_id)?;
3058 validate_public_id(shell_command_id)?;
3059 let session = require_session_record(snapshot, session_id)?;
3060 if !session
3061 .active_user_shells
3062 .iter()
3063 .any(|shell| shell.id == *shell_command_id)
3064 {
3065 return Err(ApiError::bad_request("unknown active shell command"));
3066 }
3067 }
3068 ControllerAction::RemoveQueuedPrompt {
3069 session_id,
3070 queue_id,
3071 } => {
3072 validate_public_id(session_id)?;
3073 validate_public_id(queue_id)?;
3074 require_session_record(snapshot, session_id)?;
3075 }
3076 ControllerAction::RespondElicitation {
3077 session_id,
3078 elicitation_id,
3079 response,
3080 } => {
3081 validate_public_id(session_id)?;
3082 validate_public_id(elicitation_id)?;
3083 let session = require_session_record(snapshot, session_id)?;
3084 let request = session
3085 .pending_elicitations
3086 .iter()
3087 .find(|request| request.id == *elicitation_id)
3088 .ok_or_else(|| ApiError::not_found("unknown elicitation"))?;
3089 if serde_json::to_vec(response).map_or(usize::MAX, |encoded| encoded.len())
3090 > MAX_ELICITATION_BYTES
3091 {
3092 return Err(ApiError::bad_request("elicitation answer is too large"));
3093 }
3094 if request.validate_response(response).is_err() {
3099 return Err(ApiError::bad_request(
3100 "the answer does not match this elicitation request",
3101 ));
3102 }
3103 }
3104 }
3105 Ok(())
3106}
3107
3108fn validate_public_id(id: &str) -> Result<(), ApiError> {
3109 validate_id("request", id).map_err(|_| ApiError::bad_request("invalid id"))
3110}
3111
3112fn validate_title(title: &str) -> Result<(), ApiError> {
3113 if title.trim().is_empty() || title.chars().count() > MAX_TITLE_CHARS {
3114 Err(ApiError::bad_request("title must contain 1-120 characters"))
3115 } else {
3116 Ok(())
3117 }
3118}
3119
3120fn require_session_record<'a>(
3121 snapshot: &'a ViewerSnapshot,
3122 id: &str,
3123) -> Result<&'a ViewerSession, ApiError> {
3124 snapshot
3125 .sessions
3126 .iter()
3127 .find(|session| session.id == id)
3128 .ok_or_else(|| ApiError::not_found("unknown session"))
3129}
3130
3131fn require_workspace(snapshot: &ViewerSnapshot, id: &str) -> Result<(), ApiError> {
3132 snapshot
3133 .workspaces
3134 .iter()
3135 .any(|workspace| workspace.id == id)
3136 .then_some(())
3137 .ok_or_else(|| ApiError::bad_request("unknown workspace"))
3138}
3139
3140fn require_profile<'a>(
3141 snapshot: &'a ViewerSnapshot,
3142 id: &str,
3143) -> Result<&'a ViewerProfile, ApiError> {
3144 snapshot
3145 .profiles
3146 .iter()
3147 .find(|profile| profile.id == id)
3148 .ok_or_else(|| ApiError::bad_request("unknown profile"))
3149}
3150
3151fn require_target<'a>(
3152 snapshot: &'a ViewerSnapshot,
3153 id: &str,
3154) -> Result<&'a ViewerTarget, ApiError> {
3155 snapshot
3156 .targets
3157 .iter()
3158 .find(|target| target.id == id)
3159 .ok_or_else(|| ApiError::bad_request("unknown target"))
3160}
3161
3162fn require_bundle(snapshot: &ViewerSnapshot, id: &str) -> Result<(), ApiError> {
3163 snapshot
3164 .bundles
3165 .iter()
3166 .any(|bundle| bundle.id == id)
3167 .then_some(())
3168 .ok_or_else(|| ApiError::bad_request("unknown bundle"))
3169}
3170
3171#[derive(Debug, Serialize)]
3172struct ErrorBody<'a> {
3173 error: &'a str,
3174}
3175
3176#[derive(Debug)]
3177struct ApiError {
3178 status: StatusCode,
3179 message: &'static str,
3180}
3181
3182impl ApiError {
3183 const fn new(status: StatusCode, message: &'static str) -> Self {
3184 Self { status, message }
3185 }
3186
3187 const fn unauthorized() -> Self {
3188 Self::new(StatusCode::UNAUTHORIZED, "unauthorized")
3189 }
3190
3191 const fn bad_request(message: &'static str) -> Self {
3192 Self::new(StatusCode::BAD_REQUEST, message)
3193 }
3194
3195 const fn not_found(message: &'static str) -> Self {
3196 Self::new(StatusCode::NOT_FOUND, message)
3197 }
3198
3199 const fn controller_unavailable() -> Self {
3200 Self::new(StatusCode::SERVICE_UNAVAILABLE, "controller unavailable")
3201 }
3202}
3203
3204impl IntoResponse for ApiError {
3205 fn into_response(self) -> Response<Body> {
3206 (
3207 self.status,
3208 Json(ErrorBody {
3209 error: self.message,
3210 }),
3211 )
3212 .into_response()
3213 }
3214}
3215
3216fn code_locked(state: &ServerState) -> bool {
3217 state
3218 .code_guard
3219 .lock()
3220 .expect("viewer code guard poisoned")
3221 .locked_at(Instant::now())
3222}
3223
3224fn record_code_failure(state: &ServerState) {
3225 state
3226 .code_guard
3227 .lock()
3228 .expect("viewer code guard poisoned")
3229 .record_failure_at(Instant::now());
3230}
3231
3232fn reset_code_failures(state: &ServerState) {
3233 *state.code_guard.lock().expect("viewer code guard poisoned") = CodeGuard::default();
3234}
3235
3236fn generate_viewer_code() -> AnyResult<String> {
3237 const RANGE: u32 = 1_000_000;
3240 const LIMIT: u32 = u32::MAX - (u32::MAX % RANGE);
3241 loop {
3242 let mut bytes = [0_u8; 4];
3243 getrandom::fill(&mut bytes)
3244 .map_err(|error| anyhow::anyhow!("generate Mjolnir viewer code: {error}"))?;
3245 let value = u32::from_le_bytes(bytes);
3246 if value < LIMIT {
3247 return Ok(format!("{:06}", value % RANGE));
3248 }
3249 }
3250}
3251
3252fn generate_login_token() -> AnyResult<String> {
3253 let mut token = [0_u8; 32];
3254 getrandom::fill(&mut token)
3255 .map_err(|error| anyhow::anyhow!("generate Mjolnir viewer login token: {error}"))?;
3256 Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(token))
3257}
3258
3259fn generate_cookie_key() -> AnyResult<[u8; COOKIE_KEY_BYTES]> {
3260 let mut key = [0_u8; COOKIE_KEY_BYTES];
3261 getrandom::fill(&mut key)
3262 .map_err(|error| anyhow::anyhow!("generate Mjolnir cookie key: {error}"))?;
3263 Ok(key)
3264}
3265
3266fn generate_viewer_id() -> AnyResult<String> {
3274 let mut id = [0_u8; 16];
3275 getrandom::fill(&mut id)
3276 .map_err(|error| anyhow::anyhow!("generate Mjolnir viewer id: {error}"))?;
3277 Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(id))
3278}
3279
3280fn signed_cookie_value(key: &[u8], viewer: &str, expiry: u64) -> String {
3281 let canonical = format!("{viewer}|{expiry}");
3284 let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts arbitrary key lengths");
3285 mac.update(canonical.as_bytes());
3286 let signature =
3287 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
3288 format!("{viewer}.{expiry}.{signature}")
3289}
3290
3291fn legacy_signed_cookie_value(key: &[u8], expiry: u64) -> String {
3297 let canonical = expiry.to_string();
3298 let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts arbitrary key lengths");
3299 mac.update(canonical.as_bytes());
3300 let signature =
3301 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
3302 format!("{canonical}.{signature}")
3303}
3304
3305fn session_cookie_valid(key: &[u8], value: &str, now: u64) -> bool {
3306 cookie_viewer(key, value, now).is_some()
3307}
3308
3309pub fn mint_desktop_session_cookie(key: &[u8]) -> AnyResult<String> {
3316 let viewer = generate_viewer_id()?;
3317 Ok(signed_cookie_value(
3318 key,
3319 &viewer,
3320 now_unix().saturating_add(EPHEMERAL_SESSION_TTL.as_secs()),
3321 ))
3322}
3323
3324fn cookie_viewer(key: &[u8], value: &str, now: u64) -> Option<Option<String>> {
3329 let parts = value.split('.').collect::<Vec<_>>();
3330 let (viewer, expiry, expected) = match parts.as_slice() {
3331 [viewer, expiry, _] => {
3332 let expiry_value = expiry.parse::<u64>().ok()?;
3333 (
3334 Some((*viewer).to_owned()),
3335 expiry_value,
3336 signed_cookie_value(key, viewer, expiry_value),
3337 )
3338 }
3339 [expiry, _] => {
3340 let expiry_value = expiry.parse::<u64>().ok()?;
3341 (
3342 None,
3343 expiry_value,
3344 legacy_signed_cookie_value(key, expiry_value),
3345 )
3346 }
3347 _ => return None,
3348 };
3349 if now >= expiry {
3350 return None;
3351 }
3352 constant_time_eq(expected.as_bytes(), value.as_bytes()).then_some(viewer)
3353}
3354
3355fn session_cookie_header(
3356 value: &str,
3357 max_age: Option<u64>,
3358 secure: bool,
3359) -> Result<HeaderValue, ApiError> {
3360 let mut header = format!("{COOKIE_NAME}={value}; Path=/; HttpOnly; SameSite=Strict");
3361 if secure {
3362 header.push_str("; Secure");
3363 }
3364 if let Some(max_age) = max_age {
3365 header.push_str(&format!("; Max-Age={max_age}"));
3366 }
3367 HeaderValue::from_str(&header)
3368 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "cookie creation failed"))
3369}
3370
3371fn clear_cookie_header(secure: bool) -> HeaderValue {
3372 let secure = if secure { "; Secure" } else { "" };
3373 HeaderValue::from_str(&format!(
3374 "{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Strict{secure}; Max-Age=0"
3375 ))
3376 .expect("static cookie header is valid")
3377}
3378
3379fn viewer_client_id(state: &ServerState, headers: &HeaderMap) -> Option<String> {
3386 let cookie = headers
3387 .get(COOKIE)
3388 .and_then(|value| value.to_str().ok())
3389 .and_then(|header| cookie_value(header, COOKIE_NAME))?;
3390 cookie_viewer(&state.cookie_key, cookie, now_unix())
3391 .flatten()
3392 .map(|viewer| format!("phone:{viewer}"))
3393}
3394
3395fn cookie_value<'a>(header: &'a str, name: &str) -> Option<&'a str> {
3396 header
3397 .split(';')
3398 .filter_map(|part| part.trim().split_once('='))
3399 .find(|(cookie_name, _)| *cookie_name == name)
3400 .map(|(_, value)| value)
3401}
3402
3403fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
3404 if left.len() != right.len() {
3405 return false;
3406 }
3407 left.iter()
3408 .zip(right)
3409 .fold(0_u8, |difference, (left, right)| {
3410 difference | (left ^ right)
3411 })
3412 == 0
3413}
3414
3415fn now_unix() -> u64 {
3416 SystemTime::now()
3417 .duration_since(UNIX_EPOCH)
3418 .map(|elapsed| elapsed.as_secs())
3419 .unwrap_or(u64::MAX)
3420}
3421
3422const fn session_state_name(state: SessionState) -> &'static str {
3423 match state {
3424 SessionState::Provisioning => "provisioning",
3425 SessionState::Running => "running",
3426 SessionState::Disconnected => "disconnected",
3427 SessionState::Checkpointing => "checkpointing",
3428 SessionState::Closing => "closing",
3429 SessionState::Destroying => "destroying",
3430 SessionState::Stopped => "stopped",
3431 SessionState::Lost => "lost",
3432 SessionState::Error => "error",
3433 SessionState::DestroyedWithDataLoss => "destroyed-with-data-loss",
3434 }
3435}
3436
3437const fn target_kind_name(target: &TargetTemplate) -> &'static str {
3438 match target {
3439 TargetTemplate::LocalBare => "local-bare",
3440 TargetTemplate::LocalPodman { .. } => "local-podman",
3441 TargetTemplate::LocalDocker { .. } => "local-docker",
3442 TargetTemplate::AppleContainer { .. } => "apple-container",
3443 TargetTemplate::AwsEc2 { .. } => "aws-ec2",
3444 TargetTemplate::SshBare { .. } => "ssh-bare",
3445 TargetTemplate::SshPodman { .. } => "ssh-podman",
3446 TargetTemplate::SshDocker { .. } => "ssh-docker",
3447 }
3448}
3449
3450const VIEWER_HTML: &str = include_str!("web/viewer.html");
3455const VIEWER_CSS: &str = include_str!("web/viewer.css");
3456const VIEWER_JS: &str = include_str!("web/viewer.js");
3457const MARKDOWN_JS: &str = include_str!("web/markdown.js");
3458const TOOL_OUTPUT_JS: &str = include_str!("web/tool-output.js");
3459const VOICE_WORKLET_JS: &str = include_str!("web/voice-worklet.js");
3460const VOICE_WORKER_JS: &str = include_str!("web/voice-worker.js");
3461#[cfg(test)]
3465const TEST_DOM_JS: &str = include_str!("web/test-dom.js");
3466const SERVICE_WORKER: &str = include_str!("web/service-worker.js");
3467const MANIFEST: &str = include_str!("web/manifest.webmanifest");
3468const ICON_SVG: &str = include_str!("../src/icons/icon.svg");
3469const ICON_192: &[u8] = include_bytes!("../src/icons/icon-192.png");
3470const ICON_512: &[u8] = include_bytes!("../src/icons/icon-512.png");
3471const MASKABLE_512: &[u8] = include_bytes!("../src/icons/maskable-512.png");
3472const APPLE_TOUCH_ICON: &[u8] = include_bytes!("../src/icons/apple-touch-icon.png");
3473const MONO_FONT: &[u8] = include_bytes!("../src/fonts/jetbrains-mono.woff2");
3474
3475const CONTENT_SECURITY_POLICY: &str = "default-src 'none'; \
3483script-src 'self'; \
3484style-src 'self'; \
3485img-src 'self' data: blob:; \
3486font-src 'self'; \
3487connect-src 'self'; \
3488manifest-src 'self'; \
3489base-uri 'none'; \
3490form-action 'none'; \
3491frame-ancestors 'none'";
3492
3493async fn viewer() -> Response<Body> {
3494 static_response("text/html; charset=utf-8", VIEWER_HTML, true)
3495}
3496
3497async fn viewer_css() -> Response<Body> {
3498 static_response("text/css; charset=utf-8", VIEWER_CSS, false)
3499}
3500
3501async fn viewer_js() -> Response<Body> {
3502 static_response("text/javascript; charset=utf-8", VIEWER_JS, false)
3503}
3504
3505async fn markdown_js() -> Response<Body> {
3506 static_response("text/javascript; charset=utf-8", MARKDOWN_JS, false)
3507}
3508
3509async fn voice_worklet_js() -> Response<Body> {
3510 static_response("text/javascript; charset=utf-8", VOICE_WORKLET_JS, false)
3511}
3512
3513async fn voice_worker_js() -> Response<Body> {
3514 static_response("text/javascript; charset=utf-8", VOICE_WORKER_JS, false)
3515}
3516
3517async fn tool_output_js() -> Response<Body> {
3518 static_response("text/javascript; charset=utf-8", TOOL_OUTPUT_JS, false)
3519}
3520
3521async fn manifest() -> Response<Body> {
3522 static_response("application/manifest+json", MANIFEST, false)
3523}
3524
3525async fn service_worker() -> Response<Body> {
3529 static_response("text/javascript; charset=utf-8", SERVICE_WORKER, true)
3530}
3531
3532async fn icon() -> Response<Body> {
3533 static_response("image/svg+xml", ICON_SVG, false)
3534}
3535
3536async fn icon_192() -> Response<Body> {
3537 binary_response("image/png", ICON_192)
3538}
3539
3540async fn icon_512() -> Response<Body> {
3541 binary_response("image/png", ICON_512)
3542}
3543
3544async fn maskable_512() -> Response<Body> {
3545 binary_response("image/png", MASKABLE_512)
3546}
3547
3548async fn apple_touch_icon() -> Response<Body> {
3549 binary_response("image/png", APPLE_TOUCH_ICON)
3550}
3551
3552async fn mono_font() -> Response<Body> {
3553 binary_response("font/woff2", MONO_FONT)
3554}
3555
3556fn static_response(
3557 content_type: &'static str,
3558 body: &'static str,
3559 no_store: bool,
3560) -> Response<Body> {
3561 finish_static(Response::new(Body::from(body)), content_type, no_store)
3562}
3563
3564fn binary_response(content_type: &'static str, body: &'static [u8]) -> Response<Body> {
3565 finish_static(Response::new(Body::from(body)), content_type, false)
3566}
3567
3568fn finish_static(
3572 mut response: Response<Body>,
3573 content_type: &'static str,
3574 no_store: bool,
3575) -> Response<Body> {
3576 let headers = response.headers_mut();
3577 headers.insert(CONTENT_TYPE, HeaderValue::from_static(content_type));
3578 headers.insert(
3579 CACHE_CONTROL,
3580 HeaderValue::from_static(if no_store { "no-store" } else { "no-cache" }),
3581 );
3582 response
3583}
3584
3585async fn security_headers(request: Request, next: Next) -> Response<Body> {
3593 let live = {
3594 let path = request.uri().path();
3595 path.starts_with("/api/") || path.starts_with("/auth/")
3596 };
3597 let mut response = next.run(request).await;
3598 let headers = response.headers_mut();
3599 headers.insert(
3600 CONTENT_SECURITY_POLICY_HEADER,
3601 HeaderValue::from_static(CONTENT_SECURITY_POLICY),
3602 );
3603 headers.insert(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
3604 headers.insert(REFERRER_POLICY, HeaderValue::from_static("no-referrer"));
3605 if live {
3606 headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
3607 }
3608 response
3609}
3610
3611#[cfg(test)]
3612mod tests {
3613 use super::*;
3614 use std::collections::BTreeMap;
3615 use std::path::Path;
3616
3617 use axum::http::Request;
3618 use http_body_util::BodyExt as _;
3619 use tower::ServiceExt as _;
3620
3621 use hel::hel_config::{
3622 CONFIG_VERSION, ContainerTemplate, HarnessKind, HarnessProfile, PermissionMode,
3623 ProjectBundle, ProjectRepository, SshConnection,
3624 };
3625 use hel::hel_state::{ProjectSourceIdentity, STATE_VERSION, SessionRecord};
3626
3627 #[test]
3628 fn unified_tls_backends_use_the_selected_crypto_provider() {
3629 install_rustls_crypto_provider();
3630
3631 assert!(rustls::crypto::CryptoProvider::get_default().is_some());
3632 let _builder = rustls::ServerConfig::builder();
3633 }
3634
3635 #[test]
3636 fn minted_desktop_cookie_validates_and_names_a_viewer() {
3637 let key = vec![7u8; COOKIE_KEY_BYTES];
3638 let value = mint_desktop_session_cookie(&key).unwrap();
3639 let viewer = cookie_viewer(&key, &value, now_unix());
3640 assert!(
3641 matches!(viewer, Some(Some(_))),
3642 "minted cookie must validate and carry a viewer id: {value:?}"
3643 );
3644 assert!(!session_cookie_valid(
3645 &[8u8; COOKIE_KEY_BYTES],
3646 &value,
3647 now_unix()
3648 ));
3649 }
3650
3651 fn sample_config_state() -> (HelConfig, HelState) {
3652 let config = HelConfig {
3653 version: CONFIG_VERSION,
3654 sessions_side: Default::default(),
3655 advanced: Default::default(),
3656 show_stopped_sessions: true,
3657 newer_config_version: None,
3658 spinner: Default::default(),
3659 theme: Default::default(),
3660 phone: Default::default(),
3661 review: Default::default(),
3662 startup: Default::default(),
3663 profiles: BTreeMap::from([(
3664 "codex-1".into(),
3665 HarnessProfile {
3666 context_window_bytes: None,
3667 kind: HarnessKind::Codex,
3668 home: "/highly/secret/codex".into(),
3669 environment: BTreeMap::from([("GH_TOKEN".into(), "secret-token".into())]),
3670 },
3671 )]),
3672 bundles: BTreeMap::from([(
3673 "hel".into(),
3674 ProjectBundle {
3675 primary_repo: "hel".into(),
3676 repositories: vec![ProjectRepository {
3677 id: "hel".into(),
3678 github: Some("owner/hel".into()),
3679 local: Some("/private/source/hel".into()),
3680 destination: "hel".into(),
3681 git_ref: None,
3682 }],
3683 },
3684 )]),
3685 targets: BTreeMap::from([
3686 (
3687 "podman".into(),
3688 TargetTemplate::LocalPodman {
3689 container: ContainerTemplate {
3690 image: "secret.registry/image".into(),
3691 pull_policy: Default::default(),
3692 platform: None,
3693 cpus: None,
3694 memory: None,
3695 environment: BTreeMap::from([("TOKEN".into(), "secret-target".into())]),
3696 workspace_storage: Default::default(),
3697 },
3698 },
3699 ),
3700 ("raw".into(), TargetTemplate::LocalBare),
3701 ]),
3702 };
3703 let state = HelState {
3704 version: STATE_VERSION,
3705 sessions: BTreeMap::from([(
3706 "session-1".into(),
3707 SessionRecord {
3708 workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
3709 archived: false,
3710 container_cpus: None,
3711 container_memory: None,
3712 id: "session-1".into(),
3713 title: "Build Hel".into(),
3714 harness_kind: HarnessKind::Codex,
3715 last_profile: "codex-1".into(),
3716 bundle_id: "hel".into(),
3717 project_directory: None,
3718 managed_worktree: None,
3719 target_template_id: "podman".into(),
3720 resource_allocation: None,
3721 additional_mounts: vec![],
3722 state: SessionState::Running,
3723 target: None,
3724 native_session_id: Some("native-secret-id".into()),
3725 acp_session_title: Some("Build Hel".into()),
3726 session_title_override: None,
3727 created_at: "now".into(),
3728 updated_at: "now".into(),
3729 viewed_through_event_ordinal: 0,
3730 draft_input: String::new(),
3731 last_error: Some("secret-token at /highly/secret/codex".into()),
3732 last_checkpoint_error: None,
3733 checkpoint: None,
3734 },
3735 )]),
3736 mount_history: BTreeMap::new(),
3737 container_sizes: BTreeMap::new(),
3738 };
3739 (config, state)
3740 }
3741
3742 type TestServer = (
3743 Router,
3744 mpsc::Receiver<ControllerRequest>,
3745 mpsc::Receiver<ReadReceiptRequest>,
3746 mpsc::Receiver<PreflightRequest>,
3747 mpsc::Receiver<ClientStateRequest>,
3748 );
3749
3750 fn app() -> TestServer {
3751 app_with_conversations(BTreeMap::new())
3752 }
3753
3754 fn app_with_move_receiver() -> (Router, mpsc::Receiver<MovePreparationRequest>) {
3755 let (config, state) = sample_config_state();
3756 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
3757 snapshot.sessions[0].capabilities.move_session = true;
3758 let (_snapshot_tx, snapshot_rx) = watch::channel(snapshot);
3759 let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
3760 let (action_tx, _action_rx) = mpsc::channel(8);
3761 let (bundle_tx, _bundle_rx) = mpsc::channel(8);
3762 let (receipt_tx, _receipt_rx) = mpsc::channel(8);
3763 let (preflight_tx, _preflight_rx) = mpsc::channel(8);
3764 let (move_preparation_tx, move_preparation_rx) = mpsc::channel(8);
3765 let (client_state_tx, _client_state_rx) = mpsc::channel(8);
3766 let options = test_options(
3767 snapshot_rx,
3768 conversation_rx,
3769 action_tx,
3770 bundle_tx,
3771 receipt_tx,
3772 preflight_tx,
3773 move_preparation_tx,
3774 client_state_tx,
3775 )
3776 .with_test_credentials("123456", b"01234567890123456789012345678901");
3777 (router(options), move_preparation_rx)
3778 }
3779
3780 fn app_with_conversations(conversations: BTreeMap<String, BrowserTranscript>) -> TestServer {
3781 app_with(conversations, |_| {})
3782 }
3783
3784 fn app_with_snapshot(adjust: impl FnOnce(&mut ViewerSnapshot)) -> TestServer {
3785 app_with(BTreeMap::new(), adjust)
3786 }
3787
3788 fn app_with(
3789 conversations: BTreeMap<String, BrowserTranscript>,
3790 adjust: impl FnOnce(&mut ViewerSnapshot),
3791 ) -> TestServer {
3792 let (config, state) = sample_config_state();
3793 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
3794 adjust(&mut snapshot);
3795 let (_snapshot_tx, snapshot_rx) = watch::channel(snapshot);
3796 let (_conversation_tx, conversation_rx) = watch::channel(conversations);
3797 let (action_tx, action_rx) = mpsc::channel(8);
3798 let (bundle_tx, _bundle_rx) = mpsc::channel(8);
3799 let (receipt_tx, receipt_rx) = mpsc::channel(8);
3800 let (preflight_tx, preflight_rx) = mpsc::channel(8);
3801 let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
3802 let (client_state_tx, client_state_rx) = mpsc::channel(8);
3803 let options = test_options(
3804 snapshot_rx,
3805 conversation_rx,
3806 action_tx,
3807 bundle_tx,
3808 receipt_tx,
3809 preflight_tx,
3810 move_preparation_tx,
3811 client_state_tx,
3812 )
3813 .with_test_credentials("123456", b"01234567890123456789012345678901");
3814 (
3815 router(options),
3816 action_rx,
3817 receipt_rx,
3818 preflight_rx,
3819 client_state_rx,
3820 )
3821 }
3822
3823 fn app_with_bundle_receiver() -> (Router, mpsc::Receiver<BundleRequest>) {
3824 let (config, state) = sample_config_state();
3825 let (_snapshot_tx, snapshot_rx) =
3826 watch::channel(ViewerSnapshot::from_config_state(&config, &state, 1));
3827 let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
3828 let (action_tx, _action_rx) = mpsc::channel(8);
3829 let (bundle_tx, bundle_rx) = mpsc::channel(8);
3830 let (receipt_tx, _receipt_rx) = mpsc::channel(8);
3831 let (preflight_tx, _preflight_rx) = mpsc::channel(8);
3832 let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
3833 let (client_state_tx, _client_state_rx) = mpsc::channel(8);
3834 let options = test_options(
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 .with_test_credentials("123456", b"01234567890123456789012345678901");
3845 (router(options), bundle_rx)
3846 }
3847
3848 #[allow(clippy::too_many_arguments)]
3851 fn test_options(
3852 snapshot_rx: watch::Receiver<ViewerSnapshot>,
3853 conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
3854 action_tx: mpsc::Sender<ControllerRequest>,
3855 bundle_tx: mpsc::Sender<BundleRequest>,
3856 receipt_tx: mpsc::Sender<ReadReceiptRequest>,
3857 preflight_tx: mpsc::Sender<PreflightRequest>,
3858 move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
3859 client_state_tx: mpsc::Sender<ClientStateRequest>,
3860 ) -> ServerOptions {
3861 test_options_with_dictation(
3862 snapshot_rx,
3863 conversation_rx,
3864 action_tx,
3865 bundle_tx,
3866 receipt_tx,
3867 preflight_tx,
3868 move_preparation_tx,
3869 client_state_tx,
3870 )
3871 .0
3872 }
3873
3874 #[allow(clippy::too_many_arguments)]
3875 fn test_options_with_dictation(
3876 snapshot_rx: watch::Receiver<ViewerSnapshot>,
3877 conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
3878 action_tx: mpsc::Sender<ControllerRequest>,
3879 bundle_tx: mpsc::Sender<BundleRequest>,
3880 receipt_tx: mpsc::Sender<ReadReceiptRequest>,
3881 preflight_tx: mpsc::Sender<PreflightRequest>,
3882 move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
3883 client_state_tx: mpsc::Sender<ClientStateRequest>,
3884 ) -> (ServerOptions, mpsc::Receiver<DictationRequest>) {
3885 let (dictation_tx, dictation_rx) = mpsc::channel(8);
3886 let options = ServerOptions::new(
3887 "127.0.0.1:0".parse().unwrap(),
3888 snapshot_rx,
3889 conversation_rx,
3890 ServerRequests {
3891 action_tx,
3892 bundle_tx,
3893 receipt_tx,
3894 preflight_tx,
3895 move_preparation_tx,
3896 client_state_tx,
3897 dictation_tx,
3898 },
3899 )
3900 .unwrap();
3901 (options, dictation_rx)
3902 }
3903
3904 fn app_with_dictation_receiver() -> (Router, mpsc::Receiver<DictationRequest>) {
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(8);
3910 let (bundle_tx, _bundle_rx) = mpsc::channel(8);
3911 let (receipt_tx, _receipt_rx) = mpsc::channel(8);
3912 let (preflight_tx, _preflight_rx) = mpsc::channel(8);
3913 let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
3914 let (client_state_tx, _client_state_rx) = mpsc::channel(8);
3915 let (options, dictation_rx) = test_options_with_dictation(
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 router(options.with_test_credentials("123456", b"01234567890123456789012345678901")),
3927 dictation_rx,
3928 )
3929 }
3930
3931 fn detached_options() -> ServerOptions {
3932 let (config, state) = sample_config_state();
3933 let (_snapshot_tx, snapshot_rx) =
3934 watch::channel(ViewerSnapshot::from_config_state(&config, &state, 1));
3935 let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
3936 let (action_tx, _action_rx) = mpsc::channel(1);
3937 let (bundle_tx, _bundle_rx) = mpsc::channel(1);
3938 let (receipt_tx, _receipt_rx) = mpsc::channel(1);
3939 let (preflight_tx, _preflight_rx) = mpsc::channel(1);
3940 let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(1);
3941 let (client_state_tx, _client_state_rx) = mpsc::channel(1);
3942 test_options(
3943 snapshot_rx,
3944 conversation_rx,
3945 action_tx,
3946 bundle_tx,
3947 receipt_tx,
3948 preflight_tx,
3949 move_preparation_tx,
3950 client_state_tx,
3951 )
3952 }
3953
3954 fn cookie() -> String {
3960 format!(
3961 "{COOKIE_NAME}={}",
3962 signed_cookie_value(
3963 b"01234567890123456789012345678901",
3964 "test-viewer",
3965 now_unix().saturating_add(3600)
3966 )
3967 )
3968 }
3969
3970 fn valid_wav() -> Bytes {
3971 let samples = vec![0_u8; 320];
3972 let mut wav = Vec::with_capacity(44 + samples.len());
3973 wav.extend_from_slice(b"RIFF");
3974 wav.extend_from_slice(&(36_u32 + samples.len() as u32).to_le_bytes());
3975 wav.extend_from_slice(b"WAVEfmt ");
3976 wav.extend_from_slice(&16_u32.to_le_bytes());
3977 wav.extend_from_slice(&1_u16.to_le_bytes());
3978 wav.extend_from_slice(&1_u16.to_le_bytes());
3979 wav.extend_from_slice(&16_000_u32.to_le_bytes());
3980 wav.extend_from_slice(&32_000_u32.to_le_bytes());
3981 wav.extend_from_slice(&2_u16.to_le_bytes());
3982 wav.extend_from_slice(&16_u16.to_le_bytes());
3983 wav.extend_from_slice(b"data");
3984 wav.extend_from_slice(&(samples.len() as u32).to_le_bytes());
3985 wav.extend_from_slice(&samples);
3986 Bytes::from(wav)
3987 }
3988
3989 async fn login_cookie(app: &Router) -> String {
3990 let response = app
3991 .clone()
3992 .oneshot(
3993 Request::post("/auth/session")
3994 .header(CONTENT_TYPE, "application/json")
3995 .body(Body::from(r#"{"code":"123456"}"#))
3996 .unwrap(),
3997 )
3998 .await
3999 .unwrap();
4000 assert_eq!(response.status(), StatusCode::NO_CONTENT);
4001 response
4002 .headers()
4003 .get(SET_COOKIE)
4004 .unwrap()
4005 .to_str()
4006 .unwrap()
4007 .split(';')
4008 .next()
4009 .unwrap()
4010 .to_string()
4011 }
4012
4013 #[tokio::test]
4014 async fn dictation_availability_requires_auth_and_forwards_typed_request() {
4015 let (app, mut requests) = app_with_dictation_receiver();
4016 let unauthorized = app
4017 .clone()
4018 .oneshot(
4019 Request::get("/api/sessions/session-1/dictation")
4020 .body(Body::empty())
4021 .unwrap(),
4022 )
4023 .await
4024 .unwrap();
4025 assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
4026 assert!(requests.try_recv().is_err());
4027
4028 let cookie = login_cookie(&app).await;
4029 let pending = tokio::spawn({
4030 let app = app.clone();
4031 async move {
4032 app.oneshot(
4033 Request::get("/api/sessions/session-1/dictation")
4034 .header(COOKIE, cookie)
4035 .body(Body::empty())
4036 .unwrap(),
4037 )
4038 .await
4039 .unwrap()
4040 }
4041 });
4042 let request = requests.recv().await.unwrap();
4043 assert_eq!(request.session_id, "session-1");
4044 assert!(matches!(
4045 request.operation,
4046 DictationOperation::Availability
4047 ));
4048 request
4049 .reply
4050 .send(Ok(DictationResponse::Availability {
4051 available: true,
4052 reason: None,
4053 }))
4054 .unwrap();
4055 let response = pending.await.unwrap();
4056 assert_eq!(response.status(), StatusCode::OK);
4057 let body = response.into_body().collect().await.unwrap().to_bytes();
4058 assert_eq!(&body[..], br#"{"available":true}"#);
4059 }
4060
4061 #[tokio::test]
4062 async fn dictation_rejects_bad_wav_before_controller_dispatch() {
4063 let (app, mut requests) = app_with_dictation_receiver();
4064 let cookie = login_cookie(&app).await;
4065 let response = app
4066 .oneshot(
4067 Request::post("/api/sessions/session-1/dictation")
4068 .header(COOKIE, cookie)
4069 .header(CONTENT_TYPE, "audio/wav")
4070 .body(Body::from("not wav"))
4071 .unwrap(),
4072 )
4073 .await
4074 .unwrap();
4075 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4076 assert!(requests.try_recv().is_err());
4077 }
4078
4079 #[tokio::test]
4080 async fn dictation_rejects_a_third_upload_before_reading_its_body() {
4081 let (app, mut requests) = app_with_dictation_receiver();
4082 let cookie = login_cookie(&app).await;
4083 let request = || {
4084 Request::post("/api/sessions/session-1/dictation")
4085 .header(COOKIE, cookie.clone())
4086 .header(CONTENT_TYPE, "audio/wav")
4087 .body(Body::from(valid_wav()))
4088 .unwrap()
4089 };
4090 let first = tokio::spawn({
4091 let app = app.clone();
4092 let request = request();
4093 async move { app.oneshot(request).await.unwrap() }
4094 });
4095 let second = tokio::spawn({
4096 let app = app.clone();
4097 let request = request();
4098 async move { app.oneshot(request).await.unwrap() }
4099 });
4100 let first_request = requests.recv().await.unwrap();
4101 let second_request = requests.recv().await.unwrap();
4102 let third = app
4103 .oneshot(
4104 Request::post("/api/sessions/session-1/dictation")
4105 .header(COOKIE, cookie)
4106 .header(CONTENT_TYPE, "audio/wav")
4107 .body(Body::from_stream(futures::stream::poll_fn(
4108 |_| -> std::task::Poll<Option<Result<Bytes, std::io::Error>>> {
4109 panic!("overloaded dictation polled its body")
4110 },
4111 )))
4112 .unwrap(),
4113 )
4114 .await
4115 .unwrap();
4116 assert_eq!(third.status(), StatusCode::TOO_MANY_REQUESTS);
4117 first_request
4118 .reply
4119 .send(Ok(DictationResponse::Transcript {
4120 text: "first".into(),
4121 }))
4122 .unwrap();
4123 second_request
4124 .reply
4125 .send(Ok(DictationResponse::Transcript {
4126 text: "second".into(),
4127 }))
4128 .unwrap();
4129 assert_eq!(first.await.unwrap().status(), StatusCode::OK);
4130 assert_eq!(second.await.unwrap().status(), StatusCode::OK);
4131 }
4132
4133 #[tokio::test]
4134 async fn dictation_upload_rejects_unauthorized_missing_and_oversized_requests() {
4135 let (app, mut requests) = app_with_dictation_receiver();
4136 let response = app
4137 .clone()
4138 .oneshot(
4139 Request::post("/api/sessions/session-1/dictation")
4140 .body(Body::from(valid_wav()))
4141 .unwrap(),
4142 )
4143 .await
4144 .unwrap();
4145 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
4146 let cookie = login_cookie(&app).await;
4147 let response = app
4148 .clone()
4149 .oneshot(
4150 Request::post("/api/sessions/missing/dictation")
4151 .header(COOKIE, &cookie)
4152 .body(Body::from(valid_wav()))
4153 .unwrap(),
4154 )
4155 .await
4156 .unwrap();
4157 assert_eq!(response.status(), StatusCode::NOT_FOUND);
4158 let response = app
4160 .oneshot(
4161 Request::post("/api/sessions/session-1/dictation")
4162 .header(COOKIE, cookie)
4163 .body(Body::from(vec![0_u8; MAX_AUDIO_BYTES + 1]))
4164 .unwrap(),
4165 )
4166 .await
4167 .unwrap();
4168 assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
4169 assert!(requests.try_recv().is_err());
4170 }
4171
4172 #[tokio::test]
4173 async fn dictation_provider_failure_is_actionable_and_does_not_expose_details() {
4174 let (app, mut requests) = app_with_dictation_receiver();
4175 let cookie = login_cookie(&app).await;
4176 let pending = tokio::spawn(async move {
4177 app.oneshot(
4178 Request::post("/api/sessions/session-1/dictation")
4179 .header(COOKIE, cookie)
4180 .body(Body::from(valid_wav()))
4181 .unwrap(),
4182 )
4183 .await
4184 .unwrap()
4185 });
4186 let request = requests.recv().await.unwrap();
4187 request
4188 .reply
4189 .send(Err(DictationError::Provider(
4190 "private provider details".into(),
4191 )))
4192 .unwrap();
4193 let response = pending.await.unwrap();
4194 assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
4195 let bytes = response.into_body().collect().await.unwrap().to_bytes();
4196 let body = String::from_utf8(bytes.to_vec()).unwrap();
4197 assert!(body.contains("transcription"));
4198 assert!(!body.contains("private provider details"));
4199 }
4200
4201 #[tokio::test]
4202 async fn dropped_dictation_handler_cancels_controller_request() {
4203 let (app, mut requests) = app_with_dictation_receiver();
4204 let cookie = login_cookie(&app).await;
4205 let pending = tokio::spawn({
4206 let app = app.clone();
4207 async move {
4208 app.oneshot(
4209 Request::post("/api/sessions/session-1/dictation")
4210 .header(COOKIE, cookie)
4211 .body(Body::from(valid_wav()))
4212 .unwrap(),
4213 )
4214 .await
4215 .unwrap()
4216 }
4217 });
4218 let request = requests.recv().await.unwrap();
4219 let cancel = request.cancel.clone();
4220 pending.abort();
4221 let _ = pending.await;
4222 assert!(cancel.is_cancelled());
4223 drop(request);
4224 }
4225
4226 #[tokio::test]
4227 async fn bundle_endpoint_authenticates_and_forwards_the_source() {
4228 let (app, mut bundles) = app_with_bundle_receiver();
4229 let unauthorized = app
4230 .clone()
4231 .oneshot(
4232 Request::post("/api/bundles")
4233 .header(CONTENT_TYPE, "application/json")
4234 .body(Body::from(r#"{"source":"example/app"}"#))
4235 .unwrap(),
4236 )
4237 .await
4238 .unwrap();
4239 assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
4240 assert!(bundles.try_recv().is_err());
4241
4242 let cookie = login_cookie(&app).await;
4243 let response = tokio::spawn({
4244 let app = app.clone();
4245 let cookie = cookie.clone();
4246 async move {
4247 app.oneshot(
4248 Request::post("/api/bundles")
4249 .header(CONTENT_TYPE, "application/json")
4250 .header(COOKIE, cookie)
4251 .body(Body::from(r#"{"source":"example/app"}"#))
4252 .unwrap(),
4253 )
4254 .await
4255 .unwrap()
4256 }
4257 });
4258 let request = bundles.recv().await.expect("bundle request forwarded");
4259 assert_eq!(request.source, "example/app");
4260 request.reply.send(Ok("app".into())).unwrap();
4261 let response = response.await.unwrap();
4262 assert_eq!(response.status(), StatusCode::OK);
4263 let body = response.into_body().collect().await.unwrap().to_bytes();
4264 assert_eq!(body.as_ref(), br#"{"bundle_id":"app"}"#);
4265 }
4266
4267 #[tokio::test]
4268 async fn bundle_endpoint_rejects_empty_and_oversized_sources_before_dispatch() {
4269 for source in [String::new(), "x".repeat(MAX_BUNDLE_SOURCE_CHARS + 1)] {
4270 let (app, mut bundles) = app_with_bundle_receiver();
4271 let cookie = login_cookie(&app).await;
4272 let response = app
4273 .oneshot(
4274 Request::post("/api/bundles")
4275 .header(CONTENT_TYPE, "application/json")
4276 .header(COOKIE, cookie)
4277 .body(Body::from(
4278 serde_json::to_string(&serde_json::json!({"source": source})).unwrap(),
4279 ))
4280 .unwrap(),
4281 )
4282 .await
4283 .unwrap();
4284 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4285 assert!(bundles.try_recv().is_err());
4286 }
4287 }
4288
4289 #[tokio::test]
4290 async fn bundle_endpoint_reports_invalid_source_as_a_client_error() {
4291 let (app, mut bundles) = app_with_bundle_receiver();
4292 let cookie = login_cookie(&app).await;
4293 let response = tokio::spawn({
4294 let app = app.clone();
4295 async move {
4296 app.oneshot(
4297 Request::post("/api/bundles")
4298 .header(CONTENT_TYPE, "application/json")
4299 .header(COOKIE, cookie)
4300 .body(Body::from(r#"{"source":"not a source"}"#))
4301 .unwrap(),
4302 )
4303 .await
4304 .unwrap()
4305 }
4306 });
4307 let request = bundles.recv().await.expect("bundle request forwarded");
4308 request
4309 .reply
4310 .send(Err(BundleFailure::InvalidSource))
4311 .unwrap();
4312 let response = response.await.unwrap();
4313 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4314 let body = response.into_body().collect().await.unwrap().to_bytes();
4315 assert!(String::from_utf8_lossy(&body).contains("GitHub owner/repository"));
4316 }
4317
4318 #[tokio::test]
4319 async fn api_requires_a_valid_signed_cookie() {
4320 let (app, _, _, _, _) = app();
4321 let unauthorized = app
4322 .clone()
4323 .oneshot(Request::get("/api/snapshot").body(Body::empty()).unwrap())
4324 .await
4325 .unwrap();
4326 assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
4327
4328 let cookie = login_cookie(&app).await;
4329 let authorized = app
4330 .oneshot(
4331 Request::get("/api/snapshot")
4332 .header(COOKIE, cookie)
4333 .body(Body::empty())
4334 .unwrap(),
4335 )
4336 .await
4337 .unwrap();
4338 assert_eq!(authorized.status(), StatusCode::OK);
4339 }
4340
4341 #[tokio::test]
4342 async fn qr_login_exchanges_the_secret_for_a_cookie_and_redirects_cleanly() {
4343 let (app, _, _, _, _) = app();
4344 let rejected = app
4345 .clone()
4346 .oneshot(
4347 Request::get("/auth/login?token=wrong")
4348 .body(Body::empty())
4349 .unwrap(),
4350 )
4351 .await
4352 .unwrap();
4353 assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED);
4354
4355 let accepted = app
4356 .oneshot(
4357 Request::get("/auth/login?token=test-login-token")
4358 .body(Body::empty())
4359 .unwrap(),
4360 )
4361 .await
4362 .unwrap();
4363 assert_eq!(accepted.status(), StatusCode::SEE_OTHER);
4364 assert_eq!(accepted.headers().get(LOCATION).unwrap(), "/");
4365 assert_eq!(accepted.headers().get(CACHE_CONTROL).unwrap(), "no-store");
4366 assert!(accepted.headers().contains_key(SET_COOKIE));
4367 }
4368
4369 #[test]
4370 fn signed_cookie_rejects_expiry_and_tampering() {
4371 let key = b"01234567890123456789012345678901";
4372 let cookie = signed_cookie_value(key, "test-viewer", 200);
4373 assert!(session_cookie_valid(key, &cookie, 100));
4374 assert!(!session_cookie_valid(key, &cookie, 200));
4375 assert!(!session_cookie_valid(key, &format!("{cookie}x"), 100));
4376 assert!(!session_cookie_valid(b"another-key", &cookie, 100));
4377 }
4378
4379 #[test]
4380 fn generated_code_and_cookie_attributes_are_phone_safe() {
4381 let code = generate_viewer_code().unwrap();
4382 assert_eq!(code.len(), 6);
4383 assert!(code.bytes().all(|byte| byte.is_ascii_digit()));
4384 let header = session_cookie_header("signed", Some(60), true)
4385 .unwrap()
4386 .to_str()
4387 .unwrap()
4388 .to_string();
4389 assert!(header.contains("HttpOnly"));
4390 assert!(header.contains("SameSite=Strict"));
4391 assert!(header.contains("Secure"));
4392 assert!(header.contains("Max-Age=60"));
4393 }
4394
4395 #[test]
4396 fn public_snapshot_omits_homes_environment_locators_and_raw_errors() {
4397 let (config, state) = sample_config_state();
4398 let json =
4399 serde_json::to_string(&ViewerSnapshot::from_config_state(&config, &state, 9)).unwrap();
4400 assert!(!json.contains("/highly/secret"));
4401 assert!(!json.contains("secret-token"));
4402 assert!(!json.contains("secret-target"));
4403 assert!(!json.contains("secret.registry"));
4404 assert!(!json.contains("native-secret-id"));
4405 assert!(json.contains("\"has_error\":true"));
4406 }
4407
4408 #[test]
4409 fn target_snapshot_uses_each_raw_host_project_history_and_leaves_managed_empty() {
4410 let (mut config, mut state) = sample_config_state();
4411 config
4412 .targets
4413 .insert("raw-local".into(), TargetTemplate::LocalBare);
4414 config.targets.insert(
4415 "raw-builder".into(),
4416 TargetTemplate::SshBare {
4417 ssh: SshConnection {
4418 host: "builder-a".into(),
4419 user: None,
4420 identity_file: None,
4421 extra_args: Vec::new(),
4422 },
4423 permissions: PermissionMode::Guardian,
4424 workspace_prefix: "workspaces".into(),
4425 },
4426 );
4427 state.remember_project_directory("local", Path::new("/work/local"));
4428 state.remember_project_directory("builder-a", Path::new("/srv/builder"));
4429 state.remember_project_directory("other-host", Path::new("/not-published"));
4430
4431 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4432 let target = |id: &str| {
4433 snapshot
4434 .targets
4435 .iter()
4436 .find(|target| target.id == id)
4437 .unwrap()
4438 };
4439 assert_eq!(
4440 target("raw-local").recent_project_directories,
4441 vec!["/work/local"]
4442 );
4443 assert_eq!(
4444 target("raw-builder").recent_project_directories,
4445 vec!["/srv/builder"]
4446 );
4447 assert!(target("podman").recent_project_directories.is_empty());
4448 }
4449
4450 #[test]
4451 fn public_snapshot_exposes_only_review_status_configuration() {
4452 let (mut config, state) = sample_config_state();
4453 config.review = hel::hel_config::ReviewConfig {
4454 enabled: true,
4455 tier: hel::hel_review::lanes::ReviewTier::Extended,
4456 profile: Some("reviewer-1".into()),
4457 model: Some("private-review-model".into()),
4458 effort: Some("private-review-effort".into()),
4459 };
4460
4461 let value =
4462 serde_json::to_value(ViewerSnapshot::from_config_state(&config, &state, 9)).unwrap();
4463
4464 assert_eq!(
4465 value.get("review_config"),
4466 Some(&serde_json::json!({
4467 "enabled": true,
4468 "tier": "extended",
4469 "profile": "reviewer-1",
4470 }))
4471 );
4472 let json = value.to_string();
4473 assert!(!json.contains("private-review-model"));
4474 assert!(!json.contains("private-review-effort"));
4475 }
4476
4477 fn sample_elicitation() -> ElicitationRequest {
4478 ElicitationRequest::from_acp_params(
4479 "elicitation-1",
4480 serde_json::json!({
4481 "sessionId": "session-1",
4482 "mode": "form",
4483 "message": "Which CI architecture should the workflow use?",
4484 "requestedSchema": {
4485 "type": "object",
4486 "required": ["question_0"],
4487 "properties": {
4488 "question_0": {
4489 "type": "string",
4490 "title": "CI architecture",
4491 "oneOf": [
4492 {"const": "reusable", "title": "Reusable workflow"},
4493 {"const": "matrix", "title": "Matrix job"}
4494 ]
4495 },
4496 "question_0_custom": {
4497 "type": "string",
4498 "title": "Other",
4499 "_meta": {"_askUserQuestionCustomAnswer": {
4500 "questionId": "question_0",
4501 "isCustomAnswer": true
4502 }}
4503 }
4504 }
4505 }
4506 }),
4507 )
4508 .expect("sample elicitation parses")
4509 }
4510
4511 fn accept(pairs: &[(&str, &str)]) -> ElicitationResponse {
4512 ElicitationResponse::Accept {
4513 content: pairs
4514 .iter()
4515 .map(|(id, value)| {
4516 (
4517 (*id).to_owned(),
4518 hel::hel_elicitation::ElicitationValue::String((*value).to_owned()),
4519 )
4520 })
4521 .collect(),
4522 }
4523 }
4524
4525 fn pending_elicitation_snapshot(snapshot: &mut ViewerSnapshot) {
4526 snapshot.sessions[0].pending_elicitations = vec![sample_elicitation()];
4527 }
4528
4529 #[tokio::test]
4530 async fn elicitation_answer_is_typed_and_forwarded() {
4531 let (app, mut actions, _, _, _) = app_with_snapshot(pending_elicitation_snapshot);
4532 let cookie = login_cookie(&app).await;
4533 let response = tokio::spawn(
4534 app.oneshot(
4535 Request::post("/api/actions")
4536 .header(COOKIE, cookie)
4537 .header(CONTENT_TYPE, "application/json")
4538 .body(Body::from(
4539 r#"{"action":"respond-elicitation","session_id":"session-1","elicitation_id":"elicitation-1","response":{"action":"accept","content":{"question_0":"reusable"}}}"#,
4540 ))
4541 .unwrap(),
4542 ),
4543 );
4544 let action = actions.recv().await.unwrap();
4545 assert_eq!(
4546 action.action,
4547 ControllerAction::RespondElicitation {
4548 session_id: "session-1".into(),
4549 elicitation_id: "elicitation-1".into(),
4550 response: accept(&[("question_0", "reusable")]),
4551 }
4552 );
4553 action.reply.send(ActionOutcome::Accepted).unwrap();
4554 assert_eq!(
4555 response.await.unwrap().unwrap().status(),
4556 StatusCode::ACCEPTED
4557 );
4558 }
4559
4560 #[tokio::test]
4561 async fn elicitation_answer_for_an_unknown_request_is_refused_without_reaching_the_controller()
4562 {
4563 let (app, mut actions, _, _, _) = app_with_snapshot(pending_elicitation_snapshot);
4564 let cookie = login_cookie(&app).await;
4565 let response = app
4566 .oneshot(
4567 Request::post("/api/actions")
4568 .header(COOKIE, cookie)
4569 .header(CONTENT_TYPE, "application/json")
4570 .body(Body::from(
4571 r#"{"action":"respond-elicitation","session_id":"session-1","elicitation_id":"elicitation-9","response":{"action":"cancel"}}"#,
4572 ))
4573 .unwrap(),
4574 )
4575 .await
4576 .unwrap();
4577 assert_eq!(response.status(), StatusCode::NOT_FOUND);
4578 assert!(actions.try_recv().is_err());
4579 }
4580
4581 #[test]
4582 fn elicitation_answers_are_checked_against_the_request_the_agent_asked() {
4583 let (config, state) = sample_config_state();
4584 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4585 pending_elicitation_snapshot(&mut snapshot);
4586 let respond = |response: ElicitationResponse| ControllerAction::RespondElicitation {
4587 session_id: "session-1".into(),
4588 elicitation_id: "elicitation-1".into(),
4589 response,
4590 };
4591
4592 assert!(validate_action(&respond(accept(&[("question_0", "matrix")])), &snapshot).is_ok());
4593 assert!(validate_action(&respond(ElicitationResponse::Decline), &snapshot).is_ok());
4596 assert!(validate_action(&respond(accept(&[("question_0", "cron")])), &snapshot).is_err());
4599 assert!(validate_action(&respond(accept(&[("smuggled", "yes")])), &snapshot).is_err());
4600 assert!(validate_action(&respond(accept(&[])), &snapshot).is_err());
4601 assert!(
4604 validate_action(
4605 &respond(accept(&[("question_0_custom", "a monorepo pipeline")])),
4606 &snapshot,
4607 )
4608 .is_ok()
4609 );
4610 }
4611
4612 #[test]
4613 fn oversized_elicitation_answers_are_refused() {
4614 let (config, state) = sample_config_state();
4615 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4616 pending_elicitation_snapshot(&mut snapshot);
4617 let long = "x".repeat(MAX_ELICITATION_BYTES);
4618 assert!(
4619 validate_action(
4620 &ControllerAction::RespondElicitation {
4621 session_id: "session-1".into(),
4622 elicitation_id: "elicitation-1".into(),
4623 response: accept(&[("question_0_custom", long.as_str())]),
4624 },
4625 &snapshot,
4626 )
4627 .is_err()
4628 );
4629 }
4630
4631 fn viewer_source(from: &str, to: &str) -> &'static str {
4639 let start = VIEWER_JS
4640 .find(from)
4641 .unwrap_or_else(|| panic!("src/web/viewer.js no longer contains {from:?}"));
4642 let end = VIEWER_JS[start..]
4643 .find(to)
4644 .map(|offset| start + offset)
4645 .unwrap_or_else(|| {
4646 panic!("src/web/viewer.js no longer contains {to:?} after {from:?}")
4647 });
4648 &VIEWER_JS[start..end]
4649 }
4650
4651 fn run_web_check(name: &str, check: &str) {
4658 let directory = tempfile::tempdir().expect("temporary directory for a web check");
4659 for (file, source) in [
4660 ("test-dom.js", TEST_DOM_JS),
4661 ("markdown.js", MARKDOWN_JS),
4662 ("tool-output.js", TOOL_OUTPUT_JS),
4663 ] {
4664 std::fs::write(directory.path().join(file), source).expect("write a web module");
4665 }
4666 let path = directory.path().join(format!("{name}.mjs"));
4667 std::fs::write(&path, check).expect("write the web check");
4668 let output = std::process::Command::new("node")
4669 .arg(&path)
4670 .output()
4671 .expect("Node.js is required to exercise the web viewer");
4672 assert!(
4673 output.status.success(),
4674 "{name} failed:\nstdout:\n{}\nstderr:\n{}",
4675 String::from_utf8_lossy(&output.stdout),
4676 String::from_utf8_lossy(&output.stderr),
4677 );
4678 }
4679
4680 fn run_viewer_script(name: &str, script: &str) {
4684 run_web_check(name, script);
4685 }
4686
4687 #[test]
4688 fn embedded_viewer_lists_current_workspace_histories_and_retained_move_recovery() {
4689 let source = viewer_source("function isResumeSession(", "const resumeDrafts =");
4690 let setup = r#"
4691const snapshot = {
4692 sessions: [
4693 { id: "history-a", workspace_id: "workspace-a", capabilities: { resume: true } },
4694 { id: "history-b", workspace_id: "workspace-b", capabilities: { resume: true } },
4695 { id: "running-a", workspace_id: "workspace-a", lifecycle: "live", has_error: true, capabilities: { resume: false, open: false } },
4696 { id: "move-a", workspace_id: "workspace-a", capabilities: { resume: false }, move_recovery: { checkpoint_retained: true, phase: "failed" } },
4697 { id: "moving-a", workspace_id: "workspace-a", capabilities: { resume: false }, move_recovery: { checkpoint_retained: true, phase: "starting_queue" } },
4698 ],
4699};
4700function selectedWorkspaceId() { return "workspace-a"; }
4701function sessionActivityMs() { return 0; }
4702function epochMs() { return null; }
4703"#;
4704 let checks = r#"
4705const ids = workspace => resumeSessions(workspace).map(session => session.id).sort();
4706if (JSON.stringify(ids("workspace-a")) !== JSON.stringify(["history-a", "move-a"])) {
4707 throw new Error(`workspace A histories or recoveries were wrong: ${JSON.stringify(ids("workspace-a"))}`);
4708}
4709if (JSON.stringify(ids("workspace-b")) !== JSON.stringify(["history-b"])) {
4710 throw new Error(`workspace B histories were wrong: ${JSON.stringify(ids("workspace-b"))}`);
4711}
4712if (ids("missing-workspace").length !== 0) throw new Error("unknown workspace exposed sessions");
4713"#;
4714 run_viewer_script(
4715 "workspace-resume-history",
4716 &format!("{setup}\n{source}\n{checks}"),
4717 );
4718 }
4719
4720 #[test]
4721 fn embedded_viewer_sends_the_selected_resume_workspace() {
4722 let source = viewer_source("async function runSessionAction", "sessions.onclick =");
4723 let setup = r#"
4724const pendingActions = new Set();
4725const snapshot = { sessions: [] };
4726let sent = null;
4727function selectedWorkspaceId() { return "workspace-b"; }
4728function navigate() {}
4729function renderRoute() {}
4730async function refresh() {}
4731async function request(path, options) {
4732 sent = { path, body: JSON.parse(options.body) };
4733}
4734"#;
4735 let checks = r#"
4736const errorNode = { textContent: "" };
4737await runSessionAction(
4738 { action: "resume", id: "history-a", profile: "codex-1", target: "podman" },
4739 errorNode,
4740 { queue: "start" },
4741);
4742if (sent.path !== "/api/actions" || sent.body.workspace_id !== "workspace-b") {
4743 throw new Error(`resume did not carry its destination: ${JSON.stringify(sent)}`);
4744}
4745"#;
4746 run_viewer_script(
4747 "resume-workspace-destination",
4748 &format!("{setup}\n{source}\n{checks}"),
4749 );
4750 }
4751
4752 #[test]
4753 fn embedded_viewer_warns_before_stopping_an_active_session() {
4754 let source = viewer_source("async function runSessionAction", "sessions.onclick =");
4755 let setup = r#"
4756const pendingActions = new Set();
4757const snapshot = {
4758 sessions: [
4759 { id: "active", chat_phase: "running" },
4760 { id: "idle", chat_phase: "idle" },
4761 ],
4762};
4763const questions = [];
4764function confirm(question) { questions.push(question); return false; }
4765function navigate() {}
4766"#;
4767 let checks = r#"
4768const errorNode = { textContent: "" };
4769await runSessionAction({ action: "close", id: "active" }, errorNode);
4770await runSessionAction({ action: "close", id: "idle" }, errorNode);
4771if (!questions[0].startsWith("Stop active session?\n\n")) {
4772 throw new Error(`active close warning was ${JSON.stringify(questions[0])}`);
4773}
4774if (!questions[0].includes("current turn will be interrupted")) {
4775 throw new Error(`active close omitted interruption: ${JSON.stringify(questions[0])}`);
4776}
4777if (!questions[1].startsWith("Stop session?\n\n")) {
4778 throw new Error(`idle close warning was ${JSON.stringify(questions[1])}`);
4779}
4780"#;
4781 run_viewer_script(
4782 "active-session-stop-confirmation",
4783 &format!("{setup}\n{source}\n{checks}"),
4784 );
4785 }
4786
4787 #[test]
4792 fn the_project_key_groups_without_naming_a_path() {
4793 let (config, mut state) = sample_config_state();
4794 let first = state.sessions["session-1"].clone();
4795 let mut second = first.clone();
4796 second.id = "session-2".into();
4797 state.sessions.insert(second.id.clone(), second);
4798 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4799
4800 let keys = snapshot
4801 .sessions
4802 .iter()
4803 .map(|session| session.project_key.as_str())
4804 .collect::<std::collections::BTreeSet<_>>();
4805 assert_eq!(keys.len(), 1, "two sessions in one project did not group");
4806 let key = keys.into_iter().next().expect("one key");
4807 assert!(!key.is_empty(), "the project key is empty");
4808 assert!(
4809 !key.contains('/') && !key.contains("hel"),
4810 "the project key leaks its identity: {key}"
4811 );
4812 assert_eq!(
4813 snapshot.sessions[0].project_label, "hel",
4814 "the project label should be a name a person recognises"
4815 );
4816 }
4817
4818 #[test]
4819 fn web_project_keys_follow_the_complete_repository_set() {
4820 let (mut config, mut state) = sample_config_state();
4821 let shared_bundle = config.bundles["hel"].clone();
4822 config.bundles.insert("other".into(), shared_bundle);
4823
4824 let mut other = state.sessions["session-1"].clone();
4825 other.id = "session-2".into();
4826 other.bundle_id = "other".into();
4827 state.sessions.insert(other.id.clone(), other);
4828
4829 assert_eq!(
4830 config.bundles["hel"].primary_repo,
4831 config.bundles["other"].primary_repo
4832 );
4833 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4834 let first = snapshot
4835 .sessions
4836 .iter()
4837 .find(|session| session.id == "session-1")
4838 .expect("first session");
4839 let second = snapshot
4840 .sessions
4841 .iter()
4842 .find(|session| session.id == "session-2")
4843 .expect("second session");
4844
4845 assert_eq!(first.project_label, "hel");
4846 assert_eq!(second.project_label, "hel");
4847 assert_eq!(first.project_key, second.project_key);
4848
4849 let secondary = ProjectRepository {
4850 id: "secondary".into(),
4851 github: Some("owner/secondary".into()),
4852 local: None,
4853 destination: "secondary".into(),
4854 git_ref: None,
4855 };
4856 config
4857 .bundles
4858 .get_mut("other")
4859 .unwrap()
4860 .repositories
4861 .push(secondary.clone());
4862 let project_keys = |config: &HelConfig| {
4863 ViewerSnapshot::from_config_state(config, &state, 1)
4864 .sessions
4865 .into_iter()
4866 .map(|session| session.project_key)
4867 .collect::<Vec<_>>()
4868 };
4869 let keys = project_keys(&config);
4870 assert_ne!(
4871 keys[0], keys[1],
4872 "an added repository must change the bundle identity"
4873 );
4874
4875 let first_bundle = config.bundles.get_mut("hel").unwrap();
4876 first_bundle.repositories.insert(0, secondary);
4877 first_bundle.primary_repo = "secondary".into();
4878 let keys = project_keys(&config);
4879 assert_eq!(
4880 keys[0], keys[1],
4881 "the same repository set must group together despite order or primary choice"
4882 );
4883 }
4884
4885 #[test]
4886 fn viewer_session_applies_a_resolved_source_without_publishing_it() {
4887 let (config, state) = sample_config_state();
4888 let mut viewer = ViewerSnapshot::from_config_state(&config, &state, 1)
4889 .sessions
4890 .into_iter()
4891 .next()
4892 .expect("session");
4893 let source = ProjectSourceIdentity::git_remote("git@github.com:BrokkAi/bifrost-dev.git")
4894 .expect("GitHub source");
4895
4896 viewer.set_project_source(&source);
4897
4898 assert_eq!(viewer.project_label, "bifrost-dev");
4899 assert_eq!(viewer.project_key, project_key(&source.key));
4900 let json = serde_json::to_string(&viewer).expect("serialize viewer session");
4901 assert!(!json.contains("BrokkAi"));
4902 assert!(!json.contains("github.com"));
4903 }
4904
4905 #[test]
4908 fn lifecycle_categories_decide_what_the_dashboard_shows() {
4909 use ViewerLifecycleCategory::{Failed, Live, Starting, Stopped, Stopping};
4910
4911 for (state, expected, on_dashboard) in [
4912 (SessionState::Provisioning, Starting, true),
4913 (SessionState::Running, Live, true),
4914 (SessionState::Disconnected, Live, true),
4915 (SessionState::Checkpointing, Live, true),
4916 (SessionState::Closing, Stopping, true),
4917 (SessionState::Destroying, Stopping, true),
4918 (SessionState::Stopped, Stopped, false),
4919 (SessionState::Lost, Failed, false),
4920 (SessionState::Error, Failed, false),
4921 (SessionState::DestroyedWithDataLoss, Failed, false),
4922 ] {
4923 let category = ViewerLifecycleCategory::of(state);
4924 assert_eq!(category, expected, "{state:?}");
4925 assert_eq!(
4926 category.is_dashboard_visible(),
4927 on_dashboard,
4928 "{state:?} belongs on the dashboard? "
4929 );
4930 }
4931 }
4932
4933 #[test]
4937 fn compatible_resume_targets_are_the_complement_of_the_incompatible_ones() {
4938 let (config, state) = sample_config_state();
4939 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4940 let session = &snapshot.sessions[0];
4941 let all = config.targets.keys().cloned().collect::<Vec<_>>();
4942
4943 for target in &all {
4944 assert_ne!(
4945 session.compatible_resume_targets.contains(target),
4946 session.incompatible_resume_targets.contains(target),
4947 "target {target} is in both lists or neither"
4948 );
4949 }
4950 assert_eq!(
4951 session.compatible_resume_targets.len() + session.incompatible_resume_targets.len(),
4952 all.len(),
4953 "the two lists do not cover every target"
4954 );
4955 }
4956
4957 #[tokio::test]
4961 async fn actions_are_refused_when_their_capability_is_false() {
4962 for (body, capability) in [
4963 (
4964 r#"{"action":"cancel-turn","session_id":"session-1"}"#,
4965 "cancel_turn",
4966 ),
4967 (
4968 r#"{"action":"set-plan-mode","session_id":"session-1","active":true}"#,
4969 "set_plan_mode",
4970 ),
4971 (
4972 r#"{"action":"set-config","session_id":"session-1","key":"model","value":"x"}"#,
4973 "set_config",
4974 ),
4975 ] {
4976 let (app, mut actions, _, _, _) = app();
4977 let response = post_action(app, cookie(), body.to_owned()).await;
4978 assert!(
4979 response.status().is_client_error(),
4980 "{capability} was accepted while false: {}",
4981 response.status()
4982 );
4983 assert!(
4984 actions.try_recv().is_err(),
4985 "{capability} reached the controller while false"
4986 );
4987 }
4988 }
4989
4990 #[tokio::test]
4993 async fn a_config_key_the_harness_never_advertised_is_refused() {
4994 let capable = |snapshot: &mut ViewerSnapshot| {
4995 snapshot.sessions[0].capabilities.set_config = true;
4996 snapshot.sessions[0].config_options = vec![ViewerConfigOption {
4997 key: "model".into(),
4998 label: "model".into(),
4999 current: None,
5000 choices: vec![ViewerConfigChoice {
5001 value: "sonnet".into(),
5002 name: "Sonnet".into(),
5003 description: None,
5004 }],
5005 }];
5006 };
5007
5008 for (body, why) in [
5009 (
5010 r#"{"action":"set-config","session_id":"session-1","key":"effort","value":"high"}"#,
5011 "an unadvertised key",
5012 ),
5013 (
5014 r#"{"action":"set-config","session_id":"session-1","key":"model","value":"gpt-9"}"#,
5015 "an unoffered value",
5016 ),
5017 ] {
5018 let (app, mut actions, _, _, _) = app_with_snapshot(capable);
5019 let response = post_action(app, cookie(), body.to_owned()).await;
5020 assert_eq!(
5021 response.status(),
5022 StatusCode::BAD_REQUEST,
5023 "{why} was accepted"
5024 );
5025 assert!(actions.try_recv().is_err(), "{why} reached the controller");
5026 }
5027
5028 let (app, mut actions, _, _, _) = app_with_snapshot(capable);
5030 let response = tokio::spawn(post_action(
5031 app,
5032 cookie(),
5033 r#"{"action":"set-config","session_id":"session-1","key":"model","value":"sonnet"}"#
5034 .to_owned(),
5035 ));
5036 let action = actions
5037 .recv()
5038 .await
5039 .expect("the action reached the controller");
5040 assert!(
5041 matches!(
5042 action.action,
5043 ControllerAction::SetConfig { ref key, ref value, .. }
5044 if key == "model" && value == "sonnet"
5045 ),
5046 "the advertised value was not forwarded unchanged"
5047 );
5048 action.reply.send(ActionOutcome::Accepted).unwrap();
5049 assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
5050 }
5051
5052 #[tokio::test]
5055 async fn a_dirty_acknowledgement_is_bounded_and_names_repositories() {
5056 let oversized = (0..40)
5057 .map(|index| format!(r#""repo-{index}""#))
5058 .collect::<Vec<_>>()
5059 .join(",");
5060 for (ack, why) in [
5061 (oversized.as_str(), "an unbounded acknowledgement"),
5062 (r#""""#, "an empty repository name"),
5063 ] {
5064 let (app, mut actions, _, _, _) = app();
5065 let body = format!(
5066 r#"{{"action":"new","workspace_id":"default","profile_id":"codex-1","bundle_id":"hel","target_id":"podman","dirty_ack":[{ack}]}}"#
5067 );
5068 let response = post_action(app, cookie(), body).await;
5069 assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{why}");
5070 assert!(actions.try_recv().is_err(), "{why} reached the controller");
5071 }
5072 }
5073
5074 #[tokio::test]
5077 async fn a_new_session_without_a_title_is_accepted() {
5078 let (app, mut actions, _, _, _) = app();
5079 let response = tokio::spawn(post_action(
5080 app,
5081 cookie(),
5082 r#"{"action":"new","workspace_id":"default","profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#
5083 .to_owned(),
5084 ));
5085 let action = actions
5088 .recv()
5089 .await
5090 .expect("the action reached the controller");
5091 assert!(
5092 matches!(
5093 action.action,
5094 ControllerAction::New { title: None, ref workspace_id, .. }
5095 if workspace_id == "default"
5096 ),
5097 "the workspace or the absent title did not survive the boundary"
5098 );
5099 action.reply.send(ActionOutcome::Accepted).unwrap();
5100 assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
5101 }
5102
5103 #[test]
5107 fn a_cookie_names_one_viewer_and_two_cookies_never_collide() {
5108 let key = b"01234567890123456789012345678901";
5109 let expiry = now_unix().saturating_add(3600);
5110 let first = signed_cookie_value(key, "viewer-a", expiry);
5111 let second = signed_cookie_value(key, "viewer-b", expiry);
5112 assert_ne!(
5113 first, second,
5114 "two viewers unlocking in the same second share a cookie"
5115 );
5116 assert_eq!(
5117 cookie_viewer(key, &first, now_unix()),
5118 Some(Some("viewer-a".to_owned()))
5119 );
5120 assert_eq!(
5121 cookie_viewer(key, &second, now_unix()),
5122 Some(Some("viewer-b".to_owned()))
5123 );
5124 }
5125
5126 #[test]
5130 fn a_legacy_cookie_still_authenticates_and_stores_nothing() {
5131 let key = b"01234567890123456789012345678901";
5132 let expiry = now_unix().saturating_add(3600);
5133 let legacy = legacy_signed_cookie_value(key, expiry);
5134 assert_eq!(cookie_viewer(key, &legacy, now_unix()), Some(None));
5135 assert!(session_cookie_valid(key, &legacy, now_unix()));
5136 assert!(
5137 !session_cookie_valid(key, &legacy, expiry),
5138 "an expired legacy cookie still authenticated"
5139 );
5140 }
5141
5142 #[test]
5144 fn a_tampered_cookie_is_refused() {
5145 let key = b"01234567890123456789012345678901";
5146 let expiry = now_unix().saturating_add(3600);
5147 let honest = signed_cookie_value(key, "viewer-a", expiry);
5148 let swapped = honest.replacen("viewer-a", "viewer-b", 1);
5149 assert_eq!(cookie_viewer(key, &swapped, now_unix()), None);
5150 assert_eq!(cookie_viewer(key, "nonsense", now_unix()), None);
5151 assert_eq!(cookie_viewer(key, &format!("{expiry}."), now_unix()), None);
5152 }
5153
5154 #[tokio::test]
5157 async fn an_oversized_draft_is_refused_with_a_stable_code() {
5158 let (app, _, _, _, mut stored) = app();
5159 let draft = "x".repeat(64 * 1024 + 1);
5160 let response = app
5161 .oneshot(
5162 Request::put("/api/sessions/session-1/draft")
5163 .header(COOKIE, cookie())
5164 .header(CONTENT_TYPE, "application/json")
5165 .body(Body::from(
5166 serde_json::json!({ "draft": draft }).to_string(),
5167 ))
5168 .unwrap(),
5169 )
5170 .await
5171 .unwrap();
5172 assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
5173 assert!(stored.try_recv().is_err(), "an oversized draft was stored");
5174 }
5175
5176 #[tokio::test]
5179 async fn a_legacy_viewer_reads_empty_state_and_cannot_store_a_draft() {
5180 let key = b"01234567890123456789012345678901";
5181 let legacy = format!(
5182 "{COOKIE_NAME}={}",
5183 legacy_signed_cookie_value(key, now_unix().saturating_add(3600))
5184 );
5185
5186 let (reader, _, _, _, mut stored) = app();
5187 let response = reader
5188 .oneshot(
5189 Request::get("/api/sessions/session-1/client-state")
5190 .header(COOKIE, legacy.clone())
5191 .body(Body::empty())
5192 .unwrap(),
5193 )
5194 .await
5195 .unwrap();
5196 assert_eq!(response.status(), StatusCode::OK);
5197 let body = response.into_body().collect().await.unwrap().to_bytes();
5198 let state: ViewerClientState = serde_json::from_slice(&body).unwrap();
5199 assert_eq!(state, ViewerClientState::default());
5200 assert!(
5201 stored.try_recv().is_err(),
5202 "a legacy viewer read stored state"
5203 );
5204
5205 let (writer, _, _, _, mut stored) = app();
5206 let response = writer
5207 .oneshot(
5208 Request::put("/api/sessions/session-1/draft")
5209 .header(COOKIE, legacy)
5210 .header(CONTENT_TYPE, "application/json")
5211 .body(Body::from(r#"{"draft":"text"}"#))
5212 .unwrap(),
5213 )
5214 .await
5215 .unwrap();
5216 assert_eq!(response.status(), StatusCode::CONFLICT);
5217 assert!(stored.try_recv().is_err(), "a legacy viewer stored a draft");
5218 }
5219
5220 #[tokio::test]
5222 async fn prompt_history_refuses_an_unknown_scope() {
5223 let (app, _, _, _, mut stored) = app();
5224 let response = app
5225 .oneshot(
5226 Request::get("/api/sessions/session-1/history?q=ship&scope=everything")
5227 .header(COOKIE, cookie())
5228 .body(Body::empty())
5229 .unwrap(),
5230 )
5231 .await
5232 .unwrap();
5233 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
5234 assert!(
5235 stored.try_recv().is_err(),
5236 "the search reached the controller"
5237 );
5238 }
5239
5240 #[tokio::test]
5244 async fn a_preflight_validates_before_it_reaches_the_controller() {
5245 for (body, why) in [
5246 (
5247 r#"{"profile_id":"nope","bundle_id":"hel","target_id":"podman"}"#,
5248 "an unknown profile",
5249 ),
5250 (
5251 r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw"}"#,
5252 "a bare target with no directory",
5253 ),
5254 ] {
5255 let (app, _, _, mut preflights, _) = app();
5256 let response = app
5257 .oneshot(
5258 Request::post("/api/preflight/new")
5259 .header(COOKIE, cookie())
5260 .header(CONTENT_TYPE, "application/json")
5261 .body(Body::from(body))
5262 .unwrap(),
5263 )
5264 .await
5265 .unwrap();
5266 assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{why}");
5267 assert!(
5268 preflights.try_recv().is_err(),
5269 "{why} reached the controller"
5270 );
5271 }
5272 }
5273
5274 #[tokio::test]
5278 async fn a_bare_preflight_forwards_directory_validation_to_the_controller() {
5279 let (app, _, _, mut preflights, _) = app();
5280 let response = tokio::spawn(app.oneshot(
5281 Request::post("/api/preflight/new")
5282 .header(COOKIE, cookie())
5283 .header(CONTENT_TYPE, "application/json")
5284 .body(Body::from(
5285 r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw","project_directory":"/work/project"}"#,
5286 ))
5287 .unwrap(),
5288 ));
5289 let request = preflights.recv().await.expect("the controller was asked");
5290 assert_eq!(request.bundle_id, "hel");
5291 assert_eq!(request.target_id, "raw");
5292 assert_eq!(
5293 request.project_directory,
5294 Some(PathBuf::from("/work/project"))
5295 );
5296 request
5297 .reply
5298 .send(Ok(PreflightNew {
5299 dirty_repositories: Vec::new(),
5300 remote_repositories: Vec::new(),
5301 local_changes_excluded: false,
5302 }))
5303 .unwrap();
5304 let response = response.await.unwrap().unwrap();
5305 assert_eq!(response.status(), StatusCode::OK);
5306 let body = response.into_body().collect().await.unwrap().to_bytes();
5307 let answer: PreflightNew = serde_json::from_slice(&body).unwrap();
5308 assert!(answer.dirty_repositories.is_empty());
5309 }
5310
5311 #[tokio::test]
5312 async fn a_bare_preflight_validation_failure_is_actionable_without_its_details() {
5313 let (app, _, _, mut preflights, _) = app();
5314 let response = tokio::spawn(app.oneshot(
5315 Request::post("/api/preflight/new")
5316 .header(COOKIE, cookie())
5317 .header(CONTENT_TYPE, "application/json")
5318 .body(Body::from(
5319 r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw","project_directory":"/private/project"}"#,
5320 ))
5321 .unwrap(),
5322 ));
5323 let request = preflights.recv().await.expect("the controller was asked");
5324 request
5325 .reply
5326 .send(Err(PreflightFailure::Validation))
5327 .unwrap();
5328 let response = response.await.unwrap().unwrap();
5329 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
5330 let body = response.into_body().collect().await.unwrap().to_bytes();
5331 assert_eq!(
5332 serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
5333 serde_json::json!({
5334 "error": "project validation failed; check that the directory exists, is accessible, and contains a Git repository with a valid HEAD"
5335 })
5336 );
5337 assert!(!String::from_utf8_lossy(&body).contains("/private/project"));
5338 }
5339
5340 #[tokio::test]
5341 async fn a_bundle_preflight_controller_failure_keeps_the_generic_service_error() {
5342 let (app, _, _, mut preflights, _) = app();
5343 let response = tokio::spawn(
5344 app.oneshot(
5345 Request::post("/api/preflight/new")
5346 .header(COOKIE, cookie())
5347 .header(CONTENT_TYPE, "application/json")
5348 .body(Body::from(
5349 r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#,
5350 ))
5351 .unwrap(),
5352 ),
5353 );
5354 let request = preflights.recv().await.expect("the controller was asked");
5355 request
5356 .reply
5357 .send(Err(PreflightFailure::Controller(
5358 "private /source/hel details".into(),
5359 )))
5360 .unwrap();
5361 let response = response.await.unwrap().unwrap();
5362 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
5363 let body = response.into_body().collect().await.unwrap().to_bytes();
5364 assert_eq!(
5365 serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
5366 serde_json::json!({"error": "the controller could not check this project"})
5367 );
5368 assert!(!String::from_utf8_lossy(&body).contains("/source/hel"));
5369 }
5370
5371 #[tokio::test]
5374 async fn a_bundle_preflight_reports_network_sources_and_excludes_local_changes() {
5375 let (app, _, _, mut preflights, _) = app();
5376 let response = tokio::spawn(
5377 app.oneshot(
5378 Request::post("/api/preflight/new")
5379 .header(COOKIE, cookie())
5380 .header(CONTENT_TYPE, "application/json")
5381 .body(Body::from(
5382 r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#,
5383 ))
5384 .unwrap(),
5385 ),
5386 );
5387 let request = preflights.recv().await.expect("the controller was asked");
5388 assert_eq!(request.bundle_id, "hel");
5389 assert_eq!(request.target_id, "podman");
5390 assert_eq!(request.project_directory, None);
5391 request
5392 .reply
5393 .send(Ok(PreflightNew {
5394 dirty_repositories: Vec::new(),
5395 remote_repositories: vec![PreflightRepository {
5396 id: "hel".into(),
5397 fetch_url: "https://github.com/example/hel.git".into(),
5398 default_branch: "main".into(),
5399 push_urls: vec!["ssh://git@example/hel.git".into()],
5400 }],
5401 local_changes_excluded: true,
5402 }))
5403 .unwrap();
5404 let response = response.await.unwrap().unwrap();
5405 assert_eq!(response.status(), StatusCode::OK);
5406 let body = response.into_body().collect().await.unwrap().to_bytes();
5407 let answer: PreflightNew = serde_json::from_slice(&body).unwrap();
5408 assert!(answer.dirty_repositories.is_empty());
5409 assert!(answer.local_changes_excluded);
5410 assert_eq!(answer.remote_repositories[0].default_branch, "main");
5411 assert_eq!(answer.remote_repositories[0].push_urls.len(), 1);
5412 }
5413
5414 #[test]
5419 fn the_markdown_renderer_builds_structure_and_refuses_injection() {
5420 run_web_check(
5421 "markdown",
5422 r#"import { installDocument, elements, only, check, checkEqual } from './test-dom.js';
5423installDocument();
5424const { renderMarkdown, renderDiffSummary, safeHref } = await import('./markdown.js');
5425
5426const render = source => {
5427 const host = document.createElement('section');
5428 host.append(renderMarkdown(source));
5429 return host;
5430};
5431
5432// Headings
5433checkEqual(only(render('# Title'), 'h1').textContent, 'Title', 'h1');
5434checkEqual(only(render('### Deep'), 'h3').textContent, 'Deep', 'h3');
5435
5436// Nested lists
5437const nested = render('- one\n - inner\n- two');
5438check(elements(nested, 'ul').length === 2, 'nested list produced ' + elements(nested, 'ul').length + ' lists');
5439check(elements(elements(nested, 'ul')[0], 'li').length >= 2, 'outer list lost items');
5440
5441// Ordered lists
5442checkEqual(elements(render('1. a\n2. b'), 'ol').length, 1, 'ordered list');
5443
5444// Fenced code stays unparsed
5445const fenced = render('```rust\nlet x = *y*;\n```');
5446checkEqual(only(fenced, 'code').textContent, 'let x = *y*;', 'fenced code');
5447check(elements(fenced, 'span').some(s => s.className === 'tok-kw'), 'fenced rust untinted');
5448checkEqual(elements(fenced, 'em').length, 0, 'fence emphasised its contents');
5449checkEqual(only(fenced, 'pre').dataset.lang, 'rust', 'fence language');
5450
5451// Inline code beats emphasis
5452checkEqual(only(render('`*not em*`'), 'code').textContent, '*not em*', 'inline code');
5453checkEqual(elements(render('`*not em*`'), 'em').length, 0, 'inline code emphasised');
5454
5455// Emphasis
5456checkEqual(only(render('**bold**'), 'strong').textContent, 'bold', 'strong');
5457checkEqual(only(render('*it*'), 'em').textContent, 'it', 'em');
5458checkEqual(only(render('~~gone~~'), 'del').textContent, 'gone', 'del');
5459
5460// Tables
5461const table = render('| a | b |\n| --- | ---: |\n| 1 | 2 |');
5462checkEqual(elements(table, 'table').length, 1, 'table');
5463checkEqual(elements(table, 'th').length, 2, 'table header cells');
5464checkEqual(elements(table, 'td').length, 2, 'table body cells');
5465checkEqual(elements(table, 'th')[1].className, 'align-right', 'table alignment class');
5466checkEqual(only(table, 'div').className, 'scroll-x', 'table scroll wrapper');
5467
5468// Blockquote and rule
5469checkEqual(elements(render('> quoted'), 'blockquote').length, 1, 'blockquote');
5470checkEqual(elements(render('---'), 'hr').length, 1, 'rule');
5471
5472// XSS: markup is text, never elements
5473const injected = render('<img src=x onerror=alert(1)>');
5474checkEqual(elements(injected, 'img').length, 0, 'raw HTML became an element');
5475check(injected.textContent.includes('<img src=x onerror=alert(1)>'), 'raw HTML lost its text');
5476
5477// XSS: refused link schemes
5478for (const target of ['javascript:alert(1)', 'JaVaScRiPt:alert(1)', 'java\tscript:alert(1)', 'data:text/html,<script>', 'vbscript:x']) {
5479 const out = render(`[click](${target})`);
5480 checkEqual(elements(out, 'a').length, 0, `link scheme ${JSON.stringify(target)} was allowed`);
5481 check(out.textContent.includes('click'), `link scheme ${JSON.stringify(target)} lost its label`);
5482}
5483
5484// Accepted schemes keep their href and carry safe rel/target
5485for (const target of ['https://example.com', 'http://example.com/a', 'mailto:someone@example.com']) {
5486 const anchor = only(render(`[click](${target})`), 'a');
5487 checkEqual(anchor.getAttribute('href'), target, 'href');
5488 checkEqual(anchor.getAttribute('rel'), 'noreferrer noopener', 'rel');
5489 checkEqual(anchor.getAttribute('target'), '_blank', 'target');
5490}
5491
5492// safeHref directly
5493checkEqual(safeHref('javascript:alert(1)'), null, 'safeHref allowed javascript:');
5494checkEqual(safeHref(' https://x.test '), 'https://x.test', 'safeHref cleaned value');
5495
5496// Inline markup inside a link label
5497checkEqual(only(render('[**bold link**](https://x.test)'), 'strong').textContent, 'bold link', 'link label markup');
5498
5499// An unclosed delimiter is literal, not markup
5500checkEqual(render('a * b').textContent, 'a * b', 'unclosed emphasis');
5501checkEqual(elements(render('a * b'), 'em').length, 0, 'unclosed emphasis made an element');
5502
5503// Diff summaries: the real format from format_diffstat, two spaces and U+2212
5504const diff = renderDiffSummary(['src/main.rs +12 −3', 'unparseable line']);
5505const items = elements(diff, 'li');
5506checkEqual(items.length, 2, 'diffstat rows');
5507checkEqual(elements(items[0], 'span')[0].textContent, 'src/main.rs', 'diffstat path');
5508checkEqual(elements(items[0], 'span')[1].textContent, '+12', 'diffstat additions');
5509checkEqual(elements(items[0], 'span')[2].textContent, '−3', 'diffstat deletions');
5510checkEqual(elements(items[1], 'span').length, 1, 'unparseable diffstat produced counts');
5511checkEqual(elements(items[1], 'span')[0].textContent, 'unparseable line', 'unparseable diffstat lost its text');
5512
5513console.log('all markdown checks passed');
5514"#,
5515 );
5516 }
5517
5518 #[test]
5523 fn tool_output_is_tinted_folded_and_never_read_as_markdown() {
5524 run_web_check(
5525 "tool-output",
5526 r#"import { installDocument, elements, only, check, checkEqual, openFold } from './test-dom.js';
5527installDocument();
5528const { renderToolOutput, codeBlock, detectLang, appendCommandTokens, isPathLike } = await import(
5529 './tool-output.js'
5530);
5531
5532const classes = root => elements(root, 'span').map(s => s.className);
5533
5534// A shell command is told apart into program, subcommand, flag and path.
5535const line = document.createElement('pre');
5536appendCommandTokens(line, 'cargo test --workspace src/lib.rs');
5537const seen = classes(line);
5538check(seen.includes('cmd-program'), 'no program: ' + seen);
5539check(seen.includes('cmd-subcommand'), 'no subcommand: ' + seen);
5540check(seen.includes('cmd-flag'), 'no flag: ' + seen);
5541check(seen.includes('cmd-path'), 'no path: ' + seen);
5542checkEqual(line.textContent, 'cargo test --workspace src/lib.rs', 'command text changed');
5543
5544// An operator starts the program count again, so both programs are found.
5545const piped = document.createElement('pre');
5546appendCommandTokens(piped, 'git status && cargo build');
5547checkEqual(classes(piped).filter(c => c === 'cmd-program').length, 2, 'pipeline reset');
5548
5549// Prose with a slash is not a path; a real path is.
5550check(!isPathLike('and/or'), '"and/or" read as a path');
5551check(isPathLike('src/lib/thing.rs'), 'a real path did not');
5552check(isPathLike('./x'), 'a relative path did not');
5553check(isPathLike('Cargo.toml'), 'a file with an extension did not');
5554
5555// JSON is pretty-printed and tinted, keys apart from values.
5556const json = renderToolOutput('{"name":"hel","count":3,"ok":true}');
5557const jsonClasses = classes(json);
5558check(jsonClasses.includes('tok-key'), 'no JSON key: ' + jsonClasses);
5559check(jsonClasses.includes('tok-str'), 'no JSON string: ' + jsonClasses);
5560check(jsonClasses.includes('tok-num'), 'no JSON number: ' + jsonClasses);
5561check(jsonClasses.includes('tok-kw'), 'no JSON keyword: ' + jsonClasses);
5562check(json.textContent.includes('"name"'), 'JSON lost its content');
5563
5564// Rust is tinted; an unknown language is not.
5565const rust = codeBlock('pub fn main() {\n let x = 1;\n}', 'rust');
5566check(classes(rust).includes('tok-kw'), 'rust keywords untinted');
5567checkEqual(only(rust, 'pre').dataset.lang, 'rust', 'rust data-lang');
5568const plain = codeBlock('nothing in particular here', 'brainfuck');
5569checkEqual(classes(plain).length, 0, 'unknown language was tinted');
5570
5571// Sniffing is conservative: a log stays plain, real code does not.
5572checkEqual(detectLang('12:03 INFO started\n12:04 INFO done\n12:05 INFO stopped'), '', 'a log was sniffed');
5573checkEqual(
5574 detectLang('fn a() {}\nfn b() {}\nlet mut x = 1;\nuse std::fmt;\nimpl Foo {}\nlet y = x.unwrap();'),
5575 'rust',
5576 'rust was not sniffed',
5577);
5578checkEqual(detectLang('--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+new'), 'diff', 'diff was not sniffed');
5579
5580// A long dump is one closed fold that has built nothing yet.
5581const long = Array.from({ length: 400 }, (_, i) => `line ${i}`).join('\n');
5582const folded = renderToolOutput(long);
5583checkEqual(folded.nodeName, 'DETAILS', 'a 400-line dump was not folded');
5584checkEqual(elements(folded, 'pre').length, 0, 'a closed fold built its content anyway');
5585check(only(folded, 'summary').textContent.includes('400 lines'), 'fold summary: ' + only(folded, 'summary').textContent);
5586openFold(folded);
5587checkEqual(elements(folded, 'pre').length, 1, 'an opened fold built nothing');
5588check(elements(folded, 'pre')[0].textContent.includes('line 399'), 'the fold lost its content');
5589
5590// Opening twice builds once.
5591openFold(folded);
5592checkEqual(elements(folded, 'pre').length, 1, 'reopening rebuilt the content');
5593
5594// A short dump is not folded.
5595checkEqual(renderToolOutput('one\ntwo').nodeName, 'PRE', 'a short dump was folded');
5596
5597// Tool output is never parsed as Markdown, so an underscore is an underscore.
5598const literal = renderToolOutput('a _b_ c <img src=x>');
5599checkEqual(elements(literal, 'em').length, 0, 'tool output was emphasised');
5600checkEqual(elements(literal, 'img').length, 0, 'tool output produced an element');
5601check(literal.textContent.includes('<img src=x>'), 'tool output lost its text');
5602
5603console.log('all tool-output checks passed');
5604"#,
5605 );
5606 }
5607
5608 #[test]
5615 fn no_web_module_builds_markup_from_a_string() {
5616 const SINKS: [&str; 5] = [
5617 "innerHTML",
5618 "outerHTML",
5619 "insertAdjacentHTML",
5620 "document.write",
5621 "new Function",
5622 ];
5623 const ALLOWED: [(&str, &str); 0] = [];
5626 for (name, source) in [
5627 ("viewer.js", VIEWER_JS),
5628 ("markdown.js", MARKDOWN_JS),
5629 ("tool-output.js", TOOL_OUTPUT_JS),
5630 ] {
5631 for (number, line) in source.lines().enumerate() {
5632 let trimmed = line.trim();
5633 if trimmed.starts_with("//") || trimmed.starts_with("///") {
5634 continue;
5635 }
5636 for sink in SINKS {
5637 if !trimmed.contains(sink) {
5638 continue;
5639 }
5640 assert!(
5641 ALLOWED
5642 .iter()
5643 .any(|(file, allowed)| *file == name && trimmed == *allowed),
5644 "{name}:{} builds markup from a string: {trimmed}",
5645 number + 1
5646 );
5647 }
5648 }
5649 }
5650 }
5651
5652 #[test]
5656 fn embedded_viewer_keeps_elicitation_answers_across_snapshot_polls() {
5657 let source = viewer_source(
5658 "const elicitationCards = new Map()",
5659 "async function submitElicitation",
5660 );
5661 let dom = r#"
5662let replaceCalls = 0;
5663function makeEl(tag) {
5664 return {
5665 tagName: tag.toUpperCase(),
5666 children: [],
5667 options: [],
5668 selectedOptions: [],
5669 className: "",
5670 textContent: "",
5671 disabled: false,
5672 required: false,
5673 value: "",
5674 appendChild(child) {
5675 this.children.push(child);
5676 if (this.tagName === "SELECT") this.options.push(child);
5677 return child;
5678 },
5679 append(...kids) {
5680 this.children.push(...kids);
5681 },
5682 replaceChildren(...kids) {
5683 replaceCalls += 1;
5684 this.children = kids;
5685 },
5686 addEventListener() {},
5687 querySelectorAll(selector) {
5688 const found = [];
5689 const visit = node => {
5690 for (const child of node.children) {
5691 if (child.tagName === "INPUT" && (selector === "input" || child.checked)) found.push(child);
5692 visit(child);
5693 }
5694 };
5695 visit(this);
5696 return found;
5697 },
5698 querySelector(selector) { return this.querySelectorAll(selector)[0] || null; },
5699 setCustomValidity() {},
5700 reportValidity() {
5701 return true;
5702 },
5703 };
5704}
5705const created = [];
5706const document = {
5707 createElement(tag) {
5708 const el = makeEl(tag);
5709 created.push(el);
5710 return el;
5711 },
5712};
5713const elicitations = makeEl("div");
5714function el(tag, className, text) {
5715 const node = document.createElement(tag);
5716 node.className = className || "";
5717 node.textContent = text || "";
5718 return node;
5719}
5720async function submitElicitation() {}
5721"#;
5722 let checks = r#"
5723const request = {
5724 id: "elicitation-1",
5725 message: "Which CI architecture?",
5726 title: "CI",
5727 fields: [
5728 {
5729 id: "question_0",
5730 title: "CI architecture",
5731 required: false,
5732 kind: "single_select",
5733 options: [{ value: "reusable", title: "Reusable" }, { value: "matrix", title: "Matrix" }],
5734 },
5735 { id: "question_0_custom", title: "Other", required: false, kind: "text" },
5736 ],
5737};
5738const session = { id: "session-1", pending_elicitations: [request] };
5739renderElicitations(session);
5740const card = elicitations.children[0];
5741const radio = created.find((el) => el.tagName === "INPUT" && el.value === "reusable");
5742const text = created.find((el) => el.tagName === "INPUT" && el.type === "text");
5743radio.checked = true;
5744text.value = "keep me";
5745const attachments = replaceCalls;
5746renderElicitations(session);
5747if (elicitations.children[0] !== card) {
5748 throw new Error("a snapshot rebuilt the pending card");
5749}
5750if (!radio.checked || text.value !== "keep me") {
5751 throw new Error("a snapshot wiped the half-filled answer");
5752}
5753if (replaceCalls !== attachments) {
5754 throw new Error("a snapshot re-attached an unchanged card and dropped focus");
5755}
5756sentElicitations.add(elicitationKey("session-1", request.id));
5757renderElicitations(session);
5758if (elicitations.children[0] !== card) {
5759 throw new Error("a sent answer rebuilt the card");
5760}
5761if (!radio.disabled || !text.disabled) {
5762 throw new Error("a sent answer left the controls live");
5763}
5764if (!radio.checked) {
5765 throw new Error("a sent answer wiped the reply");
5766}
5767renderElicitations({ id: "session-1", pending_elicitations: [] });
5768if (elicitations.children.length !== 0 || elicitationCards.size !== 0) {
5769 throw new Error("an answered request stayed rendered");
5770}
5771if (sentElicitations.size !== 0) {
5772 throw new Error("a resolved request kept its sent marker");
5773}
5774"#;
5775 run_viewer_script(
5776 "elicitation-rendering",
5777 &format!("{dom}\n{source}\n{checks}"),
5778 );
5779 }
5780
5781 fn sample_image(pixels: usize) -> ViewerPromptImage {
5782 ViewerPromptImage {
5783 data_base64: base64::engine::general_purpose::STANDARD.encode(vec![7_u8; pixels]),
5784 mime_type: "image/png".into(),
5785 width: 32,
5786 height: 24,
5787 attachment: None,
5788 }
5789 }
5790
5791 fn sample_valid_image() -> ViewerPromptImage {
5792 ViewerPromptImage {
5793 data_base64: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
5794 .into(),
5795 mime_type: "image/png".into(),
5796 width: 1,
5797 height: 1,
5798 attachment: None,
5799 }
5800 }
5801
5802 fn image_capable(snapshot: &mut ViewerSnapshot) {
5803 snapshot.sessions[0].prompt_images_supported = true;
5804 }
5805
5806 async fn post_action(app: Router, cookie: String, body: String) -> Response<Body> {
5807 app.oneshot(
5808 Request::post("/api/actions")
5809 .header(COOKIE, cookie)
5810 .header(CONTENT_TYPE, "application/json")
5811 .body(Body::from(body))
5812 .unwrap(),
5813 )
5814 .await
5815 .unwrap()
5816 }
5817
5818 #[tokio::test]
5819 async fn image_prompt_reaches_the_controller_with_its_images() {
5820 let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
5821 let cookie = login_cookie(&app).await;
5822 let image = sample_valid_image();
5823 let body = serde_json::to_string(&ControllerAction::Prompt {
5824 session_id: "session-1".into(),
5825 text: String::new(),
5826 images: vec![image.clone(), image.clone()],
5827 })
5828 .unwrap();
5829 let response = tokio::spawn(post_action(app, cookie, body));
5830 let request = actions.recv().await.unwrap();
5831 let ControllerRequest { action, reply } = request;
5832 let ControllerAction::Prompt {
5833 session_id,
5834 text,
5835 images,
5836 } = action
5837 else {
5838 panic!("expected a prompt action")
5839 };
5840 assert_eq!(session_id, "session-1");
5841 assert!(text.is_empty());
5842 assert_eq!(images.len(), 2);
5843 assert!(
5844 images
5845 .iter()
5846 .all(|image| { image.data_base64.is_empty() && image.attachment.is_some() })
5847 );
5848 reply.send(ActionOutcome::Accepted).unwrap();
5849 assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
5850 }
5851
5852 #[tokio::test]
5853 async fn browser_attachment_upload_returns_a_stored_reference_without_inline_bytes() {
5854 let (app, _, _, _, _) = app_with_snapshot(image_capable);
5855 let cookie = login_cookie(&app).await;
5856 let bytes = base64::engine::general_purpose::STANDARD
5857 .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")
5858 .unwrap();
5859 let response = app
5860 .oneshot(
5861 Request::post("/api/sessions/session-1/attachments")
5862 .header(COOKIE, cookie)
5863 .header(CONTENT_TYPE, "image/png")
5864 .body(Body::from(bytes))
5865 .unwrap(),
5866 )
5867 .await
5868 .unwrap();
5869 assert_eq!(response.status(), StatusCode::OK);
5870 let body = response.into_body().collect().await.unwrap().to_bytes();
5871 let image: ViewerPromptImage = serde_json::from_slice(&body).unwrap();
5872 assert!(image.data_base64.is_empty());
5873 let reference = image.attachment.expect("upload should return a reference");
5874 assert_eq!(reference.mime_type, "image/png");
5875 assert_eq!(reference.width, 1);
5876 assert_eq!(reference.height, 1);
5877 assert!(reference.size <= 700 * 1024);
5878 }
5879
5880 #[tokio::test]
5884 async fn multi_image_prompts_are_accepted_over_the_general_body_limit() {
5885 let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
5886 let cookie = login_cookie(&app).await;
5887 let image = sample_valid_image();
5888 let mut body = serde_json::to_string(&ControllerAction::Prompt {
5889 session_id: "session-1".into(),
5890 text: "look at these".into(),
5891 images: vec![image.clone(), image],
5892 })
5893 .unwrap();
5894 body.push_str(&" ".repeat(MAX_BODY_BYTES));
5895 assert!(body.len() > MAX_BODY_BYTES);
5896 assert!(body.len() < MAX_PROMPT_BODY_BYTES);
5897 let response = tokio::spawn(post_action(app, cookie, body));
5898 let action = actions.recv().await.unwrap();
5899 action.reply.send(ActionOutcome::Accepted).unwrap();
5900 assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
5901 }
5902
5903 #[tokio::test]
5904 async fn a_body_over_the_prompt_limit_is_still_refused() {
5905 let (app, _actions, _, _, _) = app_with_snapshot(image_capable);
5906 let cookie = login_cookie(&app).await;
5907 let image = sample_image(MAX_PROMPT_BODY_BYTES);
5908 let body = serde_json::to_string(&ControllerAction::Prompt {
5909 session_id: "session-1".into(),
5910 text: String::new(),
5911 images: vec![image],
5912 })
5913 .unwrap();
5914 assert!(body.len() > MAX_PROMPT_BODY_BYTES);
5915 let response = post_action(app, cookie, body).await;
5916 assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
5917 }
5918
5919 #[tokio::test]
5920 async fn malformed_image_payloads_never_reach_the_controller() {
5921 let cases = [
5922 ("aW1hZ2U=", "text/plain", 32, 24),
5923 ("aW1hZ2U=", "image/png", 0, 24),
5924 ("not base64!", "image/png", 32, 24),
5925 ("", "image/png", 32, 24),
5926 ];
5927 for (data, mime, width, height) in cases {
5928 let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
5929 let cookie = login_cookie(&app).await;
5930 let body = serde_json::to_string(&ControllerAction::Prompt {
5931 session_id: "session-1".into(),
5932 text: String::new(),
5933 images: vec![ViewerPromptImage {
5934 data_base64: data.into(),
5935 mime_type: mime.into(),
5936 width,
5937 height,
5938 attachment: None,
5939 }],
5940 })
5941 .unwrap();
5942 let response = post_action(app, cookie, body).await;
5943 assert_eq!(
5944 response.status(),
5945 StatusCode::BAD_REQUEST,
5946 "expected {data:?}/{mime} {width}x{height} to be refused"
5947 );
5948 assert!(actions.try_recv().is_err());
5949 }
5950 }
5951
5952 #[test]
5953 fn image_prompts_need_text_or_an_image_and_an_agent_that_takes_them() {
5954 let (config, state) = sample_config_state();
5955 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
5956 let prompt = |text: &str, images: Vec<ViewerPromptImage>| ControllerAction::Prompt {
5957 session_id: "session-1".into(),
5958 text: text.into(),
5959 images,
5960 };
5961
5962 assert!(validate_action(&prompt("ship it", Vec::new()), &snapshot).is_ok());
5964 assert!(validate_action(&prompt("", vec![sample_image(8)]), &snapshot).is_err());
5965
5966 image_capable(&mut snapshot);
5967 assert!(validate_action(&prompt("", vec![sample_image(8)]), &snapshot).is_ok());
5969 assert!(
5970 validate_action(
5971 &prompt("", vec![sample_image(8); MAX_PROMPT_IMAGES + 1]),
5972 &snapshot,
5973 )
5974 .is_err()
5975 );
5976 assert!(validate_action(&prompt(" ", Vec::new()), &snapshot).is_err());
5977 assert!(validate_action(&prompt("", Vec::new()), &snapshot).is_err());
5978 assert!(validate_action(&prompt("!ls", vec![sample_image(8)]), &snapshot).is_err());
5980 }
5981
5982 #[test]
5985 fn embedded_viewer_reads_multiline_composer_text_out_of_its_dom() {
5986 let source = viewer_source("function composerText()", "function setComposerText(");
5987 let harness = r##"
5988const Node = { TEXT_NODE: 3 };
5989function textNode(value) {
5990 return { nodeType: 3, nodeValue: value, nodeName: "#text", childNodes: [], dataset: {} };
5991}
5992function element(name, children = [], dataset = {}) {
5993 const node = { nodeType: 1, nodeName: name, dataset, childNodes: children };
5994 children.forEach((child, index) => {
5995 child.nextSibling = children[index + 1] || null;
5996 });
5997 return node;
5998}
5999let promptText = null;
6000function read(children) {
6001 promptText = element("DIV", children);
6002 return composerText();
6003}
6004"##;
6005 let checks = r#"
6006const plain = read([textNode("ship it")]);
6007if (plain !== "ship it") throw new Error(`plain text became ${JSON.stringify(plain)}`);
6008
6009const broken = read([textNode("first"), element("BR"), textNode("second")]);
6010if (broken !== "first\nsecond") throw new Error(`line break became ${JSON.stringify(broken)}`);
6011
6012// The trailing break a browser leaves behind to keep the caret on a new line
6013// is scaffolding, not a line the user typed.
6014const filler = read([
6015 textNode("first"),
6016 element("BR"),
6017 element("BR", [], { composerFiller: "true" }),
6018]);
6019if (filler !== "first\n") throw new Error(`filler break became ${JSON.stringify(filler)}`);
6020
6021const blocks = read([
6022 textNode("first"),
6023 element("DIV", [textNode("second")]),
6024 element("DIV", [textNode("third")]),
6025]);
6026if (blocks !== "first\nsecond\nthird") throw new Error(`blocks became ${JSON.stringify(blocks)}`);
6027
6028const carriage = read([textNode("first\r\nsecond")]);
6029if (carriage !== "first\nsecond") throw new Error(`CRLF became ${JSON.stringify(carriage)}`);
6030"#;
6031 run_viewer_script("composer-reader", &format!("{harness}\n{source}\n{checks}"));
6032 }
6033
6034 #[tokio::test]
6038 async fn viewer_declares_the_icon_route_instead_of_requesting_a_missing_favicon() {
6039 let (app, _, _, _, _) = app();
6040 let page = fetch_text(app.clone(), "/").await;
6041 assert!(page.contains(r#"rel="icon""#), "the page declares no icon");
6042 assert!(page.contains("/icon.svg"), "the page names no icon route");
6043 let icon = app
6044 .oneshot(Request::get("/icon.svg").body(Body::empty()).unwrap())
6045 .await
6046 .unwrap();
6047 assert_eq!(icon.status(), StatusCode::OK);
6048 assert_eq!(
6049 icon.headers().get(CONTENT_TYPE).unwrap(),
6050 "image/svg+xml",
6051 "the icon route does not serve an SVG"
6052 );
6053 }
6054
6055 #[tokio::test]
6056 async fn valid_action_is_typed_and_forwarded() {
6057 let (app, mut actions, _, _, _) = app();
6058 let cookie = login_cookie(&app).await;
6059 let response = tokio::spawn(
6060 app.oneshot(
6061 Request::post("/api/actions")
6062 .header(COOKIE, cookie)
6063 .header(CONTENT_TYPE, "application/json")
6064 .body(Body::from(
6065 r#"{"action":"prompt","session_id":"session-1","text":"ship it"}"#,
6066 ))
6067 .unwrap(),
6068 ),
6069 );
6070 let action = actions.recv().await.unwrap();
6071 assert_eq!(
6072 action.action,
6073 ControllerAction::Prompt {
6074 session_id: "session-1".into(),
6075 text: "ship it".into(),
6076 images: Vec::new(),
6077 }
6078 );
6079 action.reply.send(ActionOutcome::Accepted).unwrap();
6080 let response = response.await.unwrap().unwrap();
6081 assert_eq!(response.status(), StatusCode::ACCEPTED);
6082 }
6083
6084 #[tokio::test]
6085 async fn move_preparation_is_read_only_and_returns_the_daemon_fingerprint() {
6086 let (app, mut preparations) = app_with_move_receiver();
6087 let cookie = login_cookie(&app).await;
6088 let response = tokio::spawn(
6089 app.oneshot(
6090 Request::post("/api/moves/prepare")
6091 .header(COOKIE, cookie)
6092 .header(CONTENT_TYPE, "application/json")
6093 .body(Body::from(
6094 r#"{"session_id":"session-1","profile_id":"codex-1","target_template_id":"podman","clear_resource_allocation":false,"additional_mounts":null,"resource_allocation":null}"#,
6095 ))
6096 .unwrap(),
6097 ),
6098 );
6099 let request = preparations
6100 .recv()
6101 .await
6102 .expect("preparation reached daemon");
6103 assert_eq!(request.selection.session_id, "session-1");
6104 assert_eq!(request.selection.profile_id.as_deref(), Some("codex-1"));
6105 assert_eq!(
6106 request.selection.target_template_id.as_deref(),
6107 Some("podman")
6108 );
6109 request
6110 .reply
6111 .send(Ok(MovePreparation {
6112 selection: request.selection,
6113 source_profile_id: "codex-1".into(),
6114 source_target_template_id: "podman".into(),
6115 cross_harness: false,
6116 active: true,
6117 queued_commands: vec![hel::hel_state::MaterializedQueuedPrompt {
6118 command_id: "queued-1".into(),
6119 kind: hel::hel_state::QueuedCommandKind::Prompt,
6120 content: vec![serde_json::json!({
6121 "type": "image",
6122 "mimeType": "image/png",
6123 "data": "secret-image-bytes"
6124 })],
6125 queued_at_ms: 1,
6126 }],
6127 fingerprint: "fingerprint".into(),
6128 operation_id: "move-1".into(),
6129 }))
6130 .unwrap();
6131 let response = response.await.unwrap().unwrap();
6132 assert_eq!(response.status(), StatusCode::OK);
6133 let body = response.into_body().collect().await.unwrap().to_bytes();
6134 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
6135 assert_eq!(body["operation_id"], "move-1");
6136 assert_eq!(body["active"], true);
6137 assert_eq!(
6138 body["queued_commands"][0]["content"][0]["text"],
6139 "[Image attachment: image/png]"
6140 );
6141 assert!(body.to_string().contains("[Image attachment: image/png]"));
6142 assert!(!body.to_string().contains("secret-image-bytes"));
6143 }
6144
6145 #[tokio::test]
6146 async fn confirmed_move_action_forwards_the_fingerprinted_request() {
6147 let (app, mut actions, _, _, _) = app_with_snapshot(|snapshot| {
6148 snapshot.sessions[0].capabilities.move_session = true;
6149 });
6150 let cookie = login_cookie(&app).await;
6151 let response = tokio::spawn(
6152 app.oneshot(
6153 Request::post("/api/actions")
6154 .header(COOKIE, cookie)
6155 .header(CONTENT_TYPE, "application/json")
6156 .body(Body::from(
6157 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}}"#,
6158 ))
6159 .unwrap(),
6160 ),
6161 );
6162 let action = actions.recv().await.expect("move action reached daemon");
6163 assert!(matches!(action.action, ControllerAction::Move { .. }));
6164 action.reply.send(ActionOutcome::Accepted).unwrap();
6165 assert_eq!(
6166 response.await.unwrap().unwrap().status(),
6167 StatusCode::ACCEPTED
6168 );
6169 }
6170
6171 #[tokio::test]
6172 async fn shell_action_is_typed_and_forwarded() {
6173 let (app, mut actions, _, _, _) = app();
6174 let cookie = login_cookie(&app).await;
6175 let response = tokio::spawn(
6176 app.oneshot(
6177 Request::post("/api/actions")
6178 .header(COOKIE, cookie)
6179 .header(CONTENT_TYPE, "application/json")
6180 .body(Body::from(
6181 r#"{"action":"run-shell","session_id":"session-1","command":"cargo test"}"#,
6182 ))
6183 .unwrap(),
6184 ),
6185 );
6186 let action = actions.recv().await.unwrap();
6187 assert_eq!(
6188 action.action,
6189 ControllerAction::RunShell {
6190 session_id: "session-1".into(),
6191 command: "cargo test".into(),
6192 }
6193 );
6194 action.reply.send(ActionOutcome::Accepted).unwrap();
6195 assert_eq!(
6196 response.await.unwrap().unwrap().status(),
6197 StatusCode::ACCEPTED
6198 );
6199 }
6200
6201 #[test]
6202 fn shell_action_validation_reserves_bang_prompts_and_checks_cancellation_ids() {
6203 let (config, state) = sample_config_state();
6204 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6205 assert!(
6206 validate_action(
6207 &ControllerAction::Prompt {
6208 session_id: "session-1".into(),
6209 text: "!cargo test".into(),
6210 images: Vec::new(),
6211 },
6212 &snapshot,
6213 )
6214 .is_err()
6215 );
6216 assert!(
6217 validate_action(
6218 &ControllerAction::RunShell {
6219 session_id: "session-1".into(),
6220 command: "cargo test".into(),
6221 },
6222 &snapshot,
6223 )
6224 .is_ok()
6225 );
6226 assert!(
6227 validate_action(
6228 &ControllerAction::CancelShell {
6229 session_id: "session-1".into(),
6230 shell_command_id: "shell-1".into(),
6231 },
6232 &snapshot,
6233 )
6234 .is_err()
6235 );
6236
6237 snapshot.sessions[0]
6238 .active_user_shells
6239 .push(ViewerUserShell {
6240 id: "shell-1".into(),
6241 command: "cargo test".into(),
6242 started_at_ms: Some(10),
6243 });
6244 assert!(
6245 validate_action(
6246 &ControllerAction::CancelShell {
6247 session_id: "session-1".into(),
6248 shell_command_id: "shell-1".into(),
6249 },
6250 &snapshot,
6251 )
6252 .is_ok()
6253 );
6254 }
6255
6256 #[tokio::test]
6257 async fn bare_new_action_forwards_an_explicit_safe_project_directory() {
6258 let (app, mut actions, _, _, _) = app();
6259 let cookie = login_cookie(&app).await;
6260 let response = tokio::spawn(
6261 app.oneshot(
6262 Request::post("/api/actions")
6263 .header(COOKIE, cookie)
6264 .header(CONTENT_TYPE, "application/json")
6265 .body(Body::from(
6266 r#"{"action":"new","profile_id":"codex-1","bundle_id":"hel","target_id":"raw","title":"Raw work","project_directory":"/work/project"}"#,
6267 ))
6268 .unwrap(),
6269 ),
6270 );
6271 let action = actions.recv().await.unwrap();
6272 assert_eq!(
6273 action.action,
6274 ControllerAction::New {
6275 workspace_id: String::new(),
6276 profile_id: "codex-1".into(),
6277 bundle_id: "hel".into(),
6278 target_id: "raw".into(),
6279 title: Some("Raw work".into()),
6280 project_directory: Some(PathBuf::from("/work/project")),
6281 dirty_ack: Vec::new(),
6282 }
6283 );
6284 action.reply.send(ActionOutcome::Accepted).unwrap();
6285 assert_eq!(
6286 response.await.unwrap().unwrap().status(),
6287 StatusCode::ACCEPTED
6288 );
6289 }
6290
6291 #[test]
6292 fn new_action_requires_project_directory_exactly_for_bare_targets() {
6293 let (config, state) = sample_config_state();
6294 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6295 let action = |target_id: &str, project_directory: Option<PathBuf>| ControllerAction::New {
6296 workspace_id: String::new(),
6297 profile_id: "codex-1".into(),
6298 bundle_id: "hel".into(),
6299 target_id: target_id.into(),
6300 title: Some("New work".into()),
6301 project_directory,
6302 dirty_ack: Vec::new(),
6303 };
6304
6305 assert!(validate_action(&action("podman", None), &snapshot).is_ok());
6306 assert_eq!(
6307 validate_action(&action("podman", Some("/work".into())), &snapshot)
6308 .unwrap_err()
6309 .status,
6310 StatusCode::BAD_REQUEST
6311 );
6312 assert_eq!(
6313 validate_action(&action("raw", None), &snapshot)
6314 .unwrap_err()
6315 .status,
6316 StatusCode::BAD_REQUEST
6317 );
6318 assert_eq!(
6319 validate_action(&action("raw", Some("relative".into())), &snapshot)
6320 .unwrap_err()
6321 .status,
6322 StatusCode::BAD_REQUEST
6323 );
6324 assert_eq!(
6325 validate_action(&action("raw", Some("/work/../secret".into())), &snapshot)
6326 .unwrap_err()
6327 .status,
6328 StatusCode::BAD_REQUEST
6329 );
6330 assert!(validate_action(&action("raw", Some("/work/project".into())), &snapshot).is_ok());
6331 }
6332
6333 #[tokio::test]
6334 async fn cancel_action_is_typed_and_forwarded() {
6335 let (app, mut actions, _, _, _) = app();
6336 let cookie = login_cookie(&app).await;
6337 let response = tokio::spawn(
6338 app.oneshot(
6339 Request::post("/api/actions")
6340 .header(COOKIE, cookie)
6341 .header(CONTENT_TYPE, "application/json")
6342 .body(Body::from(
6343 r#"{"action":"cancel","session_id":"session-1"}"#,
6344 ))
6345 .unwrap(),
6346 ),
6347 );
6348 let action = actions.recv().await.unwrap();
6349 assert_eq!(
6350 action.action,
6351 ControllerAction::Cancel {
6352 session_id: "session-1".into(),
6353 }
6354 );
6355 action.reply.send(ActionOutcome::Accepted).unwrap();
6356 assert_eq!(
6357 response.await.unwrap().unwrap().status(),
6358 StatusCode::ACCEPTED
6359 );
6360 }
6361
6362 #[tokio::test]
6363 async fn action_validation_accepts_cross_harness_resume_and_rejects_unknown() {
6364 let (mut config, state) = sample_config_state();
6365 config.profiles.insert(
6366 "claude-1".into(),
6367 HarnessProfile {
6368 context_window_bytes: None,
6369 kind: HarnessKind::Claude,
6370 home: "/secret/claude".into(),
6371 environment: BTreeMap::new(),
6372 },
6373 );
6374 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6375 snapshot.workspaces.push(ViewerWorkspace {
6376 id: "workspace-1".into(),
6377 name: "One".into(),
6378 });
6379 validate_action(
6380 &ControllerAction::Resume {
6381 session_id: "session-1".into(),
6382 workspace_id: "workspace-1".into(),
6383 profile_id: "claude-1".into(),
6384 target_id: "podman".into(),
6385 queue: ResumeQueueDisposition::Start,
6386 additional_mounts: None,
6387 resource_allocation: None,
6388 },
6389 &snapshot,
6390 )
6391 .unwrap();
6392
6393 let error = validate_action(
6394 &ControllerAction::Resume {
6395 session_id: "session-1".into(),
6396 workspace_id: "missing".into(),
6397 profile_id: "claude-1".into(),
6398 target_id: "podman".into(),
6399 queue: ResumeQueueDisposition::Start,
6400 additional_mounts: None,
6401 resource_allocation: None,
6402 },
6403 &snapshot,
6404 )
6405 .unwrap_err();
6406 assert_eq!(error.status, StatusCode::BAD_REQUEST);
6407
6408 let error = validate_action(
6409 &ControllerAction::Close {
6410 session_id: "not-managed".into(),
6411 },
6412 &snapshot,
6413 )
6414 .unwrap_err();
6415 assert_eq!(error.status, StatusCode::NOT_FOUND);
6416 }
6417
6418 #[test]
6421 fn a_running_review_projects_to_the_phone() {
6422 use crate::hel_review_host::{RuntimeReviewView, VerdictKind, VerdictView};
6423 use hel::hel_review::driver::{Resolution, RoleState, RoleStatus, TurnReviewPhase};
6424
6425 let review = RuntimeReviewView {
6426 session_id: "session-1".into(),
6427 tier: hel::hel_review::lanes::ReviewTier::Extended,
6428 phase: TurnReviewPhase::Verdict(hel::hel_review::verdict::ReviewVerdict::Findings {
6429 synthesis: "[P1] src/lib.rs:1 -- unbounded retry".into(),
6430 evidence: Default::default(),
6431 }),
6432 roles: vec![
6433 RoleStatus {
6434 role: "supervisor".into(),
6435 label: "Supervisor".into(),
6436 state: RoleState::Clean,
6437 },
6438 RoleStatus {
6439 role: "tests".into(),
6440 label: "Tests".into(),
6441 state: RoleState::Findings,
6442 },
6443 ],
6444 status: "Enter to act".into(),
6445 verdict: Some(VerdictView {
6446 kind: VerdictKind::Findings,
6447 text: "[P1] src/lib.rs:1 -- unbounded retry".into(),
6448 allowed: vec![
6449 Resolution::Forwarded,
6450 Resolution::Dismissed,
6451 Resolution::Cancelled,
6452 ],
6453 }),
6454 };
6455
6456 let projected = ViewerTurnReview::from_runtime(&review);
6457
6458 assert_eq!(projected.tier, "extended");
6459 assert_eq!(
6460 projected
6461 .roles
6462 .iter()
6463 .map(|role| (role.label.as_str(), role.state.as_str()))
6464 .collect::<Vec<_>>(),
6465 vec![("Supervisor", "done"), ("Tests", "findings")]
6466 );
6467 let verdict = projected.verdict.expect("a findings verdict travels");
6468 assert_eq!(verdict.kind, "findings");
6469 assert!(verdict.text.contains("unbounded retry"));
6470 assert_eq!(verdict.allowed, vec!["forward", "dismiss", "cancel"]);
6471 }
6472
6473 #[test]
6477 fn resolving_a_review_is_gated_on_what_the_daemon_published() {
6478 let (config, state) = sample_config_state();
6479 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6480
6481 let resolve = |resolution: &str| ControllerAction::ResolveReview {
6482 session_id: "session-1".into(),
6483 resolution: resolution.into(),
6484 };
6485
6486 let error = validate_action(&resolve("cancel"), &snapshot).unwrap_err();
6488 assert_eq!(error.status, StatusCode::BAD_REQUEST);
6489
6490 snapshot.sessions[0].turn_review = Some(ViewerTurnReview {
6491 tier: "quick".into(),
6492 status: "the reviewer is reading the change…".into(),
6493 roles: Vec::new(),
6494 verdict: None,
6495 });
6496 validate_action(&resolve("cancel"), &snapshot).unwrap();
6498 assert_eq!(
6499 validate_action(&resolve("forward"), &snapshot)
6500 .unwrap_err()
6501 .status,
6502 StatusCode::BAD_REQUEST
6503 );
6504
6505 snapshot.sessions[0].turn_review = Some(ViewerTurnReview {
6507 tier: "quick".into(),
6508 status: "the review failed".into(),
6509 roles: Vec::new(),
6510 verdict: Some(ViewerReviewVerdict {
6511 kind: "failed".into(),
6512 text: "bifrost exited with 1".into(),
6513 allowed: vec!["dismiss".into(), "cancel".into()],
6514 }),
6515 });
6516 validate_action(&resolve("dismiss"), &snapshot).unwrap();
6517 assert_eq!(
6518 validate_action(&resolve("forward"), &snapshot)
6519 .unwrap_err()
6520 .status,
6521 StatusCode::BAD_REQUEST
6522 );
6523 assert_eq!(
6525 validate_action(&resolve("approve"), &snapshot)
6526 .unwrap_err()
6527 .status,
6528 StatusCode::BAD_REQUEST
6529 );
6530
6531 validate_action(
6533 &ControllerAction::StartReview {
6534 session_id: "session-1".into(),
6535 },
6536 &snapshot,
6537 )
6538 .unwrap();
6539 assert_eq!(
6540 validate_action(
6541 &ControllerAction::StartReview {
6542 session_id: "not-managed".into(),
6543 },
6544 &snapshot,
6545 )
6546 .unwrap_err()
6547 .status,
6548 StatusCode::NOT_FOUND
6549 );
6550 }
6551
6552 #[test]
6553 fn resume_action_refuses_a_target_the_session_cannot_use() {
6554 let (mut config, state) = sample_config_state();
6555 config.bundles.get_mut("hel").unwrap().repositories[0].local = None;
6558 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6559 snapshot.workspaces.push(ViewerWorkspace {
6560 id: "workspace-1".into(),
6561 name: "One".into(),
6562 });
6563 assert_eq!(
6564 snapshot.sessions[0].incompatible_resume_targets,
6565 vec!["raw".to_owned()]
6566 );
6567
6568 let error = validate_action(
6569 &ControllerAction::Resume {
6570 session_id: "session-1".into(),
6571 workspace_id: "workspace-1".into(),
6572 profile_id: "codex-1".into(),
6573 target_id: "raw".into(),
6574 queue: ResumeQueueDisposition::Start,
6575 additional_mounts: None,
6576 resource_allocation: None,
6577 },
6578 &snapshot,
6579 )
6580 .unwrap_err();
6581
6582 assert_eq!(error.status, StatusCode::BAD_REQUEST);
6583 }
6584
6585 #[test]
6586 fn move_confirmation_requires_interruption_ack_and_an_explicit_queue_choice() {
6587 let (config, state) = sample_config_state();
6588 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6589 snapshot.sessions[0].capabilities.move_session = true;
6590 let selection = MoveSelection {
6591 clear_resource_allocation: false,
6592 session_id: "session-1".into(),
6593 profile_id: Some("codex-1".into()),
6594 target_template_id: Some("podman".into()),
6595 additional_mounts: None,
6596 resource_allocation: None,
6597 };
6598 let preparation = MovePreparation {
6599 selection,
6600 source_profile_id: "codex-1".into(),
6601 source_target_template_id: "podman".into(),
6602 cross_harness: false,
6603 active: true,
6604 queued_commands: vec![hel::hel_state::MaterializedQueuedPrompt {
6605 command_id: "command-1".into(),
6606 kind: hel::hel_state::QueuedCommandKind::Prompt,
6607 content: vec![serde_json::json!({"type": "text", "text": "continue"})],
6608 queued_at_ms: 1,
6609 }],
6610 fingerprint: "fingerprint".into(),
6611 operation_id: "move-1".into(),
6612 };
6613 let request = |queue, acknowledge_interruption| MoveSessionRequest {
6614 preparation: preparation.clone(),
6615 queue,
6616 acknowledge_interruption,
6617 };
6618 assert_eq!(
6619 validate_action(
6620 &ControllerAction::Move {
6621 request: request(Some(ResumeQueueDisposition::Discard), false),
6622 },
6623 &snapshot,
6624 )
6625 .unwrap_err()
6626 .status,
6627 StatusCode::CONFLICT
6628 );
6629 assert_eq!(
6630 validate_action(
6631 &ControllerAction::Move {
6632 request: request(None, true),
6633 },
6634 &snapshot,
6635 )
6636 .unwrap_err()
6637 .status,
6638 StatusCode::BAD_REQUEST
6639 );
6640 validate_action(
6641 &ControllerAction::Move {
6642 request: request(Some(ResumeQueueDisposition::Discard), true),
6643 },
6644 &snapshot,
6645 )
6646 .unwrap();
6647 }
6648
6649 #[tokio::test]
6650 async fn snapshot_endpoint_returns_only_public_projection() {
6651 let (app, _, _, _, _) = app();
6652 let cookie = login_cookie(&app).await;
6653 let response = app
6654 .oneshot(
6655 Request::get("/api/snapshot")
6656 .header(COOKIE, cookie)
6657 .body(Body::empty())
6658 .unwrap(),
6659 )
6660 .await
6661 .unwrap();
6662 let body = response.into_body().collect().await.unwrap().to_bytes();
6663 let body = String::from_utf8(body.to_vec()).unwrap();
6664 assert!(body.contains("session-1"));
6665 assert!(!body.contains("secret-token"));
6666 assert!(!body.contains("native-secret-id"));
6667 assert!(!body.contains("/private/source/hel"));
6668
6669 let snapshot: serde_json::Value = serde_json::from_str(&body).unwrap();
6670 let repository = &snapshot["bundles"][0]["repositories"][0];
6671 assert_eq!(repository["id"], "hel");
6672 assert_eq!(repository["github"], "owner/hel");
6673 assert_eq!(repository["destination"], "hel");
6674 assert!(repository.get("local").is_none());
6675 }
6676
6677 #[tokio::test]
6678 async fn snapshot_clock_anchor_is_fresh_even_when_the_projection_has_not_changed() {
6679 let (app, _, _, _, _) = app_with_snapshot(|snapshot| snapshot.server_time_ms = 1);
6680 let cookie = login_cookie(&app).await;
6681 for _ in 0..2 {
6682 let before = hel::clock::epoch_millis();
6683 let response = app
6684 .clone()
6685 .oneshot(
6686 Request::get("/api/snapshot")
6687 .header(COOKIE, &cookie)
6688 .body(Body::empty())
6689 .unwrap(),
6690 )
6691 .await
6692 .unwrap();
6693 let body = response.into_body().collect().await.unwrap().to_bytes();
6694 let snapshot: ViewerSnapshot = serde_json::from_slice(&body).unwrap();
6695 assert!(snapshot.server_time_ms >= before);
6696 assert!(snapshot.server_time_ms <= hel::clock::epoch_millis());
6697 }
6698 }
6699
6700 #[tokio::test]
6701 async fn conversation_endpoint_returns_authenticated_bounded_deltas() {
6702 let transcript = BrowserTranscript {
6703 latest_seq: 8,
6704 window_start_seq: 3,
6705 reset: false,
6706 entries: vec![
6707 BrowserTranscriptEntry {
6708 id: 3,
6709 updated_seq: 3,
6710 role: "user",
6711 label: "You".into(),
6712 recorded_at_ms: None,
6713 lines: vec!["begin".into()],
6714 glyph: "\u{276f}",
6715 tone: "user",
6716 tool_status: None,
6717 diffstats: Vec::new(),
6718 },
6719 BrowserTranscriptEntry {
6720 id: 7,
6721 updated_seq: 8,
6722 role: "agent",
6723 label: "Agent".into(),
6724 recorded_at_ms: None,
6725 lines: vec!["live".into()],
6726 glyph: "\u{25cf}",
6727 tone: "agent",
6728 tool_status: None,
6729 diffstats: Vec::new(),
6730 },
6731 ],
6732 };
6733 let (app, _, _, _, _) =
6734 app_with_conversations(BTreeMap::from([("session-1".into(), transcript)]));
6735 let cookie = login_cookie(&app).await;
6736 let response = app
6737 .oneshot(
6738 Request::get("/api/conversations/session-1?after_seq=3")
6739 .header(COOKIE, cookie)
6740 .body(Body::empty())
6741 .unwrap(),
6742 )
6743 .await
6744 .unwrap();
6745 assert_eq!(response.status(), StatusCode::OK);
6746 let body = response.into_body().collect().await.unwrap().to_bytes();
6747 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
6748 assert_eq!(body["latest_seq"], 8);
6749 assert_eq!(body["reset"], false);
6750 assert_eq!(body["entries"].as_array().unwrap().len(), 1);
6751 assert_eq!(body["entries"][0]["lines"][0], "live");
6752 }
6753
6754 #[tokio::test]
6755 async fn conversation_endpoint_rejects_cached_transcript_during_transition() {
6756 let transcript = BrowserTranscript {
6757 latest_seq: 1,
6758 window_start_seq: 1,
6759 reset: false,
6760 entries: vec![BrowserTranscriptEntry {
6761 id: 1,
6762 updated_seq: 1,
6763 role: "agent",
6764 label: "Agent".into(),
6765 recorded_at_ms: None,
6766 lines: vec!["stale".into()],
6767 glyph: "●",
6768 tone: "agent",
6769 tool_status: None,
6770 diffstats: Vec::new(),
6771 }],
6772 };
6773 let (app, _, _, _, _) = app_with(
6774 BTreeMap::from([("session-1".into(), transcript)]),
6775 |snapshot| snapshot.sessions[0].transitioning = true,
6776 );
6777 let cookie = login_cookie(&app).await;
6778 let response = app
6779 .oneshot(
6780 Request::get("/api/conversations/session-1")
6781 .header(COOKIE, cookie)
6782 .body(Body::empty())
6783 .unwrap(),
6784 )
6785 .await
6786 .unwrap();
6787 assert_eq!(response.status(), StatusCode::CONFLICT);
6788 }
6789
6790 #[tokio::test]
6791 async fn conversation_read_receipt_never_contends_with_a_running_action() {
6792 let (app, mut actions, mut receipts, _, _) = app();
6793 let cookie = login_cookie(&app).await;
6794 let prompt = tokio::spawn(
6798 app.clone().oneshot(
6799 Request::post("/api/actions")
6800 .header(COOKIE, cookie.clone())
6801 .header(CONTENT_TYPE, "application/json")
6802 .body(Body::from(
6803 r#"{"action":"prompt","session_id":"session-1","text":"ship it"}"#,
6804 ))
6805 .unwrap(),
6806 ),
6807 );
6808 let action = actions.recv().await.unwrap();
6809
6810 let response = tokio::spawn(
6811 app.oneshot(
6812 Request::post("/api/conversations/session-1/read")
6813 .header(COOKIE, cookie)
6814 .header(CONTENT_TYPE, "application/json")
6815 .body(Body::from(r#"{"through":42}"#))
6816 .unwrap(),
6817 ),
6818 );
6819 let receipt = receipts.recv().await.unwrap();
6820 assert_eq!(receipt.session_id, "session-1");
6821 assert_eq!(receipt.through, 42);
6822 receipt.reply.send(Ok(())).unwrap();
6823 assert_eq!(
6824 response.await.unwrap().unwrap().status(),
6825 StatusCode::NO_CONTENT
6826 );
6827 assert!(
6828 actions.try_recv().is_err(),
6829 "a read receipt must not queue a controller action"
6830 );
6831
6832 action.reply.send(ActionOutcome::Accepted).unwrap();
6833 assert_eq!(
6834 prompt.await.unwrap().unwrap().status(),
6835 StatusCode::ACCEPTED
6836 );
6837 }
6838
6839 #[tokio::test]
6840 async fn each_rejected_action_keeps_its_own_status_and_guidance() {
6841 for (outcome, status, guidance) in [
6842 (
6843 ActionOutcome::Busy,
6844 StatusCode::TOO_MANY_REQUESTS,
6845 "concurrent action limit",
6846 ),
6847 (
6848 ActionOutcome::SessionBusy,
6849 StatusCode::CONFLICT,
6850 "another operation is already running",
6851 ),
6852 (
6853 ActionOutcome::NotCancellable,
6854 StatusCode::CONFLICT,
6855 "no cancellable operation",
6856 ),
6857 (
6858 ActionOutcome::Failed,
6859 StatusCode::INTERNAL_SERVER_ERROR,
6860 "could not start this action",
6861 ),
6862 ] {
6863 let (app, mut actions, _, _, _) = app();
6864 let cookie = login_cookie(&app).await;
6865 let response = tokio::spawn(
6866 app.oneshot(
6867 Request::post("/api/actions")
6868 .header(COOKIE, cookie)
6869 .header(CONTENT_TYPE, "application/json")
6870 .body(Body::from(r#"{"action":"close","session_id":"session-1"}"#))
6871 .unwrap(),
6872 ),
6873 );
6874 let request = actions.recv().await.unwrap();
6875 request.reply.send(outcome).unwrap();
6876
6877 let response = response.await.unwrap().unwrap();
6878 assert_eq!(response.status(), status, "{outcome:?}");
6879 let body = response.into_body().collect().await.unwrap().to_bytes();
6880 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
6881 let error = body["error"].as_str().unwrap();
6882 assert!(error.contains(guidance), "{outcome:?} answered {error:?}");
6883 }
6884 }
6885
6886 #[tokio::test]
6887 async fn the_viewer_shows_a_session_whose_action_failed_after_it_was_accepted() {
6888 let (app, _, _, _, _) = app();
6892 let script = fetch_text(app, "/viewer.js").await;
6893 assert!(script.contains("has_error"), "viewer ignores has_error");
6894 }
6895
6896 #[tokio::test]
6899 async fn every_response_carries_the_security_headers() {
6900 for path in [
6901 "/",
6902 "/viewer.js",
6903 "/voice-worklet.js",
6904 "/voice-worker.js",
6905 "/viewer.css",
6906 "/manifest.webmanifest",
6907 "/api/snapshot",
6908 ] {
6909 let (app, _, _, _, _) = app();
6910 let response = app
6911 .oneshot(Request::get(path).body(Body::empty()).unwrap())
6912 .await
6913 .unwrap();
6914 let headers = response.headers();
6915 let policy = headers
6916 .get(CONTENT_SECURITY_POLICY_HEADER)
6917 .unwrap_or_else(|| panic!("{path} carries no content-security policy"))
6918 .to_str()
6919 .unwrap();
6920 assert!(
6921 policy.starts_with("default-src 'none';"),
6922 "{path} does not refuse unlisted sources: {policy}"
6923 );
6924 assert!(
6925 policy.contains("script-src 'self'") && !policy.contains("unsafe-inline"),
6926 "{path} permits inline script: {policy}"
6927 );
6928 assert!(
6929 policy.contains("frame-ancestors 'none'"),
6930 "{path} can be framed: {policy}"
6931 );
6932 assert_eq!(
6933 headers.get(X_CONTENT_TYPE_OPTIONS).unwrap(),
6934 "nosniff",
6935 "{path} permits content sniffing"
6936 );
6937 assert_eq!(
6938 headers.get(REFERRER_POLICY).unwrap(),
6939 "no-referrer",
6940 "{path} leaks a referrer"
6941 );
6942 }
6943 }
6944
6945 #[tokio::test]
6949 async fn the_page_carries_no_inline_script_or_style() {
6950 let (app, _, _, _, _) = app();
6951 let page = fetch_text(app, "/").await;
6952 assert!(
6953 !page.contains("<script>") && !page.contains("<style>"),
6954 "the page inlines script or style, which the policy blocks"
6955 );
6956 assert!(
6957 page.contains(r#"src="/viewer.js""#) && page.contains(r#"href="/viewer.css""#),
6958 "the page does not load its script and style as separate assets"
6959 );
6960 }
6961
6962 #[tokio::test]
6965 async fn live_state_and_the_service_worker_are_never_stored() {
6966 for path in ["/", "/service-worker.js", "/api/snapshot"] {
6967 let (app, _, _, _, _) = app();
6968 let response = app
6969 .oneshot(Request::get(path).body(Body::empty()).unwrap())
6970 .await
6971 .unwrap();
6972 assert_eq!(
6973 response.headers().get(CACHE_CONTROL).unwrap(),
6974 "no-store",
6975 "{path} may be stored"
6976 );
6977 }
6978 }
6979
6980 #[test]
6983 fn the_service_worker_declines_to_handle_live_state() {
6984 assert!(
6985 SERVICE_WORKER.contains("url.pathname.startsWith('/api/')"),
6986 "the service worker does not exclude the API"
6987 );
6988 assert!(
6989 SERVICE_WORKER.contains("url.pathname.startsWith('/auth/')"),
6990 "the service worker does not exclude authentication"
6991 );
6992 assert!(
6993 SERVICE_WORKER.contains("caches.delete"),
6994 "the service worker never deletes a superseded cache"
6995 );
6996 }
6997
6998 #[tokio::test]
7001 async fn the_installable_assets_are_served() {
7002 for (path, content_type) in [
7003 ("/icon-192.png", "image/png"),
7004 ("/icon-512.png", "image/png"),
7005 ("/maskable-512.png", "image/png"),
7006 ("/apple-touch-icon.png", "image/png"),
7007 ("/fonts/jetbrains-mono.woff2", "font/woff2"),
7008 ] {
7009 let (app, _, _, _, _) = app();
7010 let response = app
7011 .oneshot(Request::get(path).body(Body::empty()).unwrap())
7012 .await
7013 .unwrap();
7014 assert_eq!(response.status(), StatusCode::OK, "{path} is not served");
7015 assert_eq!(
7016 response.headers().get(CONTENT_TYPE).unwrap(),
7017 content_type,
7018 "{path} is served as the wrong type"
7019 );
7020 }
7021 }
7022
7023 async fn fetch_text(app: Router, path: &str) -> String {
7027 let response = app
7028 .oneshot(Request::get(path).body(Body::empty()).unwrap())
7029 .await
7030 .unwrap();
7031 assert_eq!(response.status(), StatusCode::OK, "{path} is not served");
7032 let body = response.into_body().collect().await.unwrap().to_bytes();
7033 String::from_utf8(body.to_vec()).expect("assets are UTF-8")
7034 }
7035
7036 #[tokio::test]
7037 async fn repeated_wrong_codes_lock_the_login_endpoint() {
7038 let (app, _, _, _, _) = app();
7039 let attempt = |code: &'static str| {
7040 let app = app.clone();
7041 async move {
7042 app.oneshot(
7043 Request::post("/auth/session")
7044 .header(CONTENT_TYPE, "application/json")
7045 .body(Body::from(format!(r#"{{"code":"{code}"}}"#)))
7046 .unwrap(),
7047 )
7048 .await
7049 .unwrap()
7050 .status()
7051 }
7052 };
7053 for _ in 0..MAX_CODE_FAILURES {
7054 assert_eq!(attempt("000000").await, StatusCode::UNAUTHORIZED);
7055 }
7056 assert_eq!(attempt("000000").await, StatusCode::TOO_MANY_REQUESTS);
7057 assert_eq!(attempt("123456").await, StatusCode::TOO_MANY_REQUESTS);
7060 }
7061
7062 #[test]
7063 fn viewer_code_lockouts_lengthen_instead_of_resetting_after_every_wait() {
7064 let serve_one_lockout = |guard: &mut CodeGuard, now: Instant| {
7065 for _ in 0..MAX_CODE_FAILURES {
7066 assert!(!guard.locked_at(now));
7067 guard.record_failure_at(now);
7068 }
7069 assert!(guard.locked_at(now));
7070 guard.locked_until.expect("the guard is locked") - now
7071 };
7072
7073 let start = Instant::now();
7074 let mut guard = CodeGuard::default();
7075 let first = serve_one_lockout(&mut guard, start);
7076 assert_eq!(first, CODE_LOCKOUT_BASE);
7077
7078 let second_round = start + first;
7082 let second = serve_one_lockout(&mut guard, second_round);
7083 assert_eq!(second, CODE_LOCKOUT_BASE * 2);
7084 let third = serve_one_lockout(&mut guard, second_round + second);
7085 assert_eq!(third, CODE_LOCKOUT_BASE * 4);
7086 assert_eq!(code_lockout(u32::MAX), CODE_LOCKOUT_CAP);
7087
7088 let mut recovered = CodeGuard::default();
7091 assert_eq!(serve_one_lockout(&mut recovered, start), CODE_LOCKOUT_BASE);
7092 }
7093
7094 #[test]
7095 fn persisted_cookie_key_survives_a_restart_and_stays_owner_only() {
7096 let directory = tempfile::tempdir().unwrap();
7097 let path = directory.path().join("phone-cookie-key");
7098
7099 let first = load_or_create_cookie_key(&path).unwrap();
7100 assert!(first.len() >= COOKIE_KEY_BYTES);
7101 assert_eq!(std::fs::read(&path).unwrap(), first);
7102 #[cfg(unix)]
7103 {
7104 use std::os::unix::fs::PermissionsExt as _;
7105 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
7106 assert_eq!(mode & 0o777, 0o600);
7107 }
7108
7109 let mut restarted = detached_options();
7112 restarted
7113 .set_cookie_key(load_or_create_cookie_key(&path).unwrap())
7114 .unwrap();
7115 let mut original = detached_options();
7116 original.set_cookie_key(first.clone()).unwrap();
7117 let cookie = signed_cookie_value(&original.cookie_key, "test-viewer", 200);
7118 assert!(session_cookie_valid(&restarted.cookie_key, &cookie, 100));
7119 assert!(!session_cookie_valid(
7120 &detached_options().cookie_key,
7121 &cookie,
7122 100
7123 ));
7124
7125 std::fs::remove_file(&path).unwrap();
7127 let rotated = load_or_create_cookie_key(&path).unwrap();
7128 assert_ne!(rotated, first);
7129 assert!(!session_cookie_valid(&rotated, &cookie, 100));
7130 }
7131
7132 #[test]
7133 fn corrupt_cookie_key_is_regenerated_instead_of_blocking_startup() {
7134 let directory = tempfile::tempdir().unwrap();
7135 let path = directory.path().join("phone-cookie-key");
7136 std::fs::write(&path, b"short").unwrap();
7137
7138 let key = load_or_create_cookie_key(&path).unwrap();
7139
7140 assert!(key.len() >= COOKIE_KEY_BYTES);
7141 assert_eq!(std::fs::read(&path).unwrap(), key);
7142 assert_eq!(load_or_create_cookie_key(&path).unwrap(), key);
7143 }
7144}