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 phone: Default::default(),
3250 review: Default::default(),
3251 startup: Default::default(),
3252 profiles: BTreeMap::from([(
3253 "codex-1".into(),
3254 HarnessProfile {
3255 context_window_bytes: None,
3256 kind: HarnessKind::Codex,
3257 home: "/highly/secret/codex".into(),
3258 environment: BTreeMap::from([("GH_TOKEN".into(), "secret-token".into())]),
3259 },
3260 )]),
3261 bundles: BTreeMap::from([(
3262 "hel".into(),
3263 ProjectBundle {
3264 primary_repo: "hel".into(),
3265 repositories: vec![ProjectRepository {
3266 id: "hel".into(),
3267 github: Some("owner/hel".into()),
3268 local: Some("/private/source/hel".into()),
3269 destination: "hel".into(),
3270 git_ref: None,
3271 }],
3272 },
3273 )]),
3274 targets: BTreeMap::from([
3275 (
3276 "podman".into(),
3277 TargetTemplate::LocalPodman {
3278 container: ContainerTemplate {
3279 image: "secret.registry/image".into(),
3280 pull_policy: Default::default(),
3281 platform: None,
3282 cpus: None,
3283 memory: None,
3284 environment: BTreeMap::from([("TOKEN".into(), "secret-target".into())]),
3285 workspace_storage: Default::default(),
3286 },
3287 },
3288 ),
3289 ("raw".into(), TargetTemplate::LocalBare),
3290 ]),
3291 };
3292 let state = HelState {
3293 version: STATE_VERSION,
3294 sessions: BTreeMap::from([(
3295 "session-1".into(),
3296 SessionRecord {
3297 workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
3298 archived: false,
3299 container_cpus: None,
3300 container_memory: None,
3301 id: "session-1".into(),
3302 title: "Build Hel".into(),
3303 harness_kind: HarnessKind::Codex,
3304 last_profile: "codex-1".into(),
3305 bundle_id: "hel".into(),
3306 project_directory: None,
3307 managed_worktree: None,
3308 target_template_id: "podman".into(),
3309 resource_allocation: None,
3310 additional_mounts: vec![],
3311 state: SessionState::Running,
3312 target: None,
3313 native_session_id: Some("native-secret-id".into()),
3314 acp_session_title: Some("Build Hel".into()),
3315 session_title_override: None,
3316 created_at: "now".into(),
3317 updated_at: "now".into(),
3318 viewed_through_event_ordinal: 0,
3319 draft_input: String::new(),
3320 last_error: Some("secret-token at /highly/secret/codex".into()),
3321 last_checkpoint_error: None,
3322 checkpoint: None,
3323 },
3324 )]),
3325 mount_history: BTreeMap::new(),
3326 container_sizes: BTreeMap::new(),
3327 };
3328 (config, state)
3329 }
3330
3331 type TestServer = (
3332 Router,
3333 mpsc::Receiver<ControllerRequest>,
3334 mpsc::Receiver<ReadReceiptRequest>,
3335 mpsc::Receiver<PreflightRequest>,
3336 mpsc::Receiver<ClientStateRequest>,
3337 );
3338
3339 fn app() -> TestServer {
3340 app_with_conversations(BTreeMap::new())
3341 }
3342
3343 fn app_with_move_receiver() -> (Router, mpsc::Receiver<MovePreparationRequest>) {
3344 let (config, state) = sample_config_state();
3345 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
3346 snapshot.sessions[0].capabilities.move_session = true;
3347 let (_snapshot_tx, snapshot_rx) = watch::channel(snapshot);
3348 let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
3349 let (action_tx, _action_rx) = mpsc::channel(8);
3350 let (bundle_tx, _bundle_rx) = mpsc::channel(8);
3351 let (receipt_tx, _receipt_rx) = mpsc::channel(8);
3352 let (preflight_tx, _preflight_rx) = mpsc::channel(8);
3353 let (move_preparation_tx, move_preparation_rx) = mpsc::channel(8);
3354 let (client_state_tx, _client_state_rx) = mpsc::channel(8);
3355 let options = test_options(
3356 snapshot_rx,
3357 conversation_rx,
3358 action_tx,
3359 bundle_tx,
3360 receipt_tx,
3361 preflight_tx,
3362 move_preparation_tx,
3363 client_state_tx,
3364 )
3365 .with_test_credentials("123456", b"01234567890123456789012345678901");
3366 (router(options), move_preparation_rx)
3367 }
3368
3369 fn app_with_conversations(conversations: BTreeMap<String, BrowserTranscript>) -> TestServer {
3370 app_with(conversations, |_| {})
3371 }
3372
3373 fn app_with_snapshot(adjust: impl FnOnce(&mut ViewerSnapshot)) -> TestServer {
3374 app_with(BTreeMap::new(), adjust)
3375 }
3376
3377 fn app_with(
3378 conversations: BTreeMap<String, BrowserTranscript>,
3379 adjust: impl FnOnce(&mut ViewerSnapshot),
3380 ) -> TestServer {
3381 let (config, state) = sample_config_state();
3382 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
3383 adjust(&mut snapshot);
3384 let (_snapshot_tx, snapshot_rx) = watch::channel(snapshot);
3385 let (_conversation_tx, conversation_rx) = watch::channel(conversations);
3386 let (action_tx, action_rx) = mpsc::channel(8);
3387 let (bundle_tx, _bundle_rx) = mpsc::channel(8);
3388 let (receipt_tx, receipt_rx) = mpsc::channel(8);
3389 let (preflight_tx, preflight_rx) = mpsc::channel(8);
3390 let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
3391 let (client_state_tx, client_state_rx) = mpsc::channel(8);
3392 let options = test_options(
3393 snapshot_rx,
3394 conversation_rx,
3395 action_tx,
3396 bundle_tx,
3397 receipt_tx,
3398 preflight_tx,
3399 move_preparation_tx,
3400 client_state_tx,
3401 )
3402 .with_test_credentials("123456", b"01234567890123456789012345678901");
3403 (
3404 router(options),
3405 action_rx,
3406 receipt_rx,
3407 preflight_rx,
3408 client_state_rx,
3409 )
3410 }
3411
3412 fn app_with_bundle_receiver() -> (Router, mpsc::Receiver<BundleRequest>) {
3413 let (config, state) = sample_config_state();
3414 let (_snapshot_tx, snapshot_rx) =
3415 watch::channel(ViewerSnapshot::from_config_state(&config, &state, 1));
3416 let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
3417 let (action_tx, _action_rx) = mpsc::channel(8);
3418 let (bundle_tx, bundle_rx) = mpsc::channel(8);
3419 let (receipt_tx, _receipt_rx) = mpsc::channel(8);
3420 let (preflight_tx, _preflight_rx) = mpsc::channel(8);
3421 let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
3422 let (client_state_tx, _client_state_rx) = mpsc::channel(8);
3423 let options = test_options(
3424 snapshot_rx,
3425 conversation_rx,
3426 action_tx,
3427 bundle_tx,
3428 receipt_tx,
3429 preflight_tx,
3430 move_preparation_tx,
3431 client_state_tx,
3432 )
3433 .with_test_credentials("123456", b"01234567890123456789012345678901");
3434 (router(options), bundle_rx)
3435 }
3436
3437 #[allow(clippy::too_many_arguments)]
3440 fn test_options(
3441 snapshot_rx: watch::Receiver<ViewerSnapshot>,
3442 conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
3443 action_tx: mpsc::Sender<ControllerRequest>,
3444 bundle_tx: mpsc::Sender<BundleRequest>,
3445 receipt_tx: mpsc::Sender<ReadReceiptRequest>,
3446 preflight_tx: mpsc::Sender<PreflightRequest>,
3447 move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
3448 client_state_tx: mpsc::Sender<ClientStateRequest>,
3449 ) -> ServerOptions {
3450 ServerOptions::new(
3451 "127.0.0.1:0".parse().unwrap(),
3452 snapshot_rx,
3453 conversation_rx,
3454 ServerRequests {
3455 action_tx,
3456 bundle_tx,
3457 receipt_tx,
3458 preflight_tx,
3459 move_preparation_tx,
3460 client_state_tx,
3461 },
3462 )
3463 .unwrap()
3464 }
3465
3466 fn detached_options() -> ServerOptions {
3467 let (config, state) = sample_config_state();
3468 let (_snapshot_tx, snapshot_rx) =
3469 watch::channel(ViewerSnapshot::from_config_state(&config, &state, 1));
3470 let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
3471 let (action_tx, _action_rx) = mpsc::channel(1);
3472 let (bundle_tx, _bundle_rx) = mpsc::channel(1);
3473 let (receipt_tx, _receipt_rx) = mpsc::channel(1);
3474 let (preflight_tx, _preflight_rx) = mpsc::channel(1);
3475 let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(1);
3476 let (client_state_tx, _client_state_rx) = mpsc::channel(1);
3477 test_options(
3478 snapshot_rx,
3479 conversation_rx,
3480 action_tx,
3481 bundle_tx,
3482 receipt_tx,
3483 preflight_tx,
3484 move_preparation_tx,
3485 client_state_tx,
3486 )
3487 }
3488
3489 fn cookie() -> String {
3495 format!(
3496 "{COOKIE_NAME}={}",
3497 signed_cookie_value(
3498 b"01234567890123456789012345678901",
3499 "test-viewer",
3500 now_unix().saturating_add(3600)
3501 )
3502 )
3503 }
3504
3505 async fn login_cookie(app: &Router) -> String {
3506 let response = app
3507 .clone()
3508 .oneshot(
3509 Request::post("/auth/session")
3510 .header(CONTENT_TYPE, "application/json")
3511 .body(Body::from(r#"{"code":"123456"}"#))
3512 .unwrap(),
3513 )
3514 .await
3515 .unwrap();
3516 assert_eq!(response.status(), StatusCode::NO_CONTENT);
3517 response
3518 .headers()
3519 .get(SET_COOKIE)
3520 .unwrap()
3521 .to_str()
3522 .unwrap()
3523 .split(';')
3524 .next()
3525 .unwrap()
3526 .to_string()
3527 }
3528
3529 #[tokio::test]
3530 async fn bundle_endpoint_authenticates_and_forwards_the_source() {
3531 let (app, mut bundles) = app_with_bundle_receiver();
3532 let unauthorized = app
3533 .clone()
3534 .oneshot(
3535 Request::post("/api/bundles")
3536 .header(CONTENT_TYPE, "application/json")
3537 .body(Body::from(r#"{"source":"example/app"}"#))
3538 .unwrap(),
3539 )
3540 .await
3541 .unwrap();
3542 assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
3543 assert!(bundles.try_recv().is_err());
3544
3545 let cookie = login_cookie(&app).await;
3546 let response = tokio::spawn({
3547 let app = app.clone();
3548 let cookie = cookie.clone();
3549 async move {
3550 app.oneshot(
3551 Request::post("/api/bundles")
3552 .header(CONTENT_TYPE, "application/json")
3553 .header(COOKIE, cookie)
3554 .body(Body::from(r#"{"source":"example/app"}"#))
3555 .unwrap(),
3556 )
3557 .await
3558 .unwrap()
3559 }
3560 });
3561 let request = bundles.recv().await.expect("bundle request forwarded");
3562 assert_eq!(request.source, "example/app");
3563 request.reply.send(Ok("app".into())).unwrap();
3564 let response = response.await.unwrap();
3565 assert_eq!(response.status(), StatusCode::OK);
3566 let body = response.into_body().collect().await.unwrap().to_bytes();
3567 assert_eq!(body.as_ref(), br#"{"bundle_id":"app"}"#);
3568 }
3569
3570 #[tokio::test]
3571 async fn bundle_endpoint_rejects_empty_and_oversized_sources_before_dispatch() {
3572 for source in [String::new(), "x".repeat(MAX_BUNDLE_SOURCE_CHARS + 1)] {
3573 let (app, mut bundles) = app_with_bundle_receiver();
3574 let cookie = login_cookie(&app).await;
3575 let response = app
3576 .oneshot(
3577 Request::post("/api/bundles")
3578 .header(CONTENT_TYPE, "application/json")
3579 .header(COOKIE, cookie)
3580 .body(Body::from(
3581 serde_json::to_string(&serde_json::json!({"source": source})).unwrap(),
3582 ))
3583 .unwrap(),
3584 )
3585 .await
3586 .unwrap();
3587 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
3588 assert!(bundles.try_recv().is_err());
3589 }
3590 }
3591
3592 #[tokio::test]
3593 async fn bundle_endpoint_reports_invalid_source_as_a_client_error() {
3594 let (app, mut bundles) = app_with_bundle_receiver();
3595 let cookie = login_cookie(&app).await;
3596 let response = tokio::spawn({
3597 let app = app.clone();
3598 async move {
3599 app.oneshot(
3600 Request::post("/api/bundles")
3601 .header(CONTENT_TYPE, "application/json")
3602 .header(COOKIE, cookie)
3603 .body(Body::from(r#"{"source":"not a source"}"#))
3604 .unwrap(),
3605 )
3606 .await
3607 .unwrap()
3608 }
3609 });
3610 let request = bundles.recv().await.expect("bundle request forwarded");
3611 request
3612 .reply
3613 .send(Err(BundleFailure::InvalidSource))
3614 .unwrap();
3615 let response = response.await.unwrap();
3616 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
3617 let body = response.into_body().collect().await.unwrap().to_bytes();
3618 assert!(String::from_utf8_lossy(&body).contains("GitHub owner/repository"));
3619 }
3620
3621 #[tokio::test]
3622 async fn api_requires_a_valid_signed_cookie() {
3623 let (app, _, _, _, _) = app();
3624 let unauthorized = app
3625 .clone()
3626 .oneshot(Request::get("/api/snapshot").body(Body::empty()).unwrap())
3627 .await
3628 .unwrap();
3629 assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
3630
3631 let cookie = login_cookie(&app).await;
3632 let authorized = app
3633 .oneshot(
3634 Request::get("/api/snapshot")
3635 .header(COOKIE, cookie)
3636 .body(Body::empty())
3637 .unwrap(),
3638 )
3639 .await
3640 .unwrap();
3641 assert_eq!(authorized.status(), StatusCode::OK);
3642 }
3643
3644 #[tokio::test]
3645 async fn qr_login_exchanges_the_secret_for_a_cookie_and_redirects_cleanly() {
3646 let (app, _, _, _, _) = app();
3647 let rejected = app
3648 .clone()
3649 .oneshot(
3650 Request::get("/auth/login?token=wrong")
3651 .body(Body::empty())
3652 .unwrap(),
3653 )
3654 .await
3655 .unwrap();
3656 assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED);
3657
3658 let accepted = app
3659 .oneshot(
3660 Request::get("/auth/login?token=test-login-token")
3661 .body(Body::empty())
3662 .unwrap(),
3663 )
3664 .await
3665 .unwrap();
3666 assert_eq!(accepted.status(), StatusCode::SEE_OTHER);
3667 assert_eq!(accepted.headers().get(LOCATION).unwrap(), "/");
3668 assert_eq!(accepted.headers().get(CACHE_CONTROL).unwrap(), "no-store");
3669 assert!(accepted.headers().contains_key(SET_COOKIE));
3670 }
3671
3672 #[test]
3673 fn signed_cookie_rejects_expiry_and_tampering() {
3674 let key = b"01234567890123456789012345678901";
3675 let cookie = signed_cookie_value(key, "test-viewer", 200);
3676 assert!(session_cookie_valid(key, &cookie, 100));
3677 assert!(!session_cookie_valid(key, &cookie, 200));
3678 assert!(!session_cookie_valid(key, &format!("{cookie}x"), 100));
3679 assert!(!session_cookie_valid(b"another-key", &cookie, 100));
3680 }
3681
3682 #[test]
3683 fn generated_code_and_cookie_attributes_are_phone_safe() {
3684 let code = generate_viewer_code().unwrap();
3685 assert_eq!(code.len(), 6);
3686 assert!(code.bytes().all(|byte| byte.is_ascii_digit()));
3687 let header = session_cookie_header("signed", Some(60), true)
3688 .unwrap()
3689 .to_str()
3690 .unwrap()
3691 .to_string();
3692 assert!(header.contains("HttpOnly"));
3693 assert!(header.contains("SameSite=Strict"));
3694 assert!(header.contains("Secure"));
3695 assert!(header.contains("Max-Age=60"));
3696 }
3697
3698 #[test]
3699 fn public_snapshot_omits_homes_environment_locators_and_raw_errors() {
3700 let (config, state) = sample_config_state();
3701 let json =
3702 serde_json::to_string(&ViewerSnapshot::from_config_state(&config, &state, 9)).unwrap();
3703 assert!(!json.contains("/highly/secret"));
3704 assert!(!json.contains("secret-token"));
3705 assert!(!json.contains("secret-target"));
3706 assert!(!json.contains("secret.registry"));
3707 assert!(!json.contains("native-secret-id"));
3708 assert!(json.contains("\"has_error\":true"));
3709 }
3710
3711 #[test]
3712 fn target_snapshot_uses_each_raw_host_project_history_and_leaves_managed_empty() {
3713 let (mut config, mut state) = sample_config_state();
3714 config
3715 .targets
3716 .insert("raw-local".into(), TargetTemplate::LocalBare);
3717 config.targets.insert(
3718 "raw-builder".into(),
3719 TargetTemplate::SshBare {
3720 ssh: SshConnection {
3721 host: "builder-a".into(),
3722 user: None,
3723 identity_file: None,
3724 extra_args: Vec::new(),
3725 },
3726 permissions: PermissionMode::Guardian,
3727 workspace_prefix: "workspaces".into(),
3728 },
3729 );
3730 state.remember_project_directory("local", Path::new("/work/local"));
3731 state.remember_project_directory("builder-a", Path::new("/srv/builder"));
3732 state.remember_project_directory("other-host", Path::new("/not-published"));
3733
3734 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
3735 let target = |id: &str| {
3736 snapshot
3737 .targets
3738 .iter()
3739 .find(|target| target.id == id)
3740 .unwrap()
3741 };
3742 assert_eq!(
3743 target("raw-local").recent_project_directories,
3744 vec!["/work/local"]
3745 );
3746 assert_eq!(
3747 target("raw-builder").recent_project_directories,
3748 vec!["/srv/builder"]
3749 );
3750 assert!(target("podman").recent_project_directories.is_empty());
3751 }
3752
3753 #[test]
3754 fn public_snapshot_exposes_only_review_status_configuration() {
3755 let (mut config, state) = sample_config_state();
3756 config.review = hel::hel_config::ReviewConfig {
3757 enabled: true,
3758 tier: hel::hel_review::lanes::ReviewTier::Extended,
3759 profile: Some("reviewer-1".into()),
3760 model: Some("private-review-model".into()),
3761 effort: Some("private-review-effort".into()),
3762 };
3763
3764 let value =
3765 serde_json::to_value(ViewerSnapshot::from_config_state(&config, &state, 9)).unwrap();
3766
3767 assert_eq!(
3768 value.get("review_config"),
3769 Some(&serde_json::json!({
3770 "enabled": true,
3771 "tier": "extended",
3772 "profile": "reviewer-1",
3773 }))
3774 );
3775 let json = value.to_string();
3776 assert!(!json.contains("private-review-model"));
3777 assert!(!json.contains("private-review-effort"));
3778 }
3779
3780 fn sample_elicitation() -> ElicitationRequest {
3781 ElicitationRequest::from_acp_params(
3782 "elicitation-1",
3783 serde_json::json!({
3784 "sessionId": "session-1",
3785 "mode": "form",
3786 "message": "Which CI architecture should the workflow use?",
3787 "requestedSchema": {
3788 "type": "object",
3789 "required": ["question_0"],
3790 "properties": {
3791 "question_0": {
3792 "type": "string",
3793 "title": "CI architecture",
3794 "oneOf": [
3795 {"const": "reusable", "title": "Reusable workflow"},
3796 {"const": "matrix", "title": "Matrix job"}
3797 ]
3798 },
3799 "question_0_custom": {
3800 "type": "string",
3801 "title": "Other",
3802 "_meta": {"_askUserQuestionCustomAnswer": {
3803 "questionId": "question_0",
3804 "isCustomAnswer": true
3805 }}
3806 }
3807 }
3808 }
3809 }),
3810 )
3811 .expect("sample elicitation parses")
3812 }
3813
3814 fn accept(pairs: &[(&str, &str)]) -> ElicitationResponse {
3815 ElicitationResponse::Accept {
3816 content: pairs
3817 .iter()
3818 .map(|(id, value)| {
3819 (
3820 (*id).to_owned(),
3821 hel::hel_elicitation::ElicitationValue::String((*value).to_owned()),
3822 )
3823 })
3824 .collect(),
3825 }
3826 }
3827
3828 fn pending_elicitation_snapshot(snapshot: &mut ViewerSnapshot) {
3829 snapshot.sessions[0].pending_elicitations = vec![sample_elicitation()];
3830 }
3831
3832 #[tokio::test]
3833 async fn elicitation_answer_is_typed_and_forwarded() {
3834 let (app, mut actions, _, _, _) = app_with_snapshot(pending_elicitation_snapshot);
3835 let cookie = login_cookie(&app).await;
3836 let response = tokio::spawn(
3837 app.oneshot(
3838 Request::post("/api/actions")
3839 .header(COOKIE, cookie)
3840 .header(CONTENT_TYPE, "application/json")
3841 .body(Body::from(
3842 r#"{"action":"respond-elicitation","session_id":"session-1","elicitation_id":"elicitation-1","response":{"action":"accept","content":{"question_0":"reusable"}}}"#,
3843 ))
3844 .unwrap(),
3845 ),
3846 );
3847 let action = actions.recv().await.unwrap();
3848 assert_eq!(
3849 action.action,
3850 ControllerAction::RespondElicitation {
3851 session_id: "session-1".into(),
3852 elicitation_id: "elicitation-1".into(),
3853 response: accept(&[("question_0", "reusable")]),
3854 }
3855 );
3856 action.reply.send(ActionOutcome::Accepted).unwrap();
3857 assert_eq!(
3858 response.await.unwrap().unwrap().status(),
3859 StatusCode::ACCEPTED
3860 );
3861 }
3862
3863 #[tokio::test]
3864 async fn elicitation_answer_for_an_unknown_request_is_refused_without_reaching_the_controller()
3865 {
3866 let (app, mut actions, _, _, _) = app_with_snapshot(pending_elicitation_snapshot);
3867 let cookie = login_cookie(&app).await;
3868 let response = app
3869 .oneshot(
3870 Request::post("/api/actions")
3871 .header(COOKIE, cookie)
3872 .header(CONTENT_TYPE, "application/json")
3873 .body(Body::from(
3874 r#"{"action":"respond-elicitation","session_id":"session-1","elicitation_id":"elicitation-9","response":{"action":"cancel"}}"#,
3875 ))
3876 .unwrap(),
3877 )
3878 .await
3879 .unwrap();
3880 assert_eq!(response.status(), StatusCode::NOT_FOUND);
3881 assert!(actions.try_recv().is_err());
3882 }
3883
3884 #[test]
3885 fn elicitation_answers_are_checked_against_the_request_the_agent_asked() {
3886 let (config, state) = sample_config_state();
3887 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
3888 pending_elicitation_snapshot(&mut snapshot);
3889 let respond = |response: ElicitationResponse| ControllerAction::RespondElicitation {
3890 session_id: "session-1".into(),
3891 elicitation_id: "elicitation-1".into(),
3892 response,
3893 };
3894
3895 assert!(validate_action(&respond(accept(&[("question_0", "matrix")])), &snapshot).is_ok());
3896 assert!(validate_action(&respond(ElicitationResponse::Decline), &snapshot).is_ok());
3899 assert!(validate_action(&respond(accept(&[("question_0", "cron")])), &snapshot).is_err());
3902 assert!(validate_action(&respond(accept(&[("smuggled", "yes")])), &snapshot).is_err());
3903 assert!(validate_action(&respond(accept(&[])), &snapshot).is_err());
3904 assert!(
3907 validate_action(
3908 &respond(accept(&[("question_0_custom", "a monorepo pipeline")])),
3909 &snapshot,
3910 )
3911 .is_ok()
3912 );
3913 }
3914
3915 #[test]
3916 fn oversized_elicitation_answers_are_refused() {
3917 let (config, state) = sample_config_state();
3918 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
3919 pending_elicitation_snapshot(&mut snapshot);
3920 let long = "x".repeat(MAX_ELICITATION_BYTES);
3921 assert!(
3922 validate_action(
3923 &ControllerAction::RespondElicitation {
3924 session_id: "session-1".into(),
3925 elicitation_id: "elicitation-1".into(),
3926 response: accept(&[("question_0_custom", long.as_str())]),
3927 },
3928 &snapshot,
3929 )
3930 .is_err()
3931 );
3932 }
3933
3934 fn viewer_source(from: &str, to: &str) -> &'static str {
3942 let start = VIEWER_JS
3943 .find(from)
3944 .unwrap_or_else(|| panic!("src/web/viewer.js no longer contains {from:?}"));
3945 let end = VIEWER_JS[start..]
3946 .find(to)
3947 .map(|offset| start + offset)
3948 .unwrap_or_else(|| {
3949 panic!("src/web/viewer.js no longer contains {to:?} after {from:?}")
3950 });
3951 &VIEWER_JS[start..end]
3952 }
3953
3954 fn run_web_check(name: &str, check: &str) {
3961 let directory = tempfile::tempdir().expect("temporary directory for a web check");
3962 for (file, source) in [
3963 ("test-dom.js", TEST_DOM_JS),
3964 ("markdown.js", MARKDOWN_JS),
3965 ("tool-output.js", TOOL_OUTPUT_JS),
3966 ] {
3967 std::fs::write(directory.path().join(file), source).expect("write a web module");
3968 }
3969 let path = directory.path().join(format!("{name}.mjs"));
3970 std::fs::write(&path, check).expect("write the web check");
3971 let output = std::process::Command::new("node")
3972 .arg(&path)
3973 .output()
3974 .expect("Node.js is required to exercise the web viewer");
3975 assert!(
3976 output.status.success(),
3977 "{name} failed:\nstdout:\n{}\nstderr:\n{}",
3978 String::from_utf8_lossy(&output.stdout),
3979 String::from_utf8_lossy(&output.stderr),
3980 );
3981 }
3982
3983 fn run_viewer_script(name: &str, script: &str) {
3987 run_web_check(name, script);
3988 }
3989
3990 #[test]
3991 fn embedded_viewer_lists_histories_from_every_workspace() {
3992 let source = viewer_source("const resumableCards =", "function resumableCard");
3993 let setup = r#"
3994const snapshot = {
3995 profiles: [],
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-b", workspace_id: "workspace-b", capabilities: { resume: false } },
4000 ],
4001};
4002const resumable = {
4003 children: [],
4004 replaceChildren(...children) { this.children = children; },
4005};
4006function resumableCard(session) { return { id: session.id, querySelector() { return null; } }; }
4007function el(_tag, _className, text) { return text; }
4008"#;
4009 let checks = r#"
4010renderResumable();
4011if (JSON.stringify(resumable.children.map(card => card.id)) !== JSON.stringify(["history-a", "history-b"])) {
4012 throw new Error(`global histories were filtered: ${JSON.stringify(resumable.children)}`);
4013}
4014"#;
4015 run_viewer_script(
4016 "global-resume-history",
4017 &format!("{setup}\n{source}\n{checks}"),
4018 );
4019 }
4020
4021 #[test]
4022 fn embedded_viewer_sends_the_selected_resume_workspace() {
4023 let source = viewer_source("async function runSessionAction", "sessions.onclick =");
4024 let setup = r#"
4025const pendingActions = new Set();
4026const snapshot = { sessions: [] };
4027let sent = null;
4028function selectedWorkspaceId() { return "workspace-b"; }
4029function navigate() {}
4030function renderRoute() {}
4031async function refresh() {}
4032async function request(path, options) {
4033 sent = { path, body: JSON.parse(options.body) };
4034}
4035"#;
4036 let checks = r#"
4037const errorNode = { textContent: "" };
4038await runSessionAction(
4039 { action: "resume", id: "history-a", profile: "codex-1", target: "podman" },
4040 errorNode,
4041 { queue: "start" },
4042);
4043if (sent.path !== "/api/actions" || sent.body.workspace_id !== "workspace-b") {
4044 throw new Error(`resume did not carry its destination: ${JSON.stringify(sent)}`);
4045}
4046"#;
4047 run_viewer_script(
4048 "resume-workspace-destination",
4049 &format!("{setup}\n{source}\n{checks}"),
4050 );
4051 }
4052
4053 #[test]
4054 fn embedded_viewer_warns_before_stopping_an_active_session() {
4055 let source = viewer_source("async function runSessionAction", "sessions.onclick =");
4056 let setup = r#"
4057const pendingActions = new Set();
4058const snapshot = {
4059 sessions: [
4060 { id: "active", chat_phase: "running" },
4061 { id: "idle", chat_phase: "idle" },
4062 ],
4063};
4064const questions = [];
4065function confirm(question) { questions.push(question); return false; }
4066function navigate() {}
4067"#;
4068 let checks = r#"
4069const errorNode = { textContent: "" };
4070await runSessionAction({ action: "close", id: "active" }, errorNode);
4071await runSessionAction({ action: "close", id: "idle" }, errorNode);
4072if (!questions[0].startsWith("Stop active session?\n\n")) {
4073 throw new Error(`active close warning was ${JSON.stringify(questions[0])}`);
4074}
4075if (!questions[0].includes("current turn will be interrupted")) {
4076 throw new Error(`active close omitted interruption: ${JSON.stringify(questions[0])}`);
4077}
4078if (!questions[1].startsWith("Stop session?\n\n")) {
4079 throw new Error(`idle close warning was ${JSON.stringify(questions[1])}`);
4080}
4081"#;
4082 run_viewer_script(
4083 "active-session-stop-confirmation",
4084 &format!("{setup}\n{source}\n{checks}"),
4085 );
4086 }
4087
4088 #[test]
4093 fn the_project_key_groups_without_naming_a_path() {
4094 let (config, mut state) = sample_config_state();
4095 let first = state.sessions["session-1"].clone();
4096 let mut second = first.clone();
4097 second.id = "session-2".into();
4098 state.sessions.insert(second.id.clone(), second);
4099 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4100
4101 let keys = snapshot
4102 .sessions
4103 .iter()
4104 .map(|session| session.project_key.as_str())
4105 .collect::<std::collections::BTreeSet<_>>();
4106 assert_eq!(keys.len(), 1, "two sessions in one project did not group");
4107 let key = keys.into_iter().next().expect("one key");
4108 assert!(!key.is_empty(), "the project key is empty");
4109 assert!(
4110 !key.contains('/') && !key.contains("hel"),
4111 "the project key leaks its identity: {key}"
4112 );
4113 assert_eq!(
4114 snapshot.sessions[0].project_label, "hel",
4115 "the project label should be a name a person recognises"
4116 );
4117 }
4118
4119 #[test]
4120 fn web_project_keys_follow_the_complete_repository_set() {
4121 let (mut config, mut state) = sample_config_state();
4122 let shared_bundle = config.bundles["hel"].clone();
4123 config.bundles.insert("other".into(), shared_bundle);
4124
4125 let mut other = state.sessions["session-1"].clone();
4126 other.id = "session-2".into();
4127 other.bundle_id = "other".into();
4128 state.sessions.insert(other.id.clone(), other);
4129
4130 assert_eq!(
4131 config.bundles["hel"].primary_repo,
4132 config.bundles["other"].primary_repo
4133 );
4134 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4135 let first = snapshot
4136 .sessions
4137 .iter()
4138 .find(|session| session.id == "session-1")
4139 .expect("first session");
4140 let second = snapshot
4141 .sessions
4142 .iter()
4143 .find(|session| session.id == "session-2")
4144 .expect("second session");
4145
4146 assert_eq!(first.project_label, "hel");
4147 assert_eq!(second.project_label, "hel");
4148 assert_eq!(first.project_key, second.project_key);
4149
4150 let secondary = ProjectRepository {
4151 id: "secondary".into(),
4152 github: Some("owner/secondary".into()),
4153 local: None,
4154 destination: "secondary".into(),
4155 git_ref: None,
4156 };
4157 config
4158 .bundles
4159 .get_mut("other")
4160 .unwrap()
4161 .repositories
4162 .push(secondary.clone());
4163 let project_keys = |config: &HelConfig| {
4164 ViewerSnapshot::from_config_state(config, &state, 1)
4165 .sessions
4166 .into_iter()
4167 .map(|session| session.project_key)
4168 .collect::<Vec<_>>()
4169 };
4170 let keys = project_keys(&config);
4171 assert_ne!(
4172 keys[0], keys[1],
4173 "an added repository must change the bundle identity"
4174 );
4175
4176 let first_bundle = config.bundles.get_mut("hel").unwrap();
4177 first_bundle.repositories.insert(0, secondary);
4178 first_bundle.primary_repo = "secondary".into();
4179 let keys = project_keys(&config);
4180 assert_eq!(
4181 keys[0], keys[1],
4182 "the same repository set must group together despite order or primary choice"
4183 );
4184 }
4185
4186 #[test]
4187 fn viewer_session_applies_a_resolved_source_without_publishing_it() {
4188 let (config, state) = sample_config_state();
4189 let mut viewer = ViewerSnapshot::from_config_state(&config, &state, 1)
4190 .sessions
4191 .into_iter()
4192 .next()
4193 .expect("session");
4194 let source = ProjectSourceIdentity::git_remote("git@github.com:BrokkAi/bifrost-dev.git")
4195 .expect("GitHub source");
4196
4197 viewer.set_project_source(&source);
4198
4199 assert_eq!(viewer.project_label, "bifrost-dev");
4200 assert_eq!(viewer.project_key, project_key(&source.key));
4201 let json = serde_json::to_string(&viewer).expect("serialize viewer session");
4202 assert!(!json.contains("BrokkAi"));
4203 assert!(!json.contains("github.com"));
4204 }
4205
4206 #[test]
4209 fn lifecycle_categories_decide_what_the_dashboard_shows() {
4210 use ViewerLifecycleCategory::{Failed, Live, Starting, Stopped, Stopping};
4211
4212 for (state, expected, on_dashboard) in [
4213 (SessionState::Provisioning, Starting, true),
4214 (SessionState::Running, Live, true),
4215 (SessionState::Disconnected, Live, true),
4216 (SessionState::Checkpointing, Live, true),
4217 (SessionState::Closing, Stopping, true),
4218 (SessionState::Destroying, Stopping, true),
4219 (SessionState::Stopped, Stopped, false),
4220 (SessionState::Lost, Failed, false),
4221 (SessionState::Error, Failed, false),
4222 (SessionState::DestroyedWithDataLoss, Failed, false),
4223 ] {
4224 let category = ViewerLifecycleCategory::of(state);
4225 assert_eq!(category, expected, "{state:?}");
4226 assert_eq!(
4227 category.is_dashboard_visible(),
4228 on_dashboard,
4229 "{state:?} belongs on the dashboard? "
4230 );
4231 }
4232 }
4233
4234 #[test]
4238 fn compatible_resume_targets_are_the_complement_of_the_incompatible_ones() {
4239 let (config, state) = sample_config_state();
4240 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4241 let session = &snapshot.sessions[0];
4242 let all = config.targets.keys().cloned().collect::<Vec<_>>();
4243
4244 for target in &all {
4245 assert_ne!(
4246 session.compatible_resume_targets.contains(target),
4247 session.incompatible_resume_targets.contains(target),
4248 "target {target} is in both lists or neither"
4249 );
4250 }
4251 assert_eq!(
4252 session.compatible_resume_targets.len() + session.incompatible_resume_targets.len(),
4253 all.len(),
4254 "the two lists do not cover every target"
4255 );
4256 }
4257
4258 #[tokio::test]
4262 async fn actions_are_refused_when_their_capability_is_false() {
4263 for (body, capability) in [
4264 (
4265 r#"{"action":"cancel-turn","session_id":"session-1"}"#,
4266 "cancel_turn",
4267 ),
4268 (
4269 r#"{"action":"set-plan-mode","session_id":"session-1","active":true}"#,
4270 "set_plan_mode",
4271 ),
4272 (
4273 r#"{"action":"set-config","session_id":"session-1","key":"model","value":"x"}"#,
4274 "set_config",
4275 ),
4276 ] {
4277 let (app, mut actions, _, _, _) = app();
4278 let response = post_action(app, cookie(), body.to_owned()).await;
4279 assert!(
4280 response.status().is_client_error(),
4281 "{capability} was accepted while false: {}",
4282 response.status()
4283 );
4284 assert!(
4285 actions.try_recv().is_err(),
4286 "{capability} reached the controller while false"
4287 );
4288 }
4289 }
4290
4291 #[tokio::test]
4294 async fn a_config_key_the_harness_never_advertised_is_refused() {
4295 let capable = |snapshot: &mut ViewerSnapshot| {
4296 snapshot.sessions[0].capabilities.set_config = true;
4297 snapshot.sessions[0].config_options = vec![ViewerConfigOption {
4298 key: "model".into(),
4299 label: "model".into(),
4300 current: None,
4301 choices: vec![ViewerConfigChoice {
4302 value: "sonnet".into(),
4303 name: "Sonnet".into(),
4304 description: None,
4305 }],
4306 }];
4307 };
4308
4309 for (body, why) in [
4310 (
4311 r#"{"action":"set-config","session_id":"session-1","key":"effort","value":"high"}"#,
4312 "an unadvertised key",
4313 ),
4314 (
4315 r#"{"action":"set-config","session_id":"session-1","key":"model","value":"gpt-9"}"#,
4316 "an unoffered value",
4317 ),
4318 ] {
4319 let (app, mut actions, _, _, _) = app_with_snapshot(capable);
4320 let response = post_action(app, cookie(), body.to_owned()).await;
4321 assert_eq!(
4322 response.status(),
4323 StatusCode::BAD_REQUEST,
4324 "{why} was accepted"
4325 );
4326 assert!(actions.try_recv().is_err(), "{why} reached the controller");
4327 }
4328
4329 let (app, mut actions, _, _, _) = app_with_snapshot(capable);
4331 let response = tokio::spawn(post_action(
4332 app,
4333 cookie(),
4334 r#"{"action":"set-config","session_id":"session-1","key":"model","value":"sonnet"}"#
4335 .to_owned(),
4336 ));
4337 let action = actions
4338 .recv()
4339 .await
4340 .expect("the action reached the controller");
4341 assert!(
4342 matches!(
4343 action.action,
4344 ControllerAction::SetConfig { ref key, ref value, .. }
4345 if key == "model" && value == "sonnet"
4346 ),
4347 "the advertised value was not forwarded unchanged"
4348 );
4349 action.reply.send(ActionOutcome::Accepted).unwrap();
4350 assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
4351 }
4352
4353 #[tokio::test]
4356 async fn a_dirty_acknowledgement_is_bounded_and_names_repositories() {
4357 let oversized = (0..40)
4358 .map(|index| format!(r#""repo-{index}""#))
4359 .collect::<Vec<_>>()
4360 .join(",");
4361 for (ack, why) in [
4362 (oversized.as_str(), "an unbounded acknowledgement"),
4363 (r#""""#, "an empty repository name"),
4364 ] {
4365 let (app, mut actions, _, _, _) = app();
4366 let body = format!(
4367 r#"{{"action":"new","workspace_id":"default","profile_id":"codex-1","bundle_id":"hel","target_id":"podman","dirty_ack":[{ack}]}}"#
4368 );
4369 let response = post_action(app, cookie(), body).await;
4370 assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{why}");
4371 assert!(actions.try_recv().is_err(), "{why} reached the controller");
4372 }
4373 }
4374
4375 #[tokio::test]
4378 async fn a_new_session_without_a_title_is_accepted() {
4379 let (app, mut actions, _, _, _) = app();
4380 let response = tokio::spawn(post_action(
4381 app,
4382 cookie(),
4383 r#"{"action":"new","workspace_id":"default","profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#
4384 .to_owned(),
4385 ));
4386 let action = actions
4389 .recv()
4390 .await
4391 .expect("the action reached the controller");
4392 assert!(
4393 matches!(
4394 action.action,
4395 ControllerAction::New { title: None, ref workspace_id, .. }
4396 if workspace_id == "default"
4397 ),
4398 "the workspace or the absent title did not survive the boundary"
4399 );
4400 action.reply.send(ActionOutcome::Accepted).unwrap();
4401 assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
4402 }
4403
4404 #[test]
4408 fn a_cookie_names_one_viewer_and_two_cookies_never_collide() {
4409 let key = b"01234567890123456789012345678901";
4410 let expiry = now_unix().saturating_add(3600);
4411 let first = signed_cookie_value(key, "viewer-a", expiry);
4412 let second = signed_cookie_value(key, "viewer-b", expiry);
4413 assert_ne!(
4414 first, second,
4415 "two viewers unlocking in the same second share a cookie"
4416 );
4417 assert_eq!(
4418 cookie_viewer(key, &first, now_unix()),
4419 Some(Some("viewer-a".to_owned()))
4420 );
4421 assert_eq!(
4422 cookie_viewer(key, &second, now_unix()),
4423 Some(Some("viewer-b".to_owned()))
4424 );
4425 }
4426
4427 #[test]
4431 fn a_legacy_cookie_still_authenticates_and_stores_nothing() {
4432 let key = b"01234567890123456789012345678901";
4433 let expiry = now_unix().saturating_add(3600);
4434 let legacy = legacy_signed_cookie_value(key, expiry);
4435 assert_eq!(cookie_viewer(key, &legacy, now_unix()), Some(None));
4436 assert!(session_cookie_valid(key, &legacy, now_unix()));
4437 assert!(
4438 !session_cookie_valid(key, &legacy, expiry),
4439 "an expired legacy cookie still authenticated"
4440 );
4441 }
4442
4443 #[test]
4445 fn a_tampered_cookie_is_refused() {
4446 let key = b"01234567890123456789012345678901";
4447 let expiry = now_unix().saturating_add(3600);
4448 let honest = signed_cookie_value(key, "viewer-a", expiry);
4449 let swapped = honest.replacen("viewer-a", "viewer-b", 1);
4450 assert_eq!(cookie_viewer(key, &swapped, now_unix()), None);
4451 assert_eq!(cookie_viewer(key, "nonsense", now_unix()), None);
4452 assert_eq!(cookie_viewer(key, &format!("{expiry}."), now_unix()), None);
4453 }
4454
4455 #[tokio::test]
4458 async fn an_oversized_draft_is_refused_with_a_stable_code() {
4459 let (app, _, _, _, mut stored) = app();
4460 let draft = "x".repeat(64 * 1024 + 1);
4461 let response = app
4462 .oneshot(
4463 Request::put("/api/sessions/session-1/draft")
4464 .header(COOKIE, cookie())
4465 .header(CONTENT_TYPE, "application/json")
4466 .body(Body::from(
4467 serde_json::json!({ "draft": draft }).to_string(),
4468 ))
4469 .unwrap(),
4470 )
4471 .await
4472 .unwrap();
4473 assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
4474 assert!(stored.try_recv().is_err(), "an oversized draft was stored");
4475 }
4476
4477 #[tokio::test]
4480 async fn a_legacy_viewer_reads_empty_state_and_cannot_store_a_draft() {
4481 let key = b"01234567890123456789012345678901";
4482 let legacy = format!(
4483 "{COOKIE_NAME}={}",
4484 legacy_signed_cookie_value(key, now_unix().saturating_add(3600))
4485 );
4486
4487 let (reader, _, _, _, mut stored) = app();
4488 let response = reader
4489 .oneshot(
4490 Request::get("/api/sessions/session-1/client-state")
4491 .header(COOKIE, legacy.clone())
4492 .body(Body::empty())
4493 .unwrap(),
4494 )
4495 .await
4496 .unwrap();
4497 assert_eq!(response.status(), StatusCode::OK);
4498 let body = response.into_body().collect().await.unwrap().to_bytes();
4499 let state: ViewerClientState = serde_json::from_slice(&body).unwrap();
4500 assert_eq!(state, ViewerClientState::default());
4501 assert!(
4502 stored.try_recv().is_err(),
4503 "a legacy viewer read stored state"
4504 );
4505
4506 let (writer, _, _, _, mut stored) = app();
4507 let response = writer
4508 .oneshot(
4509 Request::put("/api/sessions/session-1/draft")
4510 .header(COOKIE, legacy)
4511 .header(CONTENT_TYPE, "application/json")
4512 .body(Body::from(r#"{"draft":"text"}"#))
4513 .unwrap(),
4514 )
4515 .await
4516 .unwrap();
4517 assert_eq!(response.status(), StatusCode::CONFLICT);
4518 assert!(stored.try_recv().is_err(), "a legacy viewer stored a draft");
4519 }
4520
4521 #[tokio::test]
4523 async fn prompt_history_refuses_an_unknown_scope() {
4524 let (app, _, _, _, mut stored) = app();
4525 let response = app
4526 .oneshot(
4527 Request::get("/api/sessions/session-1/history?q=ship&scope=everything")
4528 .header(COOKIE, cookie())
4529 .body(Body::empty())
4530 .unwrap(),
4531 )
4532 .await
4533 .unwrap();
4534 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4535 assert!(
4536 stored.try_recv().is_err(),
4537 "the search reached the controller"
4538 );
4539 }
4540
4541 #[tokio::test]
4545 async fn a_preflight_validates_before_it_reaches_the_controller() {
4546 for (body, why) in [
4547 (
4548 r#"{"profile_id":"nope","bundle_id":"hel","target_id":"podman"}"#,
4549 "an unknown profile",
4550 ),
4551 (
4552 r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw"}"#,
4553 "a bare target with no directory",
4554 ),
4555 ] {
4556 let (app, _, _, mut preflights, _) = app();
4557 let response = app
4558 .oneshot(
4559 Request::post("/api/preflight/new")
4560 .header(COOKIE, cookie())
4561 .header(CONTENT_TYPE, "application/json")
4562 .body(Body::from(body))
4563 .unwrap(),
4564 )
4565 .await
4566 .unwrap();
4567 assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{why}");
4568 assert!(
4569 preflights.try_recv().is_err(),
4570 "{why} reached the controller"
4571 );
4572 }
4573 }
4574
4575 #[tokio::test]
4579 async fn a_bare_preflight_forwards_directory_validation_to_the_controller() {
4580 let (app, _, _, mut preflights, _) = app();
4581 let response = tokio::spawn(app.oneshot(
4582 Request::post("/api/preflight/new")
4583 .header(COOKIE, cookie())
4584 .header(CONTENT_TYPE, "application/json")
4585 .body(Body::from(
4586 r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw","project_directory":"/work/project"}"#,
4587 ))
4588 .unwrap(),
4589 ));
4590 let request = preflights.recv().await.expect("the controller was asked");
4591 assert_eq!(request.bundle_id, "hel");
4592 assert_eq!(request.target_id, "raw");
4593 assert_eq!(
4594 request.project_directory,
4595 Some(PathBuf::from("/work/project"))
4596 );
4597 request
4598 .reply
4599 .send(Ok(PreflightNew {
4600 dirty_repositories: Vec::new(),
4601 }))
4602 .unwrap();
4603 let response = response.await.unwrap().unwrap();
4604 assert_eq!(response.status(), StatusCode::OK);
4605 let body = response.into_body().collect().await.unwrap().to_bytes();
4606 let answer: PreflightNew = serde_json::from_slice(&body).unwrap();
4607 assert!(answer.dirty_repositories.is_empty());
4608 }
4609
4610 #[tokio::test]
4611 async fn a_bare_preflight_validation_failure_is_actionable_without_its_details() {
4612 let (app, _, _, mut preflights, _) = app();
4613 let response = tokio::spawn(app.oneshot(
4614 Request::post("/api/preflight/new")
4615 .header(COOKIE, cookie())
4616 .header(CONTENT_TYPE, "application/json")
4617 .body(Body::from(
4618 r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw","project_directory":"/private/project"}"#,
4619 ))
4620 .unwrap(),
4621 ));
4622 let request = preflights.recv().await.expect("the controller was asked");
4623 request
4624 .reply
4625 .send(Err(PreflightFailure::Validation))
4626 .unwrap();
4627 let response = response.await.unwrap().unwrap();
4628 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4629 let body = response.into_body().collect().await.unwrap().to_bytes();
4630 assert_eq!(
4631 serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
4632 serde_json::json!({
4633 "error": "project validation failed; check that the directory exists, is accessible, and contains a Git repository with a valid HEAD"
4634 })
4635 );
4636 assert!(!String::from_utf8_lossy(&body).contains("/private/project"));
4637 }
4638
4639 #[tokio::test]
4640 async fn a_bundle_preflight_controller_failure_keeps_the_generic_service_error() {
4641 let (app, _, _, mut preflights, _) = app();
4642 let response = tokio::spawn(
4643 app.oneshot(
4644 Request::post("/api/preflight/new")
4645 .header(COOKIE, cookie())
4646 .header(CONTENT_TYPE, "application/json")
4647 .body(Body::from(
4648 r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#,
4649 ))
4650 .unwrap(),
4651 ),
4652 );
4653 let request = preflights.recv().await.expect("the controller was asked");
4654 request
4655 .reply
4656 .send(Err(PreflightFailure::Controller(
4657 "private /source/hel details".into(),
4658 )))
4659 .unwrap();
4660 let response = response.await.unwrap().unwrap();
4661 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
4662 let body = response.into_body().collect().await.unwrap().to_bytes();
4663 assert_eq!(
4664 serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
4665 serde_json::json!({"error": "the controller could not check this project"})
4666 );
4667 assert!(!String::from_utf8_lossy(&body).contains("/source/hel"));
4668 }
4669
4670 #[tokio::test]
4673 async fn a_bundle_preflight_reports_the_repositories_by_leaf_name() {
4674 let (app, _, _, mut preflights, _) = app();
4675 let response = tokio::spawn(
4676 app.oneshot(
4677 Request::post("/api/preflight/new")
4678 .header(COOKIE, cookie())
4679 .header(CONTENT_TYPE, "application/json")
4680 .body(Body::from(
4681 r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#,
4682 ))
4683 .unwrap(),
4684 ),
4685 );
4686 let request = preflights.recv().await.expect("the controller was asked");
4687 assert_eq!(request.bundle_id, "hel");
4688 assert_eq!(request.target_id, "podman");
4689 assert_eq!(request.project_directory, None);
4690 request
4691 .reply
4692 .send(Ok(PreflightNew {
4693 dirty_repositories: vec!["hel".into()],
4694 }))
4695 .unwrap();
4696 let response = response.await.unwrap().unwrap();
4697 assert_eq!(response.status(), StatusCode::OK);
4698 let body = response.into_body().collect().await.unwrap().to_bytes();
4699 let answer: PreflightNew = serde_json::from_slice(&body).unwrap();
4700 assert_eq!(answer.dirty_repositories, vec!["hel".to_owned()]);
4701 assert!(
4702 !String::from_utf8_lossy(&body).contains('/'),
4703 "the preflight published a path: {}",
4704 String::from_utf8_lossy(&body)
4705 );
4706 }
4707
4708 #[test]
4713 fn the_markdown_renderer_builds_structure_and_refuses_injection() {
4714 run_web_check(
4715 "markdown",
4716 r#"import { installDocument, elements, only, check, checkEqual } from './test-dom.js';
4717installDocument();
4718const { renderMarkdown, renderDiffSummary, safeHref } = await import('./markdown.js');
4719
4720const render = source => {
4721 const host = document.createElement('section');
4722 host.append(renderMarkdown(source));
4723 return host;
4724};
4725
4726// Headings
4727checkEqual(only(render('# Title'), 'h1').textContent, 'Title', 'h1');
4728checkEqual(only(render('### Deep'), 'h3').textContent, 'Deep', 'h3');
4729
4730// Nested lists
4731const nested = render('- one\n - inner\n- two');
4732check(elements(nested, 'ul').length === 2, 'nested list produced ' + elements(nested, 'ul').length + ' lists');
4733check(elements(elements(nested, 'ul')[0], 'li').length >= 2, 'outer list lost items');
4734
4735// Ordered lists
4736checkEqual(elements(render('1. a\n2. b'), 'ol').length, 1, 'ordered list');
4737
4738// Fenced code stays unparsed
4739const fenced = render('```rust\nlet x = *y*;\n```');
4740checkEqual(only(fenced, 'code').textContent, 'let x = *y*;', 'fenced code');
4741check(elements(fenced, 'span').some(s => s.className === 'tok-kw'), 'fenced rust untinted');
4742checkEqual(elements(fenced, 'em').length, 0, 'fence emphasised its contents');
4743checkEqual(only(fenced, 'pre').dataset.lang, 'rust', 'fence language');
4744
4745// Inline code beats emphasis
4746checkEqual(only(render('`*not em*`'), 'code').textContent, '*not em*', 'inline code');
4747checkEqual(elements(render('`*not em*`'), 'em').length, 0, 'inline code emphasised');
4748
4749// Emphasis
4750checkEqual(only(render('**bold**'), 'strong').textContent, 'bold', 'strong');
4751checkEqual(only(render('*it*'), 'em').textContent, 'it', 'em');
4752checkEqual(only(render('~~gone~~'), 'del').textContent, 'gone', 'del');
4753
4754// Tables
4755const table = render('| a | b |\n| --- | ---: |\n| 1 | 2 |');
4756checkEqual(elements(table, 'table').length, 1, 'table');
4757checkEqual(elements(table, 'th').length, 2, 'table header cells');
4758checkEqual(elements(table, 'td').length, 2, 'table body cells');
4759checkEqual(elements(table, 'th')[1].className, 'align-right', 'table alignment class');
4760checkEqual(only(table, 'div').className, 'scroll-x', 'table scroll wrapper');
4761
4762// Blockquote and rule
4763checkEqual(elements(render('> quoted'), 'blockquote').length, 1, 'blockquote');
4764checkEqual(elements(render('---'), 'hr').length, 1, 'rule');
4765
4766// XSS: markup is text, never elements
4767const injected = render('<img src=x onerror=alert(1)>');
4768checkEqual(elements(injected, 'img').length, 0, 'raw HTML became an element');
4769check(injected.textContent.includes('<img src=x onerror=alert(1)>'), 'raw HTML lost its text');
4770
4771// XSS: refused link schemes
4772for (const target of ['javascript:alert(1)', 'JaVaScRiPt:alert(1)', 'java\tscript:alert(1)', 'data:text/html,<script>', 'vbscript:x']) {
4773 const out = render(`[click](${target})`);
4774 checkEqual(elements(out, 'a').length, 0, `link scheme ${JSON.stringify(target)} was allowed`);
4775 check(out.textContent.includes('click'), `link scheme ${JSON.stringify(target)} lost its label`);
4776}
4777
4778// Accepted schemes keep their href and carry safe rel/target
4779for (const target of ['https://example.com', 'http://example.com/a', 'mailto:someone@example.com']) {
4780 const anchor = only(render(`[click](${target})`), 'a');
4781 checkEqual(anchor.getAttribute('href'), target, 'href');
4782 checkEqual(anchor.getAttribute('rel'), 'noreferrer noopener', 'rel');
4783 checkEqual(anchor.getAttribute('target'), '_blank', 'target');
4784}
4785
4786// safeHref directly
4787checkEqual(safeHref('javascript:alert(1)'), null, 'safeHref allowed javascript:');
4788checkEqual(safeHref(' https://x.test '), 'https://x.test', 'safeHref cleaned value');
4789
4790// Inline markup inside a link label
4791checkEqual(only(render('[**bold link**](https://x.test)'), 'strong').textContent, 'bold link', 'link label markup');
4792
4793// An unclosed delimiter is literal, not markup
4794checkEqual(render('a * b').textContent, 'a * b', 'unclosed emphasis');
4795checkEqual(elements(render('a * b'), 'em').length, 0, 'unclosed emphasis made an element');
4796
4797// Diff summaries: the real format from format_diffstat, two spaces and U+2212
4798const diff = renderDiffSummary(['src/main.rs +12 −3', 'unparseable line']);
4799const items = elements(diff, 'li');
4800checkEqual(items.length, 2, 'diffstat rows');
4801checkEqual(elements(items[0], 'span')[0].textContent, 'src/main.rs', 'diffstat path');
4802checkEqual(elements(items[0], 'span')[1].textContent, '+12', 'diffstat additions');
4803checkEqual(elements(items[0], 'span')[2].textContent, '−3', 'diffstat deletions');
4804checkEqual(elements(items[1], 'span').length, 1, 'unparseable diffstat produced counts');
4805checkEqual(elements(items[1], 'span')[0].textContent, 'unparseable line', 'unparseable diffstat lost its text');
4806
4807console.log('all markdown checks passed');
4808"#,
4809 );
4810 }
4811
4812 #[test]
4817 fn tool_output_is_tinted_folded_and_never_read_as_markdown() {
4818 run_web_check(
4819 "tool-output",
4820 r#"import { installDocument, elements, only, check, checkEqual, openFold } from './test-dom.js';
4821installDocument();
4822const { renderToolOutput, codeBlock, detectLang, appendCommandTokens, isPathLike } = await import(
4823 './tool-output.js'
4824);
4825
4826const classes = root => elements(root, 'span').map(s => s.className);
4827
4828// A shell command is told apart into program, subcommand, flag and path.
4829const line = document.createElement('pre');
4830appendCommandTokens(line, 'cargo test --workspace src/lib.rs');
4831const seen = classes(line);
4832check(seen.includes('cmd-program'), 'no program: ' + seen);
4833check(seen.includes('cmd-subcommand'), 'no subcommand: ' + seen);
4834check(seen.includes('cmd-flag'), 'no flag: ' + seen);
4835check(seen.includes('cmd-path'), 'no path: ' + seen);
4836checkEqual(line.textContent, 'cargo test --workspace src/lib.rs', 'command text changed');
4837
4838// An operator starts the program count again, so both programs are found.
4839const piped = document.createElement('pre');
4840appendCommandTokens(piped, 'git status && cargo build');
4841checkEqual(classes(piped).filter(c => c === 'cmd-program').length, 2, 'pipeline reset');
4842
4843// Prose with a slash is not a path; a real path is.
4844check(!isPathLike('and/or'), '"and/or" read as a path');
4845check(isPathLike('src/lib/thing.rs'), 'a real path did not');
4846check(isPathLike('./x'), 'a relative path did not');
4847check(isPathLike('Cargo.toml'), 'a file with an extension did not');
4848
4849// JSON is pretty-printed and tinted, keys apart from values.
4850const json = renderToolOutput('{"name":"hel","count":3,"ok":true}');
4851const jsonClasses = classes(json);
4852check(jsonClasses.includes('tok-key'), 'no JSON key: ' + jsonClasses);
4853check(jsonClasses.includes('tok-str'), 'no JSON string: ' + jsonClasses);
4854check(jsonClasses.includes('tok-num'), 'no JSON number: ' + jsonClasses);
4855check(jsonClasses.includes('tok-kw'), 'no JSON keyword: ' + jsonClasses);
4856check(json.textContent.includes('"name"'), 'JSON lost its content');
4857
4858// Rust is tinted; an unknown language is not.
4859const rust = codeBlock('pub fn main() {\n let x = 1;\n}', 'rust');
4860check(classes(rust).includes('tok-kw'), 'rust keywords untinted');
4861checkEqual(only(rust, 'pre').dataset.lang, 'rust', 'rust data-lang');
4862const plain = codeBlock('nothing in particular here', 'brainfuck');
4863checkEqual(classes(plain).length, 0, 'unknown language was tinted');
4864
4865// Sniffing is conservative: a log stays plain, real code does not.
4866checkEqual(detectLang('12:03 INFO started\n12:04 INFO done\n12:05 INFO stopped'), '', 'a log was sniffed');
4867checkEqual(
4868 detectLang('fn a() {}\nfn b() {}\nlet mut x = 1;\nuse std::fmt;\nimpl Foo {}\nlet y = x.unwrap();'),
4869 'rust',
4870 'rust was not sniffed',
4871);
4872checkEqual(detectLang('--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+new'), 'diff', 'diff was not sniffed');
4873
4874// A long dump is one closed fold that has built nothing yet.
4875const long = Array.from({ length: 400 }, (_, i) => `line ${i}`).join('\n');
4876const folded = renderToolOutput(long);
4877checkEqual(folded.nodeName, 'DETAILS', 'a 400-line dump was not folded');
4878checkEqual(elements(folded, 'pre').length, 0, 'a closed fold built its content anyway');
4879check(only(folded, 'summary').textContent.includes('400 lines'), 'fold summary: ' + only(folded, 'summary').textContent);
4880openFold(folded);
4881checkEqual(elements(folded, 'pre').length, 1, 'an opened fold built nothing');
4882check(elements(folded, 'pre')[0].textContent.includes('line 399'), 'the fold lost its content');
4883
4884// Opening twice builds once.
4885openFold(folded);
4886checkEqual(elements(folded, 'pre').length, 1, 'reopening rebuilt the content');
4887
4888// A short dump is not folded.
4889checkEqual(renderToolOutput('one\ntwo').nodeName, 'PRE', 'a short dump was folded');
4890
4891// Tool output is never parsed as Markdown, so an underscore is an underscore.
4892const literal = renderToolOutput('a _b_ c <img src=x>');
4893checkEqual(elements(literal, 'em').length, 0, 'tool output was emphasised');
4894checkEqual(elements(literal, 'img').length, 0, 'tool output produced an element');
4895check(literal.textContent.includes('<img src=x>'), 'tool output lost its text');
4896
4897console.log('all tool-output checks passed');
4898"#,
4899 );
4900 }
4901
4902 #[test]
4909 fn no_web_module_builds_markup_from_a_string() {
4910 const SINKS: [&str; 5] = [
4911 "innerHTML",
4912 "outerHTML",
4913 "insertAdjacentHTML",
4914 "document.write",
4915 "new Function",
4916 ];
4917 const ALLOWED: [(&str, &str); 0] = [];
4920 for (name, source) in [
4921 ("viewer.js", VIEWER_JS),
4922 ("markdown.js", MARKDOWN_JS),
4923 ("tool-output.js", TOOL_OUTPUT_JS),
4924 ] {
4925 for (number, line) in source.lines().enumerate() {
4926 let trimmed = line.trim();
4927 if trimmed.starts_with("//") || trimmed.starts_with("///") {
4928 continue;
4929 }
4930 for sink in SINKS {
4931 if !trimmed.contains(sink) {
4932 continue;
4933 }
4934 assert!(
4935 ALLOWED
4936 .iter()
4937 .any(|(file, allowed)| *file == name && trimmed == *allowed),
4938 "{name}:{} builds markup from a string: {trimmed}",
4939 number + 1
4940 );
4941 }
4942 }
4943 }
4944 }
4945
4946 #[test]
4950 fn embedded_viewer_keeps_elicitation_answers_across_snapshot_polls() {
4951 let source = viewer_source(
4952 "const elicitationCards = new Map()",
4953 "async function submitElicitation",
4954 );
4955 let dom = r#"
4956let replaceCalls = 0;
4957function makeEl(tag) {
4958 return {
4959 tagName: tag.toUpperCase(),
4960 children: [],
4961 options: [],
4962 selectedOptions: [],
4963 className: "",
4964 textContent: "",
4965 disabled: false,
4966 required: false,
4967 value: "",
4968 appendChild(child) {
4969 this.children.push(child);
4970 if (this.tagName === "SELECT") this.options.push(child);
4971 return child;
4972 },
4973 append(...kids) {
4974 this.children.push(...kids);
4975 },
4976 replaceChildren(...kids) {
4977 replaceCalls += 1;
4978 this.children = kids;
4979 },
4980 addEventListener() {},
4981 querySelectorAll(selector) {
4982 const found = [];
4983 const visit = node => {
4984 for (const child of node.children) {
4985 if (child.tagName === "INPUT" && (selector === "input" || child.checked)) found.push(child);
4986 visit(child);
4987 }
4988 };
4989 visit(this);
4990 return found;
4991 },
4992 querySelector(selector) { return this.querySelectorAll(selector)[0] || null; },
4993 setCustomValidity() {},
4994 reportValidity() {
4995 return true;
4996 },
4997 };
4998}
4999const created = [];
5000const document = {
5001 createElement(tag) {
5002 const el = makeEl(tag);
5003 created.push(el);
5004 return el;
5005 },
5006};
5007const elicitations = makeEl("div");
5008function el(tag, className, text) {
5009 const node = document.createElement(tag);
5010 node.className = className || "";
5011 node.textContent = text || "";
5012 return node;
5013}
5014async function submitElicitation() {}
5015"#;
5016 let checks = r#"
5017const request = {
5018 id: "elicitation-1",
5019 message: "Which CI architecture?",
5020 title: "CI",
5021 fields: [
5022 {
5023 id: "question_0",
5024 title: "CI architecture",
5025 required: false,
5026 kind: "single_select",
5027 options: [{ value: "reusable", title: "Reusable" }, { value: "matrix", title: "Matrix" }],
5028 },
5029 { id: "question_0_custom", title: "Other", required: false, kind: "text" },
5030 ],
5031};
5032const session = { id: "session-1", pending_elicitations: [request] };
5033renderElicitations(session);
5034const card = elicitations.children[0];
5035const radio = created.find((el) => el.tagName === "INPUT" && el.value === "reusable");
5036const text = created.find((el) => el.tagName === "INPUT" && el.type === "text");
5037radio.checked = true;
5038text.value = "keep me";
5039const attachments = replaceCalls;
5040renderElicitations(session);
5041if (elicitations.children[0] !== card) {
5042 throw new Error("a snapshot rebuilt the pending card");
5043}
5044if (!radio.checked || text.value !== "keep me") {
5045 throw new Error("a snapshot wiped the half-filled answer");
5046}
5047if (replaceCalls !== attachments) {
5048 throw new Error("a snapshot re-attached an unchanged card and dropped focus");
5049}
5050sentElicitations.add(elicitationKey("session-1", request.id));
5051renderElicitations(session);
5052if (elicitations.children[0] !== card) {
5053 throw new Error("a sent answer rebuilt the card");
5054}
5055if (!radio.disabled || !text.disabled) {
5056 throw new Error("a sent answer left the controls live");
5057}
5058if (!radio.checked) {
5059 throw new Error("a sent answer wiped the reply");
5060}
5061renderElicitations({ id: "session-1", pending_elicitations: [] });
5062if (elicitations.children.length !== 0 || elicitationCards.size !== 0) {
5063 throw new Error("an answered request stayed rendered");
5064}
5065if (sentElicitations.size !== 0) {
5066 throw new Error("a resolved request kept its sent marker");
5067}
5068"#;
5069 run_viewer_script(
5070 "elicitation-rendering",
5071 &format!("{dom}\n{source}\n{checks}"),
5072 );
5073 }
5074
5075 fn sample_image(pixels: usize) -> ViewerPromptImage {
5076 ViewerPromptImage {
5077 data_base64: base64::engine::general_purpose::STANDARD.encode(vec![7_u8; pixels]),
5078 mime_type: "image/png".into(),
5079 width: 32,
5080 height: 24,
5081 }
5082 }
5083
5084 fn image_capable(snapshot: &mut ViewerSnapshot) {
5085 snapshot.sessions[0].prompt_images_supported = true;
5086 }
5087
5088 async fn post_action(app: Router, cookie: String, body: String) -> Response<Body> {
5089 app.oneshot(
5090 Request::post("/api/actions")
5091 .header(COOKIE, cookie)
5092 .header(CONTENT_TYPE, "application/json")
5093 .body(Body::from(body))
5094 .unwrap(),
5095 )
5096 .await
5097 .unwrap()
5098 }
5099
5100 #[tokio::test]
5101 async fn image_prompt_reaches_the_controller_with_its_images() {
5102 let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
5103 let cookie = login_cookie(&app).await;
5104 let image = sample_image(8);
5105 let body = serde_json::to_string(&ControllerAction::Prompt {
5106 session_id: "session-1".into(),
5107 text: String::new(),
5108 images: vec![image.clone(), image.clone()],
5109 })
5110 .unwrap();
5111 let response = tokio::spawn(post_action(app, cookie, body));
5112 let action = actions.recv().await.unwrap();
5113 assert_eq!(
5114 action.action,
5115 ControllerAction::Prompt {
5116 session_id: "session-1".into(),
5117 text: String::new(),
5118 images: vec![image.clone(), image],
5119 }
5120 );
5121 action.reply.send(ActionOutcome::Accepted).unwrap();
5122 assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
5123 }
5124
5125 #[tokio::test]
5129 async fn multi_image_prompts_are_accepted_over_the_general_body_limit() {
5130 let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
5131 let cookie = login_cookie(&app).await;
5132 let image = sample_image(MAX_BODY_BYTES / 2);
5133 let body = serde_json::to_string(&ControllerAction::Prompt {
5134 session_id: "session-1".into(),
5135 text: "look at these".into(),
5136 images: vec![image.clone(), image],
5137 })
5138 .unwrap();
5139 assert!(body.len() > MAX_BODY_BYTES);
5140 assert!(body.len() < MAX_PROMPT_BODY_BYTES);
5141 let response = tokio::spawn(post_action(app, cookie, body));
5142 let action = actions.recv().await.unwrap();
5143 action.reply.send(ActionOutcome::Accepted).unwrap();
5144 assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
5145 }
5146
5147 #[tokio::test]
5148 async fn a_body_over_the_prompt_limit_is_still_refused() {
5149 let (app, _actions, _, _, _) = app_with_snapshot(image_capable);
5150 let cookie = login_cookie(&app).await;
5151 let image = sample_image(MAX_PROMPT_BODY_BYTES);
5152 let body = serde_json::to_string(&ControllerAction::Prompt {
5153 session_id: "session-1".into(),
5154 text: String::new(),
5155 images: vec![image],
5156 })
5157 .unwrap();
5158 assert!(body.len() > MAX_PROMPT_BODY_BYTES);
5159 let response = post_action(app, cookie, body).await;
5160 assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
5161 }
5162
5163 #[tokio::test]
5164 async fn malformed_image_payloads_never_reach_the_controller() {
5165 let cases = [
5166 ("aW1hZ2U=", "text/plain", 32, 24),
5167 ("aW1hZ2U=", "image/png", 0, 24),
5168 ("not base64!", "image/png", 32, 24),
5169 ("", "image/png", 32, 24),
5170 ];
5171 for (data, mime, width, height) in cases {
5172 let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
5173 let cookie = login_cookie(&app).await;
5174 let body = serde_json::to_string(&ControllerAction::Prompt {
5175 session_id: "session-1".into(),
5176 text: String::new(),
5177 images: vec![ViewerPromptImage {
5178 data_base64: data.into(),
5179 mime_type: mime.into(),
5180 width,
5181 height,
5182 }],
5183 })
5184 .unwrap();
5185 let response = post_action(app, cookie, body).await;
5186 assert_eq!(
5187 response.status(),
5188 StatusCode::BAD_REQUEST,
5189 "expected {data:?}/{mime} {width}x{height} to be refused"
5190 );
5191 assert!(actions.try_recv().is_err());
5192 }
5193 }
5194
5195 #[test]
5196 fn image_prompts_need_text_or_an_image_and_an_agent_that_takes_them() {
5197 let (config, state) = sample_config_state();
5198 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
5199 let prompt = |text: &str, images: Vec<ViewerPromptImage>| ControllerAction::Prompt {
5200 session_id: "session-1".into(),
5201 text: text.into(),
5202 images,
5203 };
5204
5205 assert!(validate_action(&prompt("ship it", Vec::new()), &snapshot).is_ok());
5207 assert!(validate_action(&prompt("", vec![sample_image(8)]), &snapshot).is_err());
5208
5209 image_capable(&mut snapshot);
5210 assert!(validate_action(&prompt("", vec![sample_image(8)]), &snapshot).is_ok());
5212 assert!(validate_action(&prompt(" ", Vec::new()), &snapshot).is_err());
5213 assert!(validate_action(&prompt("", Vec::new()), &snapshot).is_err());
5214 assert!(validate_action(&prompt("!ls", vec![sample_image(8)]), &snapshot).is_err());
5216 }
5217
5218 #[test]
5221 fn embedded_viewer_reads_multiline_composer_text_out_of_its_dom() {
5222 let source = viewer_source("function composerText()", "function setComposerText(");
5223 let harness = r##"
5224const Node = { TEXT_NODE: 3 };
5225function textNode(value) {
5226 return { nodeType: 3, nodeValue: value, nodeName: "#text", childNodes: [], dataset: {} };
5227}
5228function element(name, children = [], dataset = {}) {
5229 const node = { nodeType: 1, nodeName: name, dataset, childNodes: children };
5230 children.forEach((child, index) => {
5231 child.nextSibling = children[index + 1] || null;
5232 });
5233 return node;
5234}
5235let promptText = null;
5236function read(children) {
5237 promptText = element("DIV", children);
5238 return composerText();
5239}
5240"##;
5241 let checks = r#"
5242const plain = read([textNode("ship it")]);
5243if (plain !== "ship it") throw new Error(`plain text became ${JSON.stringify(plain)}`);
5244
5245const broken = read([textNode("first"), element("BR"), textNode("second")]);
5246if (broken !== "first\nsecond") throw new Error(`line break became ${JSON.stringify(broken)}`);
5247
5248// The trailing break a browser leaves behind to keep the caret on a new line
5249// is scaffolding, not a line the user typed.
5250const filler = read([
5251 textNode("first"),
5252 element("BR"),
5253 element("BR", [], { composerFiller: "true" }),
5254]);
5255if (filler !== "first\n") throw new Error(`filler break became ${JSON.stringify(filler)}`);
5256
5257const blocks = read([
5258 textNode("first"),
5259 element("DIV", [textNode("second")]),
5260 element("DIV", [textNode("third")]),
5261]);
5262if (blocks !== "first\nsecond\nthird") throw new Error(`blocks became ${JSON.stringify(blocks)}`);
5263
5264const carriage = read([textNode("first\r\nsecond")]);
5265if (carriage !== "first\nsecond") throw new Error(`CRLF became ${JSON.stringify(carriage)}`);
5266"#;
5267 run_viewer_script("composer-reader", &format!("{harness}\n{source}\n{checks}"));
5268 }
5269
5270 #[tokio::test]
5274 async fn viewer_declares_the_icon_route_instead_of_requesting_a_missing_favicon() {
5275 let (app, _, _, _, _) = app();
5276 let page = fetch_text(app.clone(), "/").await;
5277 assert!(page.contains(r#"rel="icon""#), "the page declares no icon");
5278 assert!(page.contains("/icon.svg"), "the page names no icon route");
5279 let icon = app
5280 .oneshot(Request::get("/icon.svg").body(Body::empty()).unwrap())
5281 .await
5282 .unwrap();
5283 assert_eq!(icon.status(), StatusCode::OK);
5284 assert_eq!(
5285 icon.headers().get(CONTENT_TYPE).unwrap(),
5286 "image/svg+xml",
5287 "the icon route does not serve an SVG"
5288 );
5289 }
5290
5291 #[tokio::test]
5292 async fn valid_action_is_typed_and_forwarded() {
5293 let (app, mut actions, _, _, _) = app();
5294 let cookie = login_cookie(&app).await;
5295 let response = tokio::spawn(
5296 app.oneshot(
5297 Request::post("/api/actions")
5298 .header(COOKIE, cookie)
5299 .header(CONTENT_TYPE, "application/json")
5300 .body(Body::from(
5301 r#"{"action":"prompt","session_id":"session-1","text":"ship it"}"#,
5302 ))
5303 .unwrap(),
5304 ),
5305 );
5306 let action = actions.recv().await.unwrap();
5307 assert_eq!(
5308 action.action,
5309 ControllerAction::Prompt {
5310 session_id: "session-1".into(),
5311 text: "ship it".into(),
5312 images: Vec::new(),
5313 }
5314 );
5315 action.reply.send(ActionOutcome::Accepted).unwrap();
5316 let response = response.await.unwrap().unwrap();
5317 assert_eq!(response.status(), StatusCode::ACCEPTED);
5318 }
5319
5320 #[tokio::test]
5321 async fn move_preparation_is_read_only_and_returns_the_daemon_fingerprint() {
5322 let (app, mut preparations) = app_with_move_receiver();
5323 let cookie = login_cookie(&app).await;
5324 let response = tokio::spawn(
5325 app.oneshot(
5326 Request::post("/api/moves/prepare")
5327 .header(COOKIE, cookie)
5328 .header(CONTENT_TYPE, "application/json")
5329 .body(Body::from(
5330 r#"{"session_id":"session-1","profile_id":"codex-1","target_template_id":"podman","clear_resource_allocation":false,"additional_mounts":null,"resource_allocation":null}"#,
5331 ))
5332 .unwrap(),
5333 ),
5334 );
5335 let request = preparations
5336 .recv()
5337 .await
5338 .expect("preparation reached daemon");
5339 assert_eq!(request.selection.session_id, "session-1");
5340 assert_eq!(request.selection.profile_id.as_deref(), Some("codex-1"));
5341 assert_eq!(
5342 request.selection.target_template_id.as_deref(),
5343 Some("podman")
5344 );
5345 request
5346 .reply
5347 .send(Ok(MovePreparation {
5348 selection: request.selection,
5349 source_profile_id: "codex-1".into(),
5350 source_target_template_id: "podman".into(),
5351 cross_harness: false,
5352 active: true,
5353 queued_commands: vec![hel::hel_state::MaterializedQueuedPrompt {
5354 command_id: "queued-1".into(),
5355 kind: hel::hel_state::QueuedCommandKind::Prompt,
5356 content: vec![serde_json::json!({
5357 "type": "image",
5358 "mimeType": "image/png",
5359 "data": "secret-image-bytes"
5360 })],
5361 queued_at_ms: 1,
5362 }],
5363 fingerprint: "fingerprint".into(),
5364 operation_id: "move-1".into(),
5365 }))
5366 .unwrap();
5367 let response = response.await.unwrap().unwrap();
5368 assert_eq!(response.status(), StatusCode::OK);
5369 let body = response.into_body().collect().await.unwrap().to_bytes();
5370 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
5371 assert_eq!(body["operation_id"], "move-1");
5372 assert_eq!(body["active"], true);
5373 assert_eq!(
5374 body["queued_commands"][0]["content"][0]["text"],
5375 "[Image attachment: image/png]"
5376 );
5377 assert!(body.to_string().contains("[Image attachment: image/png]"));
5378 assert!(!body.to_string().contains("secret-image-bytes"));
5379 }
5380
5381 #[tokio::test]
5382 async fn confirmed_move_action_forwards_the_fingerprinted_request() {
5383 let (app, mut actions, _, _, _) = app_with_snapshot(|snapshot| {
5384 snapshot.sessions[0].capabilities.move_session = true;
5385 });
5386 let cookie = login_cookie(&app).await;
5387 let response = tokio::spawn(
5388 app.oneshot(
5389 Request::post("/api/actions")
5390 .header(COOKIE, cookie)
5391 .header(CONTENT_TYPE, "application/json")
5392 .body(Body::from(
5393 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}}"#,
5394 ))
5395 .unwrap(),
5396 ),
5397 );
5398 let action = actions.recv().await.expect("move action reached daemon");
5399 assert!(matches!(action.action, ControllerAction::Move { .. }));
5400 action.reply.send(ActionOutcome::Accepted).unwrap();
5401 assert_eq!(
5402 response.await.unwrap().unwrap().status(),
5403 StatusCode::ACCEPTED
5404 );
5405 }
5406
5407 #[tokio::test]
5408 async fn shell_action_is_typed_and_forwarded() {
5409 let (app, mut actions, _, _, _) = app();
5410 let cookie = login_cookie(&app).await;
5411 let response = tokio::spawn(
5412 app.oneshot(
5413 Request::post("/api/actions")
5414 .header(COOKIE, cookie)
5415 .header(CONTENT_TYPE, "application/json")
5416 .body(Body::from(
5417 r#"{"action":"run-shell","session_id":"session-1","command":"cargo test"}"#,
5418 ))
5419 .unwrap(),
5420 ),
5421 );
5422 let action = actions.recv().await.unwrap();
5423 assert_eq!(
5424 action.action,
5425 ControllerAction::RunShell {
5426 session_id: "session-1".into(),
5427 command: "cargo test".into(),
5428 }
5429 );
5430 action.reply.send(ActionOutcome::Accepted).unwrap();
5431 assert_eq!(
5432 response.await.unwrap().unwrap().status(),
5433 StatusCode::ACCEPTED
5434 );
5435 }
5436
5437 #[test]
5438 fn shell_action_validation_reserves_bang_prompts_and_checks_cancellation_ids() {
5439 let (config, state) = sample_config_state();
5440 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
5441 assert!(
5442 validate_action(
5443 &ControllerAction::Prompt {
5444 session_id: "session-1".into(),
5445 text: "!cargo test".into(),
5446 images: Vec::new(),
5447 },
5448 &snapshot,
5449 )
5450 .is_err()
5451 );
5452 assert!(
5453 validate_action(
5454 &ControllerAction::RunShell {
5455 session_id: "session-1".into(),
5456 command: "cargo test".into(),
5457 },
5458 &snapshot,
5459 )
5460 .is_ok()
5461 );
5462 assert!(
5463 validate_action(
5464 &ControllerAction::CancelShell {
5465 session_id: "session-1".into(),
5466 shell_command_id: "shell-1".into(),
5467 },
5468 &snapshot,
5469 )
5470 .is_err()
5471 );
5472
5473 snapshot.sessions[0]
5474 .active_user_shells
5475 .push(ViewerUserShell {
5476 id: "shell-1".into(),
5477 command: "cargo test".into(),
5478 started_at_ms: Some(10),
5479 });
5480 assert!(
5481 validate_action(
5482 &ControllerAction::CancelShell {
5483 session_id: "session-1".into(),
5484 shell_command_id: "shell-1".into(),
5485 },
5486 &snapshot,
5487 )
5488 .is_ok()
5489 );
5490 }
5491
5492 #[tokio::test]
5493 async fn bare_new_action_forwards_an_explicit_safe_project_directory() {
5494 let (app, mut actions, _, _, _) = app();
5495 let cookie = login_cookie(&app).await;
5496 let response = tokio::spawn(
5497 app.oneshot(
5498 Request::post("/api/actions")
5499 .header(COOKIE, cookie)
5500 .header(CONTENT_TYPE, "application/json")
5501 .body(Body::from(
5502 r#"{"action":"new","profile_id":"codex-1","bundle_id":"hel","target_id":"raw","title":"Raw work","project_directory":"/work/project"}"#,
5503 ))
5504 .unwrap(),
5505 ),
5506 );
5507 let action = actions.recv().await.unwrap();
5508 assert_eq!(
5509 action.action,
5510 ControllerAction::New {
5511 workspace_id: String::new(),
5512 profile_id: "codex-1".into(),
5513 bundle_id: "hel".into(),
5514 target_id: "raw".into(),
5515 title: Some("Raw work".into()),
5516 project_directory: Some(PathBuf::from("/work/project")),
5517 dirty_ack: Vec::new(),
5518 }
5519 );
5520 action.reply.send(ActionOutcome::Accepted).unwrap();
5521 assert_eq!(
5522 response.await.unwrap().unwrap().status(),
5523 StatusCode::ACCEPTED
5524 );
5525 }
5526
5527 #[test]
5528 fn new_action_requires_project_directory_exactly_for_bare_targets() {
5529 let (config, state) = sample_config_state();
5530 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
5531 let action = |target_id: &str, project_directory: Option<PathBuf>| ControllerAction::New {
5532 workspace_id: String::new(),
5533 profile_id: "codex-1".into(),
5534 bundle_id: "hel".into(),
5535 target_id: target_id.into(),
5536 title: Some("New work".into()),
5537 project_directory,
5538 dirty_ack: Vec::new(),
5539 };
5540
5541 assert!(validate_action(&action("podman", None), &snapshot).is_ok());
5542 assert_eq!(
5543 validate_action(&action("podman", Some("/work".into())), &snapshot)
5544 .unwrap_err()
5545 .status,
5546 StatusCode::BAD_REQUEST
5547 );
5548 assert_eq!(
5549 validate_action(&action("raw", None), &snapshot)
5550 .unwrap_err()
5551 .status,
5552 StatusCode::BAD_REQUEST
5553 );
5554 assert_eq!(
5555 validate_action(&action("raw", Some("relative".into())), &snapshot)
5556 .unwrap_err()
5557 .status,
5558 StatusCode::BAD_REQUEST
5559 );
5560 assert_eq!(
5561 validate_action(&action("raw", Some("/work/../secret".into())), &snapshot)
5562 .unwrap_err()
5563 .status,
5564 StatusCode::BAD_REQUEST
5565 );
5566 assert!(validate_action(&action("raw", Some("/work/project".into())), &snapshot).is_ok());
5567 }
5568
5569 #[tokio::test]
5570 async fn cancel_action_is_typed_and_forwarded() {
5571 let (app, mut actions, _, _, _) = app();
5572 let cookie = login_cookie(&app).await;
5573 let response = tokio::spawn(
5574 app.oneshot(
5575 Request::post("/api/actions")
5576 .header(COOKIE, cookie)
5577 .header(CONTENT_TYPE, "application/json")
5578 .body(Body::from(
5579 r#"{"action":"cancel","session_id":"session-1"}"#,
5580 ))
5581 .unwrap(),
5582 ),
5583 );
5584 let action = actions.recv().await.unwrap();
5585 assert_eq!(
5586 action.action,
5587 ControllerAction::Cancel {
5588 session_id: "session-1".into(),
5589 }
5590 );
5591 action.reply.send(ActionOutcome::Accepted).unwrap();
5592 assert_eq!(
5593 response.await.unwrap().unwrap().status(),
5594 StatusCode::ACCEPTED
5595 );
5596 }
5597
5598 #[tokio::test]
5599 async fn action_validation_accepts_cross_harness_resume_and_rejects_unknown() {
5600 let (mut config, state) = sample_config_state();
5601 config.profiles.insert(
5602 "claude-1".into(),
5603 HarnessProfile {
5604 context_window_bytes: None,
5605 kind: HarnessKind::Claude,
5606 home: "/secret/claude".into(),
5607 environment: BTreeMap::new(),
5608 },
5609 );
5610 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
5611 snapshot.workspaces.push(ViewerWorkspace {
5612 id: "workspace-1".into(),
5613 name: "One".into(),
5614 });
5615 validate_action(
5616 &ControllerAction::Resume {
5617 session_id: "session-1".into(),
5618 workspace_id: "workspace-1".into(),
5619 profile_id: "claude-1".into(),
5620 target_id: "podman".into(),
5621 queue: ResumeQueueDisposition::Start,
5622 additional_mounts: None,
5623 resource_allocation: None,
5624 },
5625 &snapshot,
5626 )
5627 .unwrap();
5628
5629 let error = validate_action(
5630 &ControllerAction::Resume {
5631 session_id: "session-1".into(),
5632 workspace_id: "missing".into(),
5633 profile_id: "claude-1".into(),
5634 target_id: "podman".into(),
5635 queue: ResumeQueueDisposition::Start,
5636 additional_mounts: None,
5637 resource_allocation: None,
5638 },
5639 &snapshot,
5640 )
5641 .unwrap_err();
5642 assert_eq!(error.status, StatusCode::BAD_REQUEST);
5643
5644 let error = validate_action(
5645 &ControllerAction::Close {
5646 session_id: "not-managed".into(),
5647 },
5648 &snapshot,
5649 )
5650 .unwrap_err();
5651 assert_eq!(error.status, StatusCode::NOT_FOUND);
5652 }
5653
5654 #[test]
5657 fn a_running_review_projects_to_the_phone() {
5658 use crate::hel_review_host::{RuntimeReviewView, VerdictKind, VerdictView};
5659 use hel::hel_review::driver::{Resolution, RoleState, RoleStatus, TurnReviewPhase};
5660
5661 let review = RuntimeReviewView {
5662 session_id: "session-1".into(),
5663 tier: hel::hel_review::lanes::ReviewTier::Extended,
5664 phase: TurnReviewPhase::Verdict(hel::hel_review::verdict::ReviewVerdict::Findings {
5665 synthesis: "[P1] src/lib.rs:1 -- unbounded retry".into(),
5666 evidence: Default::default(),
5667 }),
5668 roles: vec![
5669 RoleStatus {
5670 role: "supervisor".into(),
5671 label: "Supervisor".into(),
5672 state: RoleState::Clean,
5673 },
5674 RoleStatus {
5675 role: "tests".into(),
5676 label: "Tests".into(),
5677 state: RoleState::Findings,
5678 },
5679 ],
5680 status: "Enter to act".into(),
5681 verdict: Some(VerdictView {
5682 kind: VerdictKind::Findings,
5683 text: "[P1] src/lib.rs:1 -- unbounded retry".into(),
5684 allowed: vec![
5685 Resolution::Forwarded,
5686 Resolution::Dismissed,
5687 Resolution::Cancelled,
5688 ],
5689 }),
5690 };
5691
5692 let projected = ViewerTurnReview::from_runtime(&review);
5693
5694 assert_eq!(projected.tier, "extended");
5695 assert_eq!(
5696 projected
5697 .roles
5698 .iter()
5699 .map(|role| (role.label.as_str(), role.state.as_str()))
5700 .collect::<Vec<_>>(),
5701 vec![("Supervisor", "done"), ("Tests", "findings")]
5702 );
5703 let verdict = projected.verdict.expect("a findings verdict travels");
5704 assert_eq!(verdict.kind, "findings");
5705 assert!(verdict.text.contains("unbounded retry"));
5706 assert_eq!(verdict.allowed, vec!["forward", "dismiss", "cancel"]);
5707 }
5708
5709 #[test]
5713 fn resolving_a_review_is_gated_on_what_the_daemon_published() {
5714 let (config, state) = sample_config_state();
5715 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
5716
5717 let resolve = |resolution: &str| ControllerAction::ResolveReview {
5718 session_id: "session-1".into(),
5719 resolution: resolution.into(),
5720 };
5721
5722 let error = validate_action(&resolve("cancel"), &snapshot).unwrap_err();
5724 assert_eq!(error.status, StatusCode::BAD_REQUEST);
5725
5726 snapshot.sessions[0].turn_review = Some(ViewerTurnReview {
5727 tier: "quick".into(),
5728 status: "the reviewer is reading the change…".into(),
5729 roles: Vec::new(),
5730 verdict: None,
5731 });
5732 validate_action(&resolve("cancel"), &snapshot).unwrap();
5734 assert_eq!(
5735 validate_action(&resolve("forward"), &snapshot)
5736 .unwrap_err()
5737 .status,
5738 StatusCode::BAD_REQUEST
5739 );
5740
5741 snapshot.sessions[0].turn_review = Some(ViewerTurnReview {
5743 tier: "quick".into(),
5744 status: "the review failed".into(),
5745 roles: Vec::new(),
5746 verdict: Some(ViewerReviewVerdict {
5747 kind: "failed".into(),
5748 text: "bifrost exited with 1".into(),
5749 allowed: vec!["dismiss".into(), "cancel".into()],
5750 }),
5751 });
5752 validate_action(&resolve("dismiss"), &snapshot).unwrap();
5753 assert_eq!(
5754 validate_action(&resolve("forward"), &snapshot)
5755 .unwrap_err()
5756 .status,
5757 StatusCode::BAD_REQUEST
5758 );
5759 assert_eq!(
5761 validate_action(&resolve("approve"), &snapshot)
5762 .unwrap_err()
5763 .status,
5764 StatusCode::BAD_REQUEST
5765 );
5766
5767 validate_action(
5769 &ControllerAction::StartReview {
5770 session_id: "session-1".into(),
5771 },
5772 &snapshot,
5773 )
5774 .unwrap();
5775 assert_eq!(
5776 validate_action(
5777 &ControllerAction::StartReview {
5778 session_id: "not-managed".into(),
5779 },
5780 &snapshot,
5781 )
5782 .unwrap_err()
5783 .status,
5784 StatusCode::NOT_FOUND
5785 );
5786 }
5787
5788 #[test]
5789 fn resume_action_refuses_a_target_the_session_cannot_use() {
5790 let (mut config, state) = sample_config_state();
5791 config.bundles.get_mut("hel").unwrap().repositories[0].local = None;
5794 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
5795 snapshot.workspaces.push(ViewerWorkspace {
5796 id: "workspace-1".into(),
5797 name: "One".into(),
5798 });
5799 assert_eq!(
5800 snapshot.sessions[0].incompatible_resume_targets,
5801 vec!["raw".to_owned()]
5802 );
5803
5804 let error = validate_action(
5805 &ControllerAction::Resume {
5806 session_id: "session-1".into(),
5807 workspace_id: "workspace-1".into(),
5808 profile_id: "codex-1".into(),
5809 target_id: "raw".into(),
5810 queue: ResumeQueueDisposition::Start,
5811 additional_mounts: None,
5812 resource_allocation: None,
5813 },
5814 &snapshot,
5815 )
5816 .unwrap_err();
5817
5818 assert_eq!(error.status, StatusCode::BAD_REQUEST);
5819 }
5820
5821 #[test]
5822 fn move_confirmation_requires_interruption_ack_and_an_explicit_queue_choice() {
5823 let (config, state) = sample_config_state();
5824 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
5825 snapshot.sessions[0].capabilities.move_session = true;
5826 let selection = MoveSelection {
5827 clear_resource_allocation: false,
5828 session_id: "session-1".into(),
5829 profile_id: Some("codex-1".into()),
5830 target_template_id: Some("podman".into()),
5831 additional_mounts: None,
5832 resource_allocation: None,
5833 };
5834 let preparation = MovePreparation {
5835 selection,
5836 source_profile_id: "codex-1".into(),
5837 source_target_template_id: "podman".into(),
5838 cross_harness: false,
5839 active: true,
5840 queued_commands: vec![hel::hel_state::MaterializedQueuedPrompt {
5841 command_id: "command-1".into(),
5842 kind: hel::hel_state::QueuedCommandKind::Prompt,
5843 content: vec![serde_json::json!({"type": "text", "text": "continue"})],
5844 queued_at_ms: 1,
5845 }],
5846 fingerprint: "fingerprint".into(),
5847 operation_id: "move-1".into(),
5848 };
5849 let request = |queue, acknowledge_interruption| MoveSessionRequest {
5850 preparation: preparation.clone(),
5851 queue,
5852 acknowledge_interruption,
5853 };
5854 assert_eq!(
5855 validate_action(
5856 &ControllerAction::Move {
5857 request: request(Some(ResumeQueueDisposition::Discard), false),
5858 },
5859 &snapshot,
5860 )
5861 .unwrap_err()
5862 .status,
5863 StatusCode::CONFLICT
5864 );
5865 assert_eq!(
5866 validate_action(
5867 &ControllerAction::Move {
5868 request: request(None, true),
5869 },
5870 &snapshot,
5871 )
5872 .unwrap_err()
5873 .status,
5874 StatusCode::BAD_REQUEST
5875 );
5876 validate_action(
5877 &ControllerAction::Move {
5878 request: request(Some(ResumeQueueDisposition::Discard), true),
5879 },
5880 &snapshot,
5881 )
5882 .unwrap();
5883 }
5884
5885 #[tokio::test]
5886 async fn snapshot_endpoint_returns_only_public_projection() {
5887 let (app, _, _, _, _) = app();
5888 let cookie = login_cookie(&app).await;
5889 let response = app
5890 .oneshot(
5891 Request::get("/api/snapshot")
5892 .header(COOKIE, cookie)
5893 .body(Body::empty())
5894 .unwrap(),
5895 )
5896 .await
5897 .unwrap();
5898 let body = response.into_body().collect().await.unwrap().to_bytes();
5899 let body = String::from_utf8(body.to_vec()).unwrap();
5900 assert!(body.contains("session-1"));
5901 assert!(!body.contains("secret-token"));
5902 assert!(!body.contains("native-secret-id"));
5903 assert!(!body.contains("/private/source/hel"));
5904
5905 let snapshot: serde_json::Value = serde_json::from_str(&body).unwrap();
5906 let repository = &snapshot["bundles"][0]["repositories"][0];
5907 assert_eq!(repository["id"], "hel");
5908 assert_eq!(repository["github"], "owner/hel");
5909 assert_eq!(repository["destination"], "hel");
5910 assert!(repository.get("local").is_none());
5911 }
5912
5913 #[tokio::test]
5914 async fn snapshot_clock_anchor_is_fresh_even_when_the_projection_has_not_changed() {
5915 let (app, _, _, _, _) = app_with_snapshot(|snapshot| snapshot.server_time_ms = 1);
5916 let cookie = login_cookie(&app).await;
5917 for _ in 0..2 {
5918 let before = hel::clock::epoch_millis();
5919 let response = app
5920 .clone()
5921 .oneshot(
5922 Request::get("/api/snapshot")
5923 .header(COOKIE, &cookie)
5924 .body(Body::empty())
5925 .unwrap(),
5926 )
5927 .await
5928 .unwrap();
5929 let body = response.into_body().collect().await.unwrap().to_bytes();
5930 let snapshot: ViewerSnapshot = serde_json::from_slice(&body).unwrap();
5931 assert!(snapshot.server_time_ms >= before);
5932 assert!(snapshot.server_time_ms <= hel::clock::epoch_millis());
5933 }
5934 }
5935
5936 #[tokio::test]
5937 async fn conversation_endpoint_returns_authenticated_bounded_deltas() {
5938 let transcript = BrowserTranscript {
5939 latest_seq: 8,
5940 window_start_seq: 3,
5941 reset: false,
5942 entries: vec![
5943 BrowserTranscriptEntry {
5944 id: 3,
5945 updated_seq: 3,
5946 role: "user",
5947 label: "You".into(),
5948 recorded_at_ms: None,
5949 lines: vec!["begin".into()],
5950 glyph: "\u{276f}",
5951 tone: "user",
5952 tool_status: None,
5953 diffstats: Vec::new(),
5954 },
5955 BrowserTranscriptEntry {
5956 id: 7,
5957 updated_seq: 8,
5958 role: "agent",
5959 label: "Agent".into(),
5960 recorded_at_ms: None,
5961 lines: vec!["live".into()],
5962 glyph: "\u{25cf}",
5963 tone: "agent",
5964 tool_status: None,
5965 diffstats: Vec::new(),
5966 },
5967 ],
5968 };
5969 let (app, _, _, _, _) =
5970 app_with_conversations(BTreeMap::from([("session-1".into(), transcript)]));
5971 let cookie = login_cookie(&app).await;
5972 let response = app
5973 .oneshot(
5974 Request::get("/api/conversations/session-1?after_seq=3")
5975 .header(COOKIE, cookie)
5976 .body(Body::empty())
5977 .unwrap(),
5978 )
5979 .await
5980 .unwrap();
5981 assert_eq!(response.status(), StatusCode::OK);
5982 let body = response.into_body().collect().await.unwrap().to_bytes();
5983 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
5984 assert_eq!(body["latest_seq"], 8);
5985 assert_eq!(body["reset"], false);
5986 assert_eq!(body["entries"].as_array().unwrap().len(), 1);
5987 assert_eq!(body["entries"][0]["lines"][0], "live");
5988 }
5989
5990 #[tokio::test]
5991 async fn conversation_endpoint_rejects_cached_transcript_during_transition() {
5992 let transcript = BrowserTranscript {
5993 latest_seq: 1,
5994 window_start_seq: 1,
5995 reset: false,
5996 entries: vec![BrowserTranscriptEntry {
5997 id: 1,
5998 updated_seq: 1,
5999 role: "agent",
6000 label: "Agent".into(),
6001 recorded_at_ms: None,
6002 lines: vec!["stale".into()],
6003 glyph: "●",
6004 tone: "agent",
6005 tool_status: None,
6006 diffstats: Vec::new(),
6007 }],
6008 };
6009 let (app, _, _, _, _) = app_with(
6010 BTreeMap::from([("session-1".into(), transcript)]),
6011 |snapshot| snapshot.sessions[0].transitioning = true,
6012 );
6013 let cookie = login_cookie(&app).await;
6014 let response = app
6015 .oneshot(
6016 Request::get("/api/conversations/session-1")
6017 .header(COOKIE, cookie)
6018 .body(Body::empty())
6019 .unwrap(),
6020 )
6021 .await
6022 .unwrap();
6023 assert_eq!(response.status(), StatusCode::CONFLICT);
6024 }
6025
6026 #[tokio::test]
6027 async fn conversation_read_receipt_never_contends_with_a_running_action() {
6028 let (app, mut actions, mut receipts, _, _) = app();
6029 let cookie = login_cookie(&app).await;
6030 let prompt = tokio::spawn(
6034 app.clone().oneshot(
6035 Request::post("/api/actions")
6036 .header(COOKIE, cookie.clone())
6037 .header(CONTENT_TYPE, "application/json")
6038 .body(Body::from(
6039 r#"{"action":"prompt","session_id":"session-1","text":"ship it"}"#,
6040 ))
6041 .unwrap(),
6042 ),
6043 );
6044 let action = actions.recv().await.unwrap();
6045
6046 let response = tokio::spawn(
6047 app.oneshot(
6048 Request::post("/api/conversations/session-1/read")
6049 .header(COOKIE, cookie)
6050 .header(CONTENT_TYPE, "application/json")
6051 .body(Body::from(r#"{"through":42}"#))
6052 .unwrap(),
6053 ),
6054 );
6055 let receipt = receipts.recv().await.unwrap();
6056 assert_eq!(receipt.session_id, "session-1");
6057 assert_eq!(receipt.through, 42);
6058 receipt.reply.send(Ok(())).unwrap();
6059 assert_eq!(
6060 response.await.unwrap().unwrap().status(),
6061 StatusCode::NO_CONTENT
6062 );
6063 assert!(
6064 actions.try_recv().is_err(),
6065 "a read receipt must not queue a controller action"
6066 );
6067
6068 action.reply.send(ActionOutcome::Accepted).unwrap();
6069 assert_eq!(
6070 prompt.await.unwrap().unwrap().status(),
6071 StatusCode::ACCEPTED
6072 );
6073 }
6074
6075 #[tokio::test]
6076 async fn each_rejected_action_keeps_its_own_status_and_guidance() {
6077 for (outcome, status, guidance) in [
6078 (
6079 ActionOutcome::Busy,
6080 StatusCode::TOO_MANY_REQUESTS,
6081 "concurrent action limit",
6082 ),
6083 (
6084 ActionOutcome::SessionBusy,
6085 StatusCode::CONFLICT,
6086 "another operation is already running",
6087 ),
6088 (
6089 ActionOutcome::NotCancellable,
6090 StatusCode::CONFLICT,
6091 "no cancellable operation",
6092 ),
6093 (
6094 ActionOutcome::Failed,
6095 StatusCode::INTERNAL_SERVER_ERROR,
6096 "could not start this action",
6097 ),
6098 ] {
6099 let (app, mut actions, _, _, _) = app();
6100 let cookie = login_cookie(&app).await;
6101 let response = tokio::spawn(
6102 app.oneshot(
6103 Request::post("/api/actions")
6104 .header(COOKIE, cookie)
6105 .header(CONTENT_TYPE, "application/json")
6106 .body(Body::from(r#"{"action":"close","session_id":"session-1"}"#))
6107 .unwrap(),
6108 ),
6109 );
6110 let request = actions.recv().await.unwrap();
6111 request.reply.send(outcome).unwrap();
6112
6113 let response = response.await.unwrap().unwrap();
6114 assert_eq!(response.status(), status, "{outcome:?}");
6115 let body = response.into_body().collect().await.unwrap().to_bytes();
6116 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
6117 let error = body["error"].as_str().unwrap();
6118 assert!(error.contains(guidance), "{outcome:?} answered {error:?}");
6119 }
6120 }
6121
6122 #[tokio::test]
6123 async fn the_viewer_shows_a_session_whose_action_failed_after_it_was_accepted() {
6124 let (app, _, _, _, _) = app();
6128 let script = fetch_text(app, "/viewer.js").await;
6129 assert!(script.contains("has_error"), "viewer ignores has_error");
6130 }
6131
6132 #[tokio::test]
6135 async fn every_response_carries_the_security_headers() {
6136 for path in [
6137 "/",
6138 "/viewer.js",
6139 "/viewer.css",
6140 "/manifest.webmanifest",
6141 "/api/snapshot",
6142 ] {
6143 let (app, _, _, _, _) = app();
6144 let response = app
6145 .oneshot(Request::get(path).body(Body::empty()).unwrap())
6146 .await
6147 .unwrap();
6148 let headers = response.headers();
6149 let policy = headers
6150 .get(CONTENT_SECURITY_POLICY_HEADER)
6151 .unwrap_or_else(|| panic!("{path} carries no content-security policy"))
6152 .to_str()
6153 .unwrap();
6154 assert!(
6155 policy.starts_with("default-src 'none';"),
6156 "{path} does not refuse unlisted sources: {policy}"
6157 );
6158 assert!(
6159 policy.contains("script-src 'self'") && !policy.contains("unsafe-inline"),
6160 "{path} permits inline script: {policy}"
6161 );
6162 assert!(
6163 policy.contains("frame-ancestors 'none'"),
6164 "{path} can be framed: {policy}"
6165 );
6166 assert_eq!(
6167 headers.get(X_CONTENT_TYPE_OPTIONS).unwrap(),
6168 "nosniff",
6169 "{path} permits content sniffing"
6170 );
6171 assert_eq!(
6172 headers.get(REFERRER_POLICY).unwrap(),
6173 "no-referrer",
6174 "{path} leaks a referrer"
6175 );
6176 }
6177 }
6178
6179 #[tokio::test]
6183 async fn the_page_carries_no_inline_script_or_style() {
6184 let (app, _, _, _, _) = app();
6185 let page = fetch_text(app, "/").await;
6186 assert!(
6187 !page.contains("<script>") && !page.contains("<style>"),
6188 "the page inlines script or style, which the policy blocks"
6189 );
6190 assert!(
6191 page.contains(r#"src="/viewer.js""#) && page.contains(r#"href="/viewer.css""#),
6192 "the page does not load its script and style as separate assets"
6193 );
6194 }
6195
6196 #[tokio::test]
6199 async fn live_state_and_the_service_worker_are_never_stored() {
6200 for path in ["/", "/service-worker.js", "/api/snapshot"] {
6201 let (app, _, _, _, _) = app();
6202 let response = app
6203 .oneshot(Request::get(path).body(Body::empty()).unwrap())
6204 .await
6205 .unwrap();
6206 assert_eq!(
6207 response.headers().get(CACHE_CONTROL).unwrap(),
6208 "no-store",
6209 "{path} may be stored"
6210 );
6211 }
6212 }
6213
6214 #[test]
6217 fn the_service_worker_declines_to_handle_live_state() {
6218 assert!(
6219 SERVICE_WORKER.contains("url.pathname.startsWith('/api/')"),
6220 "the service worker does not exclude the API"
6221 );
6222 assert!(
6223 SERVICE_WORKER.contains("url.pathname.startsWith('/auth/')"),
6224 "the service worker does not exclude authentication"
6225 );
6226 assert!(
6227 SERVICE_WORKER.contains("caches.delete"),
6228 "the service worker never deletes a superseded cache"
6229 );
6230 }
6231
6232 #[tokio::test]
6235 async fn the_installable_assets_are_served() {
6236 for (path, content_type) in [
6237 ("/icon-192.png", "image/png"),
6238 ("/icon-512.png", "image/png"),
6239 ("/maskable-512.png", "image/png"),
6240 ("/apple-touch-icon.png", "image/png"),
6241 ("/fonts/jetbrains-mono.woff2", "font/woff2"),
6242 ] {
6243 let (app, _, _, _, _) = app();
6244 let response = app
6245 .oneshot(Request::get(path).body(Body::empty()).unwrap())
6246 .await
6247 .unwrap();
6248 assert_eq!(response.status(), StatusCode::OK, "{path} is not served");
6249 assert_eq!(
6250 response.headers().get(CONTENT_TYPE).unwrap(),
6251 content_type,
6252 "{path} is served as the wrong type"
6253 );
6254 }
6255 }
6256
6257 async fn fetch_text(app: Router, path: &str) -> String {
6261 let response = app
6262 .oneshot(Request::get(path).body(Body::empty()).unwrap())
6263 .await
6264 .unwrap();
6265 assert_eq!(response.status(), StatusCode::OK, "{path} is not served");
6266 let body = response.into_body().collect().await.unwrap().to_bytes();
6267 String::from_utf8(body.to_vec()).expect("assets are UTF-8")
6268 }
6269
6270 #[tokio::test]
6271 async fn repeated_wrong_codes_lock_the_login_endpoint() {
6272 let (app, _, _, _, _) = app();
6273 let attempt = |code: &'static str| {
6274 let app = app.clone();
6275 async move {
6276 app.oneshot(
6277 Request::post("/auth/session")
6278 .header(CONTENT_TYPE, "application/json")
6279 .body(Body::from(format!(r#"{{"code":"{code}"}}"#)))
6280 .unwrap(),
6281 )
6282 .await
6283 .unwrap()
6284 .status()
6285 }
6286 };
6287 for _ in 0..MAX_CODE_FAILURES {
6288 assert_eq!(attempt("000000").await, StatusCode::UNAUTHORIZED);
6289 }
6290 assert_eq!(attempt("000000").await, StatusCode::TOO_MANY_REQUESTS);
6291 assert_eq!(attempt("123456").await, StatusCode::TOO_MANY_REQUESTS);
6294 }
6295
6296 #[test]
6297 fn viewer_code_lockouts_lengthen_instead_of_resetting_after_every_wait() {
6298 let serve_one_lockout = |guard: &mut CodeGuard, now: Instant| {
6299 for _ in 0..MAX_CODE_FAILURES {
6300 assert!(!guard.locked_at(now));
6301 guard.record_failure_at(now);
6302 }
6303 assert!(guard.locked_at(now));
6304 guard.locked_until.expect("the guard is locked") - now
6305 };
6306
6307 let start = Instant::now();
6308 let mut guard = CodeGuard::default();
6309 let first = serve_one_lockout(&mut guard, start);
6310 assert_eq!(first, CODE_LOCKOUT_BASE);
6311
6312 let second_round = start + first;
6316 let second = serve_one_lockout(&mut guard, second_round);
6317 assert_eq!(second, CODE_LOCKOUT_BASE * 2);
6318 let third = serve_one_lockout(&mut guard, second_round + second);
6319 assert_eq!(third, CODE_LOCKOUT_BASE * 4);
6320 assert_eq!(code_lockout(u32::MAX), CODE_LOCKOUT_CAP);
6321
6322 let mut recovered = CodeGuard::default();
6325 assert_eq!(serve_one_lockout(&mut recovered, start), CODE_LOCKOUT_BASE);
6326 }
6327
6328 #[test]
6329 fn persisted_cookie_key_survives_a_restart_and_stays_owner_only() {
6330 let directory = tempfile::tempdir().unwrap();
6331 let path = directory.path().join("phone-cookie-key");
6332
6333 let first = load_or_create_cookie_key(&path).unwrap();
6334 assert!(first.len() >= COOKIE_KEY_BYTES);
6335 assert_eq!(std::fs::read(&path).unwrap(), first);
6336 #[cfg(unix)]
6337 {
6338 use std::os::unix::fs::PermissionsExt as _;
6339 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
6340 assert_eq!(mode & 0o777, 0o600);
6341 }
6342
6343 let mut restarted = detached_options();
6346 restarted
6347 .set_cookie_key(load_or_create_cookie_key(&path).unwrap())
6348 .unwrap();
6349 let mut original = detached_options();
6350 original.set_cookie_key(first.clone()).unwrap();
6351 let cookie = signed_cookie_value(&original.cookie_key, "test-viewer", 200);
6352 assert!(session_cookie_valid(&restarted.cookie_key, &cookie, 100));
6353 assert!(!session_cookie_valid(
6354 &detached_options().cookie_key,
6355 &cookie,
6356 100
6357 ));
6358
6359 std::fs::remove_file(&path).unwrap();
6361 let rotated = load_or_create_cookie_key(&path).unwrap();
6362 assert_ne!(rotated, first);
6363 assert!(!session_cookie_valid(&rotated, &cookie, 100));
6364 }
6365
6366 #[test]
6367 fn corrupt_cookie_key_is_regenerated_instead_of_blocking_startup() {
6368 let directory = tempfile::tempdir().unwrap();
6369 let path = directory.path().join("phone-cookie-key");
6370 std::fs::write(&path, b"short").unwrap();
6371
6372 let key = load_or_create_cookie_key(&path).unwrap();
6373
6374 assert!(key.len() >= COOKIE_KEY_BYTES);
6375 assert_eq!(std::fs::read(&path).unwrap(), key);
6376 assert_eq!(load_or_create_cookie_key(&path).unwrap(), key);
6377 }
6378}