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