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