1use std::collections::BTreeMap;
8use std::convert::Infallible;
9use std::net::SocketAddr;
10use std::path::{Component, PathBuf};
11use std::sync::{Arc, Mutex};
12use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
13
14use anyhow::{Context, Result as AnyResult};
15use axum::body::{Body, Bytes, to_bytes};
16use axum::extract::{DefaultBodyLimit, Path, Query, Request, State};
17use axum::http::header::{
18 CACHE_CONTROL, CONTENT_SECURITY_POLICY as CONTENT_SECURITY_POLICY_HEADER, CONTENT_TYPE, COOKIE,
19 HeaderValue, LOCATION, REFERRER_POLICY, SET_COOKIE, X_CONTENT_TYPE_OPTIONS,
20};
21use axum::http::{HeaderMap, Response, StatusCode};
22use axum::middleware::Next;
23use axum::response::IntoResponse;
24use axum::response::sse::{Event, KeepAlive, Sse};
25use axum::routing::{get, post, put};
26use axum::{Json, Router};
27use base64::Engine as _;
28use hmac::{Hmac, Mac};
29use serde::{Deserialize, Serialize};
30use sha2::Sha256;
31use tokio::sync::Semaphore;
32use tokio::sync::{mpsc, watch};
33use tokio_stream::wrappers::ReceiverStream;
34use tokio_util::sync::CancellationToken;
35
36use hel::hel_attachment::{AttachmentRef, AttachmentStore, MAX_IMAGE_BYTES, MAX_IMAGES};
37use hel::hel_config::{HelConfig, TargetTemplate, project_history_host, validate_id};
38use hel::hel_elicitation::{ElicitationRequest, ElicitationResponse, MAX_ELICITATION_BYTES};
39use hel::hel_state::{
40 HelState, MoveOperation, MovePhase, MovePreparation, MoveSelection, MoveSessionRequest,
41 ProjectSourceIdentity, SessionResourceAllocation, SessionState, SessionTransitionKind,
42};
43use hel::hel_targets::AdditionalMount;
44
45use crate::hel_dictation::{
46 DictationError, DictationOperation, DictationRequest, DictationResponse, MAX_AUDIO_BYTES,
47 validate_wav,
48};
49use crate::hel_image::optimize_image;
50
51pub use mj_client::web::{
52 BrowserDiffStat, BrowserTranscript, BrowserTranscriptEntry, WebListenerProcess,
53 WebViewerAccess, WebViewerRecovery,
54};
55
56pub use hel::hel_state::ResumeQueueDisposition;
60
61pub fn install_rustls_crypto_provider() {
68 let _ = rustls::crypto::ring::default_provider().install_default();
69}
70
71pub const COOKIE_NAME: &str = "hel_viewer_session";
72const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60);
73const EPHEMERAL_SESSION_TTL: Duration = Duration::from_secs(24 * 60 * 60);
74const MAX_BODY_BYTES: usize = 128 * 1024;
75const MAX_CODE_FAILURES: u32 = 5;
76const CODE_LOCKOUT_BASE: Duration = Duration::from_secs(30);
77const CODE_LOCKOUT_CAP: Duration = Duration::from_secs(60 * 60);
78const MAX_TITLE_CHARS: usize = 120;
79const MAX_PROMPT_CHARS: usize = 64 * 1024;
80const MAX_DIRTY_ACKNOWLEDGEMENTS: usize = 32;
83const MAX_DRAFT_BYTES: usize = 64 * 1024;
87pub const MAX_HISTORY_MATCHES: usize = 40;
91const MAX_PROMPT_BODY_BYTES: usize = 32 * 1024 * 1024;
96const MAX_ATTACHMENT_UPLOAD_BYTES: usize = 64 * 1024 * 1024;
100const MAX_CONCURRENT_DICTATIONS: usize = 2;
104pub const MAX_PROMPT_IMAGES: usize = MAX_IMAGES;
106const COOKIE_KEY_BYTES: usize = 32;
107const COOKIE_KEY_FILE: &str = "phone-cookie-key";
108
109pub const fn default_session_ttl() -> Duration {
114 DEFAULT_SESSION_TTL
115}
116
117pub fn cookie_key_path() -> PathBuf {
118 hel::hel_config::data_dir().join(COOKIE_KEY_FILE)
119}
120
121pub fn load_or_create_cookie_key(path: &std::path::Path) -> AnyResult<Vec<u8>> {
131 match std::fs::read(path) {
132 Ok(key) if key.len() >= COOKIE_KEY_BYTES => return Ok(key),
133 Ok(key) => tracing::warn!(
134 path = %path.display(),
135 bytes = key.len(),
136 "phone cookie key is shorter than {COOKIE_KEY_BYTES} bytes; generating a new key signs every phone out"
137 ),
138 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
139 Err(error) => tracing::warn!(
140 path = %path.display(),
141 "could not read the phone cookie key ({error}); generating a new key signs every phone out"
142 ),
143 }
144 let key = generate_cookie_key()?;
145 hel::hel_config::atomic_write(path, &key)
146 .with_context(|| format!("persist Mjolnir phone cookie key {}", path.display()))?;
147 Ok(key.to_vec())
148}
149
150#[derive(Clone)]
158pub struct ServerOptions {
159 pub bind: SocketAddr,
160 pub snapshot_rx: watch::Receiver<ViewerSnapshot>,
161 pub conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
162 pub action_tx: mpsc::Sender<ControllerRequest>,
163 pub bundle_tx: mpsc::Sender<BundleRequest>,
164 pub receipt_tx: mpsc::Sender<ReadReceiptRequest>,
165 pub preflight_tx: mpsc::Sender<PreflightRequest>,
166 pub move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
167 pub client_state_tx: mpsc::Sender<ClientStateRequest>,
168 pub dictation_tx: mpsc::Sender<DictationRequest>,
169 background_task_stop_tx: mpsc::Sender<BackgroundTaskStopRequest>,
173 pub shutdown: CancellationToken,
174 pub session_ttl: Duration,
175 pub secure_cookie: bool,
178 tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
179 viewer_code: String,
180 login_token: String,
181 cookie_key: Vec<u8>,
182}
183
184pub struct ServerRequests {
186 pub action_tx: mpsc::Sender<ControllerRequest>,
187 pub bundle_tx: mpsc::Sender<BundleRequest>,
188 pub receipt_tx: mpsc::Sender<ReadReceiptRequest>,
189 pub preflight_tx: mpsc::Sender<PreflightRequest>,
190 pub move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
191 pub client_state_tx: mpsc::Sender<ClientStateRequest>,
192 pub dictation_tx: mpsc::Sender<DictationRequest>,
193}
194
195impl ServerOptions {
196 pub fn new(
197 bind: SocketAddr,
198 snapshot_rx: watch::Receiver<ViewerSnapshot>,
199 conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
200 requests: ServerRequests,
201 ) -> AnyResult<Self> {
202 Ok(Self {
203 bind,
204 snapshot_rx,
205 conversation_rx,
206 action_tx: requests.action_tx,
207 bundle_tx: requests.bundle_tx,
208 receipt_tx: requests.receipt_tx,
209 preflight_tx: requests.preflight_tx,
210 move_preparation_tx: requests.move_preparation_tx,
211 client_state_tx: requests.client_state_tx,
212 dictation_tx: requests.dictation_tx,
213 background_task_stop_tx: mpsc::channel(1).0,
214 shutdown: CancellationToken::new(),
215 session_ttl: DEFAULT_SESSION_TTL,
216 secure_cookie: true,
217 tls_config: None,
218 viewer_code: generate_viewer_code()?,
219 login_token: generate_login_token()?,
220 cookie_key: generate_cookie_key()?.to_vec(),
221 })
222 }
223
224 pub fn viewer_code(&self) -> &str {
225 &self.viewer_code
226 }
227
228 pub fn login_token(&self) -> &str {
229 &self.login_token
230 }
231
232 pub fn set_tls_config(&mut self, config: axum_server::tls_rustls::RustlsConfig) {
236 self.tls_config = Some(config);
237 self.secure_cookie = true;
238 }
239
240 pub fn set_cookie_key(&mut self, key: Vec<u8>) -> AnyResult<()> {
243 anyhow::ensure!(
244 key.len() >= COOKIE_KEY_BYTES,
245 "cookie signing key must be at least {COOKIE_KEY_BYTES} bytes"
246 );
247 self.cookie_key = key;
248 Ok(())
249 }
250
251 pub fn set_background_task_stop_tx(&mut self, tx: mpsc::Sender<BackgroundTaskStopRequest>) {
253 self.background_task_stop_tx = tx;
254 }
255
256 #[cfg(test)]
257 fn with_test_credentials(mut self, code: &str, key: &[u8]) -> Self {
258 self.viewer_code = code.to_string();
259 self.login_token = "test-login-token".into();
260 self.cookie_key = key.to_vec();
261 self.secure_cookie = false;
262 self
263 }
264}
265
266pub async fn run_server(options: ServerOptions) -> AnyResult<()> {
272 let listener = tokio::net::TcpListener::bind(options.bind)
273 .await
274 .with_context(|| format!("bind web viewer to {}", options.bind))?;
275 run_server_on_listener(options, listener).await
276}
277
278pub async fn run_server_on_listener(
280 options: ServerOptions,
281 listener: tokio::net::TcpListener,
282) -> AnyResult<()> {
283 let mut options = options;
284 let bind = listener.local_addr().context("read web viewer address")?;
285 let shutdown = options.shutdown.clone();
286 let viewer_code = options.viewer_code.clone();
287 let tls_config = options.tls_config.take();
288 let app = router(options);
289 println!("Mjolnir viewer code: {viewer_code}");
290 let listener = listener.into_std().context("prepare web viewer listener")?;
291 let handle = axum_server::Handle::new();
292 let shutdown_handle = handle.clone();
293 let serve = async move {
294 if let Some(tls_config) = tls_config {
295 axum_server::from_tcp_rustls(listener, tls_config)
296 .handle(handle)
297 .serve(app.into_make_service())
298 .await
299 } else {
300 axum_server::from_tcp(listener)
301 .handle(handle)
302 .serve(app.into_make_service())
303 .await
304 }
305 };
306 tokio::pin!(serve);
307 tokio::select! {
308 result = &mut serve => result,
309 _ = shutdown.cancelled() => {
310 shutdown_handle.graceful_shutdown(Some(Duration::from_secs(2)));
311 serve.await
312 }
313 }
314 .with_context(|| format!("serve web viewer on {bind}"))
315}
316
317#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
318#[serde(deny_unknown_fields)]
319pub struct ViewerSnapshot {
320 pub revision: u64,
321 pub generated_at: String,
322 #[serde(default)]
325 pub server_time_ms: i64,
326 #[serde(default, skip_serializing_if = "Vec::is_empty")]
327 pub workspaces: Vec<ViewerWorkspace>,
328 pub sessions: Vec<ViewerSession>,
329 pub profiles: Vec<ViewerProfile>,
330 pub targets: Vec<ViewerTarget>,
331 pub bundles: Vec<ViewerBundle>,
332 #[serde(default)]
335 pub review_config: ViewerReviewConfig,
336 #[serde(default, skip_serializing_if = "Vec::is_empty")]
339 pub capacity: Vec<ViewerTargetCapacity>,
340 #[serde(default, skip_serializing_if = "Vec::is_empty")]
342 pub launch_failures: Vec<ViewerLaunchFailure>,
343}
344
345#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
347pub struct ViewerLaunchFailure {
348 pub id: String,
349 pub workspace_id: String,
350}
351
352impl ViewerSnapshot {
353 pub fn from_config_state(config: &HelConfig, state: &HelState, revision: u64) -> Self {
357 let sessions = state
358 .sessions
359 .values()
360 .map(|session| {
361 let incompatible = config
362 .targets
363 .keys()
364 .filter(|target_id| {
365 crate::hel_controller::resume_compatibility(session, config, target_id)
366 .is_err()
367 })
368 .cloned()
369 .collect::<Vec<_>>();
370 let lifecycle = ViewerLifecycleCategory::of(session.state);
371 let source = session.project_source(config);
372 ViewerSession {
373 id: session.id.clone(),
374 workspace_id: session.workspace_id.clone(),
375 title: session.display_title().to_owned(),
376 harness_kind: session.harness_kind.id().into(),
377 profile_id: session.last_profile.clone(),
378 bundle_id: session.bundle_id.clone(),
379 target_id: session.target_template_id.clone(),
380 state: session_state_name(session.state).into(),
381 created_at: session.created_at.clone(),
382 updated_at: session.updated_at.clone(),
383 has_error: session.last_error.is_some(),
384 preview: Vec::new(),
385 queued_prompts: Vec::new(),
386 active_user_shells: Vec::new(),
387 background_tasks: Vec::new(),
388 pending_elicitations: Vec::new(),
389 conversation_available: false,
390 prompt_images_supported: false,
391 incompatible_resume_targets: incompatible.clone(),
392 compatible_resume_targets: config
393 .targets
394 .keys()
395 .filter(|target_id| !incompatible.contains(*target_id))
396 .cloned()
397 .collect(),
398 project_label: source.short,
399 project_key: project_key(&source.key),
400 display_location: session.project_target(config, &session.target_template_id),
401 lifecycle,
402 transitioning: session.state.transition_kind().is_some(),
403 latest_event_ordinal: 0,
404 last_activity_at_ms: None,
405 activity_details: None,
406 activity: String::new(),
407 operation: None,
408 move_recovery: None,
409 chat_phase: ViewerChatPhase::default(),
410 is_idle: false,
411 config_options: Vec::new(),
412 plan_mode_active: None,
413 turn_review: None,
414 available_commands: Vec::new(),
415 capabilities: ViewerSessionCapabilities {
419 open: false,
420 prompt: false,
421 run_shell: false,
422 cancel_turn: false,
423 cancel_operation: false,
424 stop: lifecycle.is_dashboard_visible(),
425 rename: true,
426 resume: !lifecycle.is_dashboard_visible(),
427 move_session: false,
428 set_config: false,
429 set_plan_mode: false,
430 },
431 }
432 })
433 .collect();
434 let profiles = config
435 .enabled_profiles()
436 .map(|(id, profile)| ViewerProfile {
437 id: id.to_owned(),
438 harness_kind: profile.kind.id().into(),
439 quota: None,
440 })
441 .collect();
442 let targets = config
443 .targets
444 .iter()
445 .map(|(id, target)| ViewerTarget {
446 id: id.clone(),
447 kind: target_kind_name(target).into(),
448 requires_project_directory: matches!(
449 target,
450 TargetTemplate::LocalBare | TargetTemplate::SshBare { .. }
451 ),
452 recent_project_directories: project_history_host(target)
453 .map(|host| {
454 state
455 .project_directories(host)
456 .iter()
457 .map(|directory| directory.to_string_lossy().into_owned())
458 .collect()
459 })
460 .unwrap_or_default(),
461 })
462 .collect();
463 let bundles = config
464 .bundles
465 .iter()
466 .map(|(id, bundle)| ViewerBundle {
467 id: id.clone(),
468 primary_repository: bundle.primary_repo.clone(),
469 repositories: bundle
470 .repositories
471 .iter()
472 .map(|repository| ViewerRepository {
473 id: repository.id.clone(),
474 github: repository.github.clone(),
475 destination: repository.destination.to_string_lossy().into_owned(),
476 })
477 .collect(),
478 })
479 .collect();
480 Self {
481 revision,
482 generated_at: now_unix().to_string(),
483 server_time_ms: hel::clock::epoch_millis(),
484 workspaces: Vec::new(),
485 sessions,
486 profiles,
487 targets,
488 bundles,
489 review_config: ViewerReviewConfig {
490 enabled: config.review.enabled,
491 tier: config.review.tier.label().to_owned(),
492 profile: config.review.profile.clone(),
493 },
494 capacity: Vec::new(),
495 launch_failures: Vec::new(),
496 }
497 }
498}
499
500fn project_key(identity: &str) -> String {
507 use sha2::Digest as _;
508 let digest = Sha256::digest(identity.as_bytes());
509 digest[..8]
510 .iter()
511 .map(|byte| format!("{byte:02x}"))
512 .collect()
513}
514
515#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
516#[serde(deny_unknown_fields)]
517pub struct ViewerSession {
518 pub id: String,
519 #[serde(default, skip_serializing_if = "String::is_empty")]
520 pub workspace_id: String,
521 pub title: String,
522 pub harness_kind: String,
523 pub profile_id: String,
524 pub bundle_id: String,
525 pub target_id: String,
526 pub state: String,
527 pub created_at: String,
528 pub updated_at: String,
529 pub has_error: bool,
530 #[serde(default, skip_serializing_if = "Vec::is_empty")]
531 pub preview: Vec<String>,
532 #[serde(default, skip_serializing_if = "Vec::is_empty")]
533 pub queued_prompts: Vec<ViewerQueuedPrompt>,
534 #[serde(default, skip_serializing_if = "Vec::is_empty")]
535 pub active_user_shells: Vec<ViewerUserShell>,
536 #[serde(default, skip_serializing_if = "Vec::is_empty")]
537 pub background_tasks: Vec<ViewerBackgroundTask>,
538 #[serde(default, skip_serializing_if = "Vec::is_empty")]
542 pub pending_elicitations: Vec<ElicitationRequest>,
543 pub conversation_available: bool,
544 #[serde(default)]
548 pub prompt_images_supported: bool,
549 #[serde(default, skip_serializing_if = "Vec::is_empty")]
556 pub incompatible_resume_targets: Vec<String>,
557 #[serde(default, skip_serializing_if = "Vec::is_empty")]
560 pub compatible_resume_targets: Vec<String>,
561 #[serde(default, skip_serializing_if = "String::is_empty")]
564 pub project_label: String,
565 #[serde(default, skip_serializing_if = "String::is_empty")]
569 pub project_key: String,
570 #[serde(default)]
573 pub display_location: String,
574 pub lifecycle: ViewerLifecycleCategory,
575 #[serde(default)]
579 pub transitioning: bool,
580 #[serde(default)]
584 pub latest_event_ordinal: u64,
585 #[serde(default, skip_serializing_if = "Option::is_none")]
589 pub last_activity_at_ms: Option<i64>,
590 #[serde(default, skip_serializing_if = "Option::is_none")]
593 pub activity_details: Option<ViewerActivityDetails>,
594 #[serde(default, skip_serializing_if = "Option::is_none")]
595 pub operation: Option<ViewerOperation>,
596 #[serde(default, skip_serializing_if = "Option::is_none")]
600 pub move_recovery: Option<ViewerMoveRecovery>,
601 #[serde(default)]
602 pub chat_phase: ViewerChatPhase,
603 #[serde(default)]
606 pub is_idle: bool,
607 #[serde(default, skip_serializing_if = "String::is_empty")]
610 pub activity: String,
611 #[serde(default, skip_serializing_if = "Vec::is_empty")]
613 pub config_options: Vec<ViewerConfigOption>,
614 #[serde(default, skip_serializing_if = "Option::is_none")]
616 pub plan_mode_active: Option<bool>,
617 #[serde(default, skip_serializing_if = "Option::is_none")]
620 pub turn_review: Option<ViewerTurnReview>,
621 #[serde(default, skip_serializing_if = "Vec::is_empty")]
625 pub available_commands: Vec<ViewerMjCommand>,
626 pub capabilities: ViewerSessionCapabilities,
627}
628
629#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
630#[serde(deny_unknown_fields)]
631pub struct ViewerMoveRecovery {
632 pub operation_id: String,
633 pub source_profile_id: String,
634 pub source_target_template_id: String,
635 pub destination_profile_id: String,
636 pub destination_target_template_id: String,
637 pub phase: String,
638 pub queue: String,
639 pub clear_resource_allocation: bool,
640 #[serde(default)]
643 pub source_additional_mounts: Vec<AdditionalMount>,
644 #[serde(default)]
645 pub source_resource_allocation: Option<SessionResourceAllocation>,
646 #[serde(default)]
649 pub destination_additional_mounts: Vec<AdditionalMount>,
650 #[serde(default)]
651 pub destination_resource_allocation: Option<SessionResourceAllocation>,
652 pub checkpoint_retained: bool,
653 pub destination_ready: bool,
654 pub queue_admission_started: bool,
655 pub queue_admission_finished: bool,
656}
657
658impl ViewerMoveRecovery {
659 #[must_use]
660 pub fn from_operation(operation: &MoveOperation) -> Option<Self> {
661 if matches!(operation.phase, MovePhase::Completed) {
662 return None;
663 }
664 Some(Self {
665 operation_id: operation.operation_id.clone(),
666 source_profile_id: operation.source_profile_id.clone(),
667 source_target_template_id: operation.source_target_template_id.clone(),
668 destination_profile_id: operation.selection.profile_id.clone().unwrap_or_default(),
669 destination_target_template_id: operation
670 .selection
671 .target_template_id
672 .clone()
673 .unwrap_or_default(),
674 phase: match operation.phase {
675 MovePhase::Preparing => "preparing",
676 MovePhase::ClosingSource => "closing_source",
677 MovePhase::ResumingDestination => "resuming_destination",
678 MovePhase::StartingQueue => "starting_queue",
679 MovePhase::Completed => "completed",
680 MovePhase::Failed => "failed",
681 MovePhase::Cancelled => "cancelled",
682 }
683 .into(),
684 queue: match operation.queue {
685 ResumeQueueDisposition::Start => "start",
686 ResumeQueueDisposition::Discard => "discard",
687 }
688 .into(),
689 clear_resource_allocation: operation.selection.clear_resource_allocation,
690 source_additional_mounts: operation.source_additional_mounts.clone(),
691 source_resource_allocation: operation.source_resource_allocation.clone(),
692 destination_additional_mounts: operation
693 .selection
694 .additional_mounts
695 .clone()
696 .unwrap_or_default(),
697 destination_resource_allocation: operation.selection.resource_allocation.clone(),
698 checkpoint_retained: operation.checkpoint.is_some(),
699 destination_ready: operation.destination_target.is_some()
700 && operation.destination_native_session_id.is_some(),
701 queue_admission_started: operation.queue_admission_started,
702 queue_admission_finished: operation.queue_admission_finished,
703 })
704 }
705}
706
707impl ViewerSession {
708 pub fn set_project_source(&mut self, source: &ProjectSourceIdentity) {
711 self.project_label = source.short.clone();
712 self.project_key = project_key(&source.key);
713 }
714}
715
716#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
720#[serde(deny_unknown_fields)]
721pub struct ViewerActivityDetails {
722 pub kind: ViewerActivityKind,
723 #[serde(default, skip_serializing_if = "Option::is_none")]
724 pub turn_started_at_ms: Option<i64>,
725 #[serde(default, skip_serializing_if = "Option::is_none")]
726 pub step_started_at_ms: Option<i64>,
727 #[serde(default, skip_serializing_if = "Option::is_none")]
728 pub background_started_at_ms: Option<i64>,
729 #[serde(default, skip_serializing_if = "Option::is_none")]
730 pub idle_since_ms: Option<i64>,
731 #[serde(default, skip_serializing_if = "Option::is_none")]
732 pub label: Option<String>,
733}
734
735#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
736#[serde(rename_all = "lowercase")]
737pub enum ViewerActivityKind {
738 Turn,
739 Step,
740 Background,
741 Idle,
742 Lifecycle,
743}
744
745#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
747#[serde(deny_unknown_fields)]
748pub struct ViewerMjCommand {
749 pub name: String,
750 pub description: String,
751 pub source: ViewerCommandSource,
754 #[serde(default, skip_serializing_if = "Option::is_none")]
756 pub argument: Option<String>,
757}
758
759#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
760#[serde(rename_all = "snake_case")]
761pub enum ViewerCommandSource {
762 Mj,
763 Agent,
764}
765
766#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
768#[serde(deny_unknown_fields)]
769pub struct ViewerReviewConfig {
770 pub enabled: bool,
771 pub tier: String,
772 #[serde(default, skip_serializing_if = "Option::is_none")]
773 pub profile: Option<String>,
774}
775
776#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
778#[serde(deny_unknown_fields)]
779pub struct ViewerTurnReview {
780 pub tier: String,
782 pub status: String,
784 #[serde(default, skip_serializing_if = "Vec::is_empty")]
786 pub roles: Vec<ViewerReviewRole>,
787 #[serde(default, skip_serializing_if = "Option::is_none")]
789 pub verdict: Option<ViewerReviewVerdict>,
790}
791
792#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
793#[serde(deny_unknown_fields)]
794pub struct ViewerReviewRole {
795 pub label: String,
796 pub state: String,
798}
799
800#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
801#[serde(deny_unknown_fields)]
802pub struct ViewerReviewVerdict {
803 pub kind: String,
805 pub text: String,
807 #[serde(default, skip_serializing_if = "Vec::is_empty")]
811 pub allowed: Vec<String>,
812}
813
814impl ViewerTurnReview {
815 #[must_use]
817 pub fn from_runtime(review: &crate::hel_review_host::RuntimeReviewView) -> Self {
818 Self {
819 tier: review.tier.label().to_owned(),
820 status: review.status.clone(),
821 roles: review
822 .roles
823 .iter()
824 .map(|role| ViewerReviewRole {
825 label: role.label.clone(),
826 state: role.state.label().to_owned(),
827 })
828 .collect(),
829 verdict: review.verdict.as_ref().map(|verdict| ViewerReviewVerdict {
830 kind: match verdict.kind {
831 crate::hel_review_host::VerdictKind::Clean => "clean",
832 crate::hel_review_host::VerdictKind::Findings => "findings",
833 crate::hel_review_host::VerdictKind::Failed => "failed",
834 }
835 .to_owned(),
836 text: verdict.text.clone(),
837 allowed: verdict
838 .allowed
839 .iter()
840 .filter_map(resolution_name)
841 .map(str::to_owned)
842 .collect(),
843 }),
844 }
845 }
846}
847
848#[must_use]
851pub fn resolution_name(resolution: &hel::hel_review::driver::Resolution) -> Option<&'static str> {
852 match resolution {
853 hel::hel_review::driver::Resolution::Forwarded => Some("forward"),
854 hel::hel_review::driver::Resolution::Dismissed => Some("dismiss"),
855 hel::hel_review::driver::Resolution::Cancelled => Some("cancel"),
856 hel::hel_review::driver::Resolution::NothingToReview
858 | hel::hel_review::driver::Resolution::CoverageStarted => None,
859 }
860}
861
862#[must_use]
864pub fn resolution_from_name(name: &str) -> Option<hel::hel_review::driver::Resolution> {
865 match name {
866 "forward" => Some(hel::hel_review::driver::Resolution::Forwarded),
867 "dismiss" => Some(hel::hel_review::driver::Resolution::Dismissed),
868 "cancel" => Some(hel::hel_review::driver::Resolution::Cancelled),
869 _ => None,
870 }
871}
872
873#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
874#[serde(deny_unknown_fields)]
875pub struct ViewerWorkspace {
876 pub id: String,
877 pub name: String,
878}
879
880#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
881#[serde(deny_unknown_fields)]
882pub struct ViewerQueuedPrompt {
883 pub id: String,
884 pub text: String,
885 pub created_at: String,
886}
887
888#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
889#[serde(deny_unknown_fields)]
890pub struct ViewerUserShell {
891 pub id: String,
892 pub command: String,
893 pub started_at_ms: Option<i64>,
894}
895
896#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
898#[serde(deny_unknown_fields)]
899pub struct ViewerBackgroundTask {
900 pub id: String,
901 pub command: String,
902 pub started_at_ms: i64,
903 pub can_stop: bool,
904}
905
906#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
907#[serde(deny_unknown_fields)]
908pub struct ViewerProfile {
909 pub id: String,
910 pub harness_kind: String,
911 #[serde(default, skip_serializing_if = "Option::is_none")]
912 pub quota: Option<ViewerQuota>,
913}
914
915#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
921#[serde(deny_unknown_fields)]
922pub struct ViewerQuotaWindow {
923 pub label: String,
924 #[serde(default, skip_serializing_if = "Option::is_none")]
925 pub percent_used: Option<u8>,
926 #[serde(default, skip_serializing_if = "Option::is_none")]
927 pub resets_at: Option<String>,
928 pub projects_exhaustion_before_reset: bool,
931}
932
933#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
934#[serde(deny_unknown_fields)]
935pub struct ViewerQuota {
936 pub summary: String,
939 #[serde(default, skip_serializing_if = "Vec::is_empty")]
940 pub windows: Vec<ViewerQuotaWindow>,
941 #[serde(default, skip_serializing_if = "Option::is_none")]
942 pub resets_at: Option<String>,
943 pub stale: bool,
944 #[serde(default)]
947 pub refreshed_at_epoch_seconds: u64,
948 pub has_error: bool,
951}
952
953#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
959#[serde(deny_unknown_fields)]
960pub struct ViewerTargetCapacity {
961 pub id: String,
962 pub label: String,
965 pub target_ids: Vec<String>,
966 #[serde(default, skip_serializing_if = "Option::is_none")]
967 pub cpu_percent: Option<u8>,
968 #[serde(default, skip_serializing_if = "Option::is_none")]
969 pub memory_used_bytes: Option<u64>,
970 #[serde(default, skip_serializing_if = "Option::is_none")]
971 pub memory_total_bytes: Option<u64>,
972 #[serde(default, skip_serializing_if = "Option::is_none")]
973 pub logical_cores: Option<u64>,
974 #[serde(default, skip_serializing_if = "Option::is_none")]
975 pub disk_total_bytes: Option<u64>,
976 #[serde(default, skip_serializing_if = "Option::is_none")]
978 pub virtual_machines: Option<u64>,
979 #[serde(default, skip_serializing_if = "Option::is_none")]
980 pub sampled_at_epoch_seconds: Option<u64>,
981 pub refreshing: bool,
982 pub stale: bool,
983 pub has_error: bool,
986}
987
988#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
989#[serde(deny_unknown_fields)]
990pub struct ViewerTarget {
991 pub id: String,
992 pub kind: String,
993 pub requires_project_directory: bool,
994 #[serde(default)]
998 pub recent_project_directories: Vec<String>,
999}
1000
1001#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1002#[serde(deny_unknown_fields)]
1003pub struct ViewerBundle {
1004 pub id: String,
1005 pub primary_repository: String,
1006 pub repositories: Vec<ViewerRepository>,
1007}
1008
1009#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1010#[serde(deny_unknown_fields)]
1011pub struct ViewerRepository {
1012 pub id: String,
1013 pub github: Option<String>,
1014 pub destination: String,
1015}
1016
1017#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1025#[serde(deny_unknown_fields)]
1026pub struct ViewerSessionCapabilities {
1027 pub open: bool,
1028 pub prompt: bool,
1029 pub run_shell: bool,
1030 pub cancel_turn: bool,
1032 pub cancel_operation: bool,
1034 pub stop: bool,
1035 pub rename: bool,
1036 pub resume: bool,
1037 #[serde(default)]
1040 pub move_session: bool,
1041 pub set_config: bool,
1042 pub set_plan_mode: bool,
1043}
1044
1045#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1051#[serde(rename_all = "kebab-case")]
1052pub enum ViewerLifecycleCategory {
1053 Live,
1054 Starting,
1055 Stopping,
1056 Stopped,
1057 Failed,
1058}
1059
1060impl ViewerLifecycleCategory {
1061 const fn of(state: SessionState) -> Self {
1062 match state {
1063 SessionState::Provisioning => Self::Starting,
1064 SessionState::Running | SessionState::Disconnected | SessionState::Checkpointing => {
1065 Self::Live
1066 }
1067 SessionState::Closing | SessionState::Destroying => Self::Stopping,
1068 SessionState::Stopped => Self::Stopped,
1069 SessionState::Lost | SessionState::Error | SessionState::DestroyedWithDataLoss => {
1070 Self::Failed
1071 }
1072 }
1073 }
1074
1075 pub const fn is_dashboard_visible(self) -> bool {
1079 matches!(self, Self::Live | Self::Starting | Self::Stopping)
1080 }
1081}
1082
1083#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1084#[serde(rename_all = "kebab-case")]
1085pub enum ViewerOperationKind {
1086 Create,
1087 Resume,
1088 Move,
1089 Stop,
1090 Destroy,
1091 Cleanup,
1092 Checkpoint,
1093}
1094
1095impl ViewerOperationKind {
1096 pub const fn transition_kind(self) -> Option<SessionTransitionKind> {
1097 match self {
1098 Self::Create => Some(SessionTransitionKind::Starting),
1099 Self::Resume => Some(SessionTransitionKind::Resuming),
1100 Self::Move => Some(SessionTransitionKind::Moving),
1101 Self::Stop => Some(SessionTransitionKind::Stopping),
1102 Self::Destroy | Self::Cleanup => Some(SessionTransitionKind::Destroying),
1103 Self::Checkpoint => None,
1106 }
1107 }
1108
1109 pub const fn label(self) -> &'static str {
1110 match self {
1111 Self::Create => "Starting",
1112 Self::Resume => "Resuming",
1113 Self::Move => "Moving",
1114 Self::Stop => "Stopping",
1115 Self::Destroy => "Destroying",
1116 Self::Cleanup => "Cleaning up",
1117 Self::Checkpoint => "Checkpointing",
1118 }
1119 }
1120}
1121
1122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1124#[serde(deny_unknown_fields)]
1125pub struct ViewerOperationStage {
1126 pub label: String,
1127 pub started_at_epoch_seconds: u64,
1128}
1129
1130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1136#[serde(deny_unknown_fields)]
1137pub struct ViewerOperation {
1138 pub id: String,
1139 pub session_id: String,
1140 pub kind: ViewerOperationKind,
1141 pub started_at_epoch_seconds: u64,
1142 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1143 pub stages: Vec<ViewerOperationStage>,
1144 #[serde(default, skip_serializing_if = "Option::is_none")]
1147 pub notice: Option<String>,
1148 pub cancellable: bool,
1149}
1150
1151#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1153#[serde(rename_all = "kebab-case")]
1154pub enum ViewerChatPhase {
1155 #[default]
1156 Idle,
1157 Running,
1158 Closing,
1159 Closed,
1160}
1161
1162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1163#[serde(deny_unknown_fields)]
1164pub struct ViewerConfigChoice {
1165 pub value: String,
1166 pub name: String,
1167 #[serde(default, skip_serializing_if = "Option::is_none")]
1168 pub description: Option<String>,
1169}
1170
1171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1177#[serde(deny_unknown_fields)]
1178pub struct ViewerConfigOption {
1179 pub key: String,
1180 pub label: String,
1181 #[serde(default, skip_serializing_if = "Option::is_none")]
1182 pub current: Option<String>,
1183 pub choices: Vec<ViewerConfigChoice>,
1184}
1185
1186#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1190#[serde(tag = "action", rename_all = "kebab-case", deny_unknown_fields)]
1191pub enum ControllerAction {
1192 New {
1193 #[serde(default)]
1198 workspace_id: String,
1199 profile_id: String,
1200 bundle_id: String,
1201 target_id: String,
1202 #[serde(default)]
1204 title: Option<String>,
1205 #[serde(default)]
1206 project_directory: Option<PathBuf>,
1207 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1215 dirty_ack: Vec<String>,
1216 },
1217 Rename {
1219 session_id: String,
1220 title: String,
1221 },
1222 CancelTurn {
1225 session_id: String,
1226 },
1227 SetConfig {
1229 session_id: String,
1230 key: String,
1231 value: String,
1232 },
1233 SetPlanMode {
1236 session_id: String,
1237 active: bool,
1238 },
1239 RefreshQuota {
1240 profile_id: String,
1241 },
1242 RefreshCapacity {
1243 target_id: String,
1244 },
1245 Resume {
1246 session_id: String,
1247 workspace_id: String,
1248 profile_id: String,
1249 target_id: String,
1250 queue: ResumeQueueDisposition,
1251 #[serde(default)]
1255 additional_mounts: Option<Vec<AdditionalMount>>,
1256 #[serde(default)]
1257 resource_allocation: Option<SessionResourceAllocation>,
1258 },
1259 Move {
1263 request: MoveSessionRequest,
1264 },
1265 Open {
1266 session_id: String,
1267 },
1268 Prompt {
1269 session_id: String,
1270 text: String,
1271 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1274 images: Vec<ViewerPromptImage>,
1275 },
1276 RunShell {
1277 session_id: String,
1278 command: String,
1279 },
1280 CancelShell {
1281 session_id: String,
1282 shell_command_id: String,
1283 },
1284 Close {
1285 session_id: String,
1286 },
1287 Cancel {
1288 session_id: String,
1289 },
1290 StartReview {
1292 session_id: String,
1293 },
1294 ResolveReview {
1296 session_id: String,
1297 resolution: String,
1299 },
1300 RemoveQueuedPrompt {
1301 session_id: String,
1302 queue_id: String,
1303 },
1304 RespondElicitation {
1306 session_id: String,
1307 elicitation_id: String,
1308 response: ElicitationResponse,
1309 },
1310}
1311
1312#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1315#[serde(deny_unknown_fields)]
1316pub struct ViewerPromptImage {
1317 #[serde(default)]
1320 pub data_base64: String,
1321 pub mime_type: String,
1322 pub width: u32,
1323 pub height: u32,
1324 #[serde(default, skip_serializing_if = "Option::is_none")]
1327 pub attachment: Option<AttachmentRef>,
1328}
1329
1330#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1341pub enum ActionOutcome {
1342 Accepted,
1344 Busy,
1346 SessionBusy,
1348 NotCancellable,
1350 Failed,
1352}
1353
1354impl ActionOutcome {
1355 const fn rejection(self) -> Option<ApiError> {
1357 match self {
1358 Self::Accepted => None,
1359 Self::Busy => Some(ApiError::new(
1360 StatusCode::TOO_MANY_REQUESTS,
1361 "the controller is at its concurrent action limit; retry shortly",
1362 )),
1363 Self::SessionBusy => Some(ApiError::new(
1364 StatusCode::CONFLICT,
1365 "another operation is already running for this session",
1366 )),
1367 Self::NotCancellable => Some(ApiError::new(
1368 StatusCode::CONFLICT,
1369 "the session has no cancellable operation",
1370 )),
1371 Self::Failed => Some(ApiError::new(
1372 StatusCode::INTERNAL_SERVER_ERROR,
1373 "the controller could not start this action",
1374 )),
1375 }
1376 }
1377}
1378
1379#[derive(Debug)]
1380pub struct ControllerRequest {
1381 pub action: ControllerAction,
1382 pub reply: tokio::sync::oneshot::Sender<ActionOutcome>,
1383}
1384
1385#[derive(Debug)]
1390pub struct BundleRequest {
1391 pub source: String,
1392 pub reply: tokio::sync::oneshot::Sender<Result<String, BundleFailure>>,
1393}
1394
1395#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1399pub enum BundleFailure {
1400 InvalidSource,
1401 Controller,
1402}
1403
1404#[derive(Debug)]
1421pub struct PreflightRequest {
1422 pub bundle_id: String,
1423 pub target_id: String,
1424 pub project_directory: Option<PathBuf>,
1425 pub remote_repairs: Vec<hel::hel_local_git::LocalRemoteRepair>,
1426 pub reply: tokio::sync::oneshot::Sender<Result<PreflightNew, PreflightFailure>>,
1427}
1428
1429#[derive(Debug)]
1433pub struct MovePreparationRequest {
1434 pub selection: MoveSelection,
1435 pub reply: tokio::sync::oneshot::Sender<Result<MovePreparation, String>>,
1436}
1437
1438#[derive(Debug)]
1443pub enum PreflightFailure {
1444 Validation,
1445 InvalidRepository(String),
1449 Controller(String),
1450}
1451
1452#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1456#[serde(deny_unknown_fields)]
1457pub struct PreflightRepository {
1458 pub id: String,
1459 pub fetch_url: String,
1460 pub default_branch: String,
1461 pub push_urls: Vec<String>,
1462}
1463
1464#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1470#[serde(deny_unknown_fields)]
1471pub struct PreflightNew {
1472 #[serde(default, skip_serializing_if = "Option::is_none")]
1473 pub project_directory: Option<PathBuf>,
1474 #[serde(default)]
1475 pub remote_repairs: Vec<hel::hel_local_git::LocalRemoteRepair>,
1476 #[serde(default)]
1477 pub dirty_repositories: Vec<String>,
1478 #[serde(default)]
1479 pub remote_repositories: Vec<PreflightRepository>,
1480 pub local_changes_excluded: bool,
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(Debug)]
1546pub struct BackgroundTaskStopRequest {
1547 pub session_id: String,
1548 pub background_task_id: String,
1549 pub reply: tokio::sync::oneshot::Sender<Result<(), BackgroundTaskStopFailure>>,
1550}
1551
1552#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1553pub enum BackgroundTaskStopFailure {
1554 SessionUnavailable,
1556 Provider,
1558 Internal,
1560}
1561
1562#[derive(Clone)]
1563struct ServerState {
1564 snapshot_rx: watch::Receiver<ViewerSnapshot>,
1565 conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
1566 action_tx: mpsc::Sender<ControllerRequest>,
1567 bundle_tx: mpsc::Sender<BundleRequest>,
1568 receipt_tx: mpsc::Sender<ReadReceiptRequest>,
1569 preflight_tx: mpsc::Sender<PreflightRequest>,
1570 move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
1571 client_state_tx: mpsc::Sender<ClientStateRequest>,
1572 dictation_tx: mpsc::Sender<DictationRequest>,
1573 background_task_stop_tx: mpsc::Sender<BackgroundTaskStopRequest>,
1574 dictation_permits: Arc<Semaphore>,
1575 dictation_probe_permits: Arc<Semaphore>,
1576 shutdown: CancellationToken,
1577 viewer_code: Arc<str>,
1578 login_token: Arc<str>,
1579 cookie_key: Arc<[u8]>,
1580 session_ttl: Duration,
1581 secure_cookie: bool,
1582 code_guard: Arc<Mutex<CodeGuard>>,
1583}
1584
1585#[derive(Debug, Default)]
1593struct CodeGuard {
1594 failures: u32,
1595 lockouts: u32,
1596 locked_until: Option<Instant>,
1597}
1598
1599impl CodeGuard {
1600 fn locked_at(&mut self, now: Instant) -> bool {
1601 match self.locked_until {
1602 Some(until) if now < until => true,
1603 Some(_) => {
1604 self.locked_until = None;
1607 self.failures = 0;
1608 false
1609 }
1610 None => false,
1611 }
1612 }
1613
1614 fn record_failure_at(&mut self, now: Instant) {
1615 self.failures = self.failures.saturating_add(1);
1616 if self.failures < MAX_CODE_FAILURES {
1617 return;
1618 }
1619 self.failures = 0;
1620 self.lockouts = self.lockouts.saturating_add(1);
1621 self.locked_until = Some(now + code_lockout(self.lockouts));
1622 }
1623}
1624
1625fn code_lockout(lockouts: u32) -> Duration {
1628 let multiplier = 1_u32
1629 .checked_shl(lockouts.saturating_sub(1))
1630 .unwrap_or(u32::MAX);
1631 CODE_LOCKOUT_BASE
1632 .saturating_mul(multiplier)
1633 .min(CODE_LOCKOUT_CAP)
1634}
1635
1636fn router(options: ServerOptions) -> Router {
1637 let state = ServerState {
1638 snapshot_rx: options.snapshot_rx,
1639 conversation_rx: options.conversation_rx,
1640 action_tx: options.action_tx,
1641 bundle_tx: options.bundle_tx,
1642 receipt_tx: options.receipt_tx,
1643 preflight_tx: options.preflight_tx,
1644 move_preparation_tx: options.move_preparation_tx,
1645 client_state_tx: options.client_state_tx,
1646 dictation_tx: options.dictation_tx,
1647 background_task_stop_tx: options.background_task_stop_tx,
1648 dictation_permits: Arc::new(Semaphore::new(MAX_CONCURRENT_DICTATIONS)),
1649 dictation_probe_permits: Arc::new(Semaphore::new(MAX_CONCURRENT_DICTATIONS)),
1650 shutdown: options.shutdown,
1651 viewer_code: options.viewer_code.into(),
1652 login_token: options.login_token.into(),
1653 cookie_key: options.cookie_key.into(),
1654 session_ttl: options.session_ttl,
1655 secure_cookie: options.secure_cookie,
1656 code_guard: Arc::new(Mutex::new(CodeGuard::default())),
1657 };
1658 let protected = Router::new()
1659 .route("/api/snapshot", get(snapshot))
1660 .route("/api/conversations/{session_id}", get(conversation))
1661 .route(
1662 "/api/conversations/{session_id}/read",
1663 post(mark_conversation_read),
1664 )
1665 .route("/api/events", get(events))
1666 .route("/api/bundles", post(create_bundle))
1667 .route("/api/preflight/new", post(preflight_new))
1668 .route("/api/moves/prepare", post(prepare_move))
1669 .route("/api/sessions/{session_id}/client-state", get(client_state))
1670 .route(
1671 "/api/sessions/{session_id}/dictation",
1672 get(dictation_availability).post(upload_dictation),
1673 )
1674 .route(
1675 "/api/sessions/{session_id}/background-tasks/stop",
1676 post(stop_background_task),
1677 )
1678 .route(
1679 "/api/sessions/{session_id}/attachments",
1680 post(upload_attachment).layer(DefaultBodyLimit::max(MAX_ATTACHMENT_UPLOAD_BYTES)),
1681 )
1682 .route(
1683 "/api/sessions/{session_id}/draft",
1684 put(save_draft).layer(DefaultBodyLimit::max(MAX_DRAFT_BYTES)),
1685 )
1686 .route("/api/sessions/{session_id}/history", get(prompt_history))
1687 .route(
1688 "/api/workspaces/{workspace_id}/read",
1689 post(mark_workspace_read),
1690 )
1691 .route(
1692 "/api/actions",
1693 post(action).layer(DefaultBodyLimit::max(MAX_PROMPT_BODY_BYTES)),
1694 )
1695 .route_layer(axum::middleware::from_fn_with_state(
1696 state.clone(),
1697 require_session,
1698 ));
1699 Router::new()
1700 .route("/", get(viewer))
1701 .route("/login", get(viewer))
1702 .route("/viewer.css", get(viewer_css))
1703 .route("/viewer.js", get(viewer_js))
1704 .route("/voice-worklet.js", get(voice_worklet_js))
1705 .route("/voice-worker.js", get(voice_worker_js))
1706 .route("/markdown.js", get(markdown_js))
1707 .route("/tool-output.js", get(tool_output_js))
1708 .route("/manifest.webmanifest", get(manifest))
1709 .route("/service-worker.js", get(service_worker))
1710 .route("/icon.svg", get(icon))
1711 .route("/icon-192.png", get(icon_192))
1712 .route("/icon-512.png", get(icon_512))
1713 .route("/maskable-512.png", get(maskable_512))
1714 .route("/apple-touch-icon.png", get(apple_touch_icon))
1715 .route("/fonts/jetbrains-mono.woff2", get(mono_font))
1716 .route("/auth/session", post(create_session).delete(clear_session))
1717 .route("/auth/login", get(create_session_from_query))
1718 .merge(protected)
1719 .layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
1720 .layer(axum::middleware::from_fn(security_headers))
1721 .with_state(state)
1722}
1723
1724async fn require_session(
1725 State(state): State<ServerState>,
1726 request: Request,
1727 next: Next,
1728) -> Result<Response<Body>, ApiError> {
1729 let cookie = request
1730 .headers()
1731 .get(COOKIE)
1732 .and_then(|value| value.to_str().ok())
1733 .and_then(|header| cookie_value(header, COOKIE_NAME));
1734 if cookie.is_some_and(|value| session_cookie_valid(&state.cookie_key, value, now_unix())) {
1735 Ok(next.run(request).await)
1736 } else {
1737 Err(ApiError::unauthorized())
1738 }
1739}
1740
1741#[derive(Debug, Deserialize)]
1742#[serde(deny_unknown_fields)]
1743struct LoginRequest {
1744 code: String,
1745}
1746
1747#[derive(Debug, Deserialize)]
1748#[serde(deny_unknown_fields)]
1749struct LoginQuery {
1750 token: String,
1751}
1752
1753async fn create_session_from_query(
1754 State(state): State<ServerState>,
1755 Query(query): Query<LoginQuery>,
1756) -> Result<Response<Body>, ApiError> {
1757 if !constant_time_eq(state.login_token.as_bytes(), query.token.trim().as_bytes()) {
1758 return Err(ApiError::unauthorized());
1759 }
1760 let mut response = issue_session_cookie(&state, StatusCode::SEE_OTHER)?;
1761 response
1762 .headers_mut()
1763 .insert(LOCATION, HeaderValue::from_static("/"));
1764 response
1765 .headers_mut()
1766 .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
1767 Ok(response)
1768}
1769
1770async fn create_session(
1771 State(state): State<ServerState>,
1772 Json(request): Json<LoginRequest>,
1773) -> Result<Response<Body>, ApiError> {
1774 if code_locked(&state) {
1775 return Err(ApiError::new(
1776 StatusCode::TOO_MANY_REQUESTS,
1777 "too many incorrect codes; wait and try again",
1778 ));
1779 }
1780 if !constant_time_eq(state.viewer_code.as_bytes(), request.code.trim().as_bytes()) {
1781 record_code_failure(&state);
1782 return Err(ApiError::unauthorized());
1783 }
1784 reset_code_failures(&state);
1785 issue_session_cookie(&state, StatusCode::NO_CONTENT)
1786}
1787
1788fn issue_session_cookie(
1789 state: &ServerState,
1790 status: StatusCode,
1791) -> Result<Response<Body>, ApiError> {
1792 let ephemeral = state.session_ttl.is_zero();
1793 let validity = if ephemeral {
1794 EPHEMERAL_SESSION_TTL
1795 } else {
1796 state.session_ttl
1797 };
1798 let value = signed_cookie_value(
1799 &state.cookie_key,
1800 &generate_viewer_id().map_err(|_| {
1801 ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "cookie creation failed")
1802 })?,
1803 now_unix().saturating_add(validity.as_secs()),
1804 );
1805 let cookie = session_cookie_header(
1806 &value,
1807 (!ephemeral).then_some(validity.as_secs()),
1808 state.secure_cookie,
1809 )?;
1810 let mut response = status.into_response();
1811 response.headers_mut().insert(SET_COOKIE, cookie);
1812 Ok(response)
1813}
1814
1815async fn clear_session(State(state): State<ServerState>) -> Response<Body> {
1816 let mut response = StatusCode::NO_CONTENT.into_response();
1817 response
1818 .headers_mut()
1819 .insert(SET_COOKIE, clear_cookie_header(state.secure_cookie));
1820 response
1821}
1822
1823async fn snapshot(State(state): State<ServerState>) -> Response<Body> {
1824 let mut projection = state.snapshot_rx.borrow().clone();
1825 projection.server_time_ms = hel::clock::epoch_millis();
1828 let mut response = Json(projection).into_response();
1829 response
1830 .headers_mut()
1831 .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
1832 response
1833}
1834
1835async fn upload_attachment(
1840 State(state): State<ServerState>,
1841 Path(session_id): Path<String>,
1842 body: Bytes,
1843) -> Result<Json<ViewerPromptImage>, ApiError> {
1844 validate_public_id(&session_id)?;
1845 let prompt_images_supported = {
1846 let snapshot = state.snapshot_rx.borrow();
1847 require_session_record(&snapshot, &session_id)?.prompt_images_supported
1848 };
1849 if !prompt_images_supported {
1850 return Err(ApiError::bad_request(
1851 "this session does not support image prompts",
1852 ));
1853 }
1854 if body.is_empty() {
1855 return Err(ApiError::bad_request("image upload must not be empty"));
1856 }
1857
1858 let result = tokio::task::spawn_blocking(move || {
1859 let optimized = optimize_image(&body).map_err(|_| {
1860 ApiError::bad_request("unsupported image format or image could not be decoded")
1861 })?;
1862 if optimized.bytes.is_empty() || optimized.bytes.len() > MAX_IMAGE_BYTES {
1863 return Err(ApiError::new(
1864 StatusCode::INTERNAL_SERVER_ERROR,
1865 "the image optimizer returned an invalid image size",
1866 ));
1867 }
1868 let reference = AttachmentRef::new(
1869 &optimized.bytes,
1870 optimized.mime_type.clone(),
1871 optimized.width,
1872 optimized.height,
1873 )
1874 .map_err(|_| {
1875 ApiError::new(
1876 StatusCode::INTERNAL_SERVER_ERROR,
1877 "could not create image attachment",
1878 )
1879 })?;
1880 let store = AttachmentStore::controller(&session_id).map_err(|_| {
1881 ApiError::new(
1882 StatusCode::INTERNAL_SERVER_ERROR,
1883 "could not open the image attachment store",
1884 )
1885 })?;
1886 store.install(&reference, &optimized.bytes).map_err(|_| {
1887 ApiError::new(
1888 StatusCode::INTERNAL_SERVER_ERROR,
1889 "could not store the image attachment",
1890 )
1891 })?;
1892 Ok(ViewerPromptImage {
1893 data_base64: String::new(),
1894 mime_type: reference.mime_type.clone(),
1895 width: reference.width,
1896 height: reference.height,
1897 attachment: Some(reference),
1898 })
1899 })
1900 .await
1901 .map_err(|_| {
1902 ApiError::new(
1903 StatusCode::INTERNAL_SERVER_ERROR,
1904 "the server could not process the image upload",
1905 )
1906 })??;
1907
1908 Ok(Json(result))
1909}
1910
1911async fn action(
1917 State(state): State<ServerState>,
1918 Json(action): Json<ControllerAction>,
1919) -> Result<StatusCode, ApiError> {
1920 validate_action(&action, &state.snapshot_rx.borrow())?;
1921 let action = decode_prompt_images_off_task(action).await?;
1922 let (reply, outcome) = tokio::sync::oneshot::channel();
1923 state
1924 .action_tx
1925 .send(ControllerRequest { action, reply })
1926 .await
1927 .map_err(|_| ApiError::controller_unavailable())?;
1928 let outcome = outcome
1929 .await
1930 .map_err(|_| ApiError::controller_unavailable())?;
1931 match outcome.rejection() {
1932 Some(rejection) => Err(rejection),
1933 None => Ok(StatusCode::ACCEPTED),
1934 }
1935}
1936
1937const MAX_BUNDLE_SOURCE_CHARS: usize = 1024;
1938
1939#[derive(Debug, Deserialize)]
1940#[serde(deny_unknown_fields)]
1941struct CreateBundleRequest {
1942 source: String,
1943}
1944
1945#[derive(Debug, Serialize)]
1946struct CreateBundleResponse {
1947 bundle_id: String,
1948}
1949
1950async fn create_bundle(
1955 State(state): State<ServerState>,
1956 Json(request): Json<CreateBundleRequest>,
1957) -> Result<Json<CreateBundleResponse>, ApiError> {
1958 if request.source.trim().is_empty() {
1959 return Err(ApiError::bad_request("repository source cannot be empty"));
1960 }
1961 if request.source.chars().count() > MAX_BUNDLE_SOURCE_CHARS {
1962 return Err(ApiError::bad_request(
1963 "repository source must contain 1024 characters or fewer",
1964 ));
1965 }
1966 let (reply, result) = tokio::sync::oneshot::channel();
1967 state
1968 .bundle_tx
1969 .send(BundleRequest {
1970 source: request.source,
1971 reply,
1972 })
1973 .await
1974 .map_err(|_| ApiError::controller_unavailable())?;
1975 let bundle_id = result
1976 .await
1977 .map_err(|_| ApiError::controller_unavailable())?
1978 .map_err(|failure| match failure {
1979 BundleFailure::InvalidSource => ApiError::bad_request(
1980 "use a GitHub owner/repository or an existing Git checkout on the controller host",
1981 ),
1982 BundleFailure::Controller => ApiError::new(
1983 StatusCode::INTERNAL_SERVER_ERROR,
1984 "the controller could not create the bundle",
1985 ),
1986 })?;
1987 Ok(Json(CreateBundleResponse { bundle_id }))
1988}
1989
1990#[derive(Debug, Deserialize)]
1991struct ConversationQuery {
1992 after_seq: Option<u64>,
1993 presentation_key: Option<String>,
1994}
1995
1996async fn conversation(
1997 State(state): State<ServerState>,
1998 Path(session_id): Path<String>,
1999 Query(query): Query<ConversationQuery>,
2000) -> Result<Json<BrowserTranscript>, ApiError> {
2001 validate_public_id(&session_id)?;
2002 let transitioning = {
2003 let snapshot = state.snapshot_rx.borrow();
2004 require_session_record(&snapshot, &session_id)?.transitioning
2005 };
2006 if transitioning {
2007 return Err(ApiError::new(
2008 StatusCode::CONFLICT,
2009 "conversation unavailable while the session is transitioning",
2010 ));
2011 }
2012 let conversations = state.conversation_rx.borrow();
2013 let transcript = conversations
2014 .get(&session_id)
2015 .ok_or_else(|| ApiError::not_found("conversation unavailable"))?;
2016 let mut response = transcript.clone();
2017 let presentation_mismatch = query
2021 .presentation_key
2022 .as_deref()
2023 .is_some_and(|key| key != transcript.presentation_key);
2024 if let Some(after) = query.after_seq {
2025 response.reset = presentation_mismatch || after < response.window_start_seq;
2026 if !response.reset {
2027 response.entries.retain(|entry| entry.updated_seq > after);
2028 }
2029 } else if presentation_mismatch {
2030 response.reset = true;
2031 }
2032 Ok(Json(response))
2033}
2034
2035#[derive(Debug, Deserialize)]
2036#[serde(deny_unknown_fields)]
2037struct ReadRequest {
2038 through: u64,
2039}
2040
2041async fn mark_conversation_read(
2042 State(state): State<ServerState>,
2043 Path(session_id): Path<String>,
2044 headers: HeaderMap,
2045 Json(request): Json<ReadRequest>,
2046) -> Result<StatusCode, ApiError> {
2047 validate_public_id(&session_id)?;
2048 let transitioning = {
2049 let snapshot = state.snapshot_rx.borrow();
2050 require_session_record(&snapshot, &session_id)?.transitioning
2051 };
2052 if transitioning {
2053 return Err(ApiError::new(
2054 StatusCode::CONFLICT,
2055 "conversation unavailable while the session is transitioning",
2056 ));
2057 }
2058 let (reply, result) = tokio::sync::oneshot::channel();
2059 let client_id = viewer_client_id(&state, &headers).ok_or_else(ApiError::unauthorized)?;
2060 state
2061 .receipt_tx
2062 .send(ReadReceiptRequest {
2063 client_id,
2064 session_id,
2065 through: request.through,
2066 reply,
2067 })
2068 .await
2069 .map_err(|_| ApiError::controller_unavailable())?;
2070 result
2071 .await
2072 .map_err(|_| ApiError::controller_unavailable())?
2073 .map_err(|_| ApiError::new(StatusCode::CONFLICT, "read receipt failed"))?;
2074 Ok(StatusCode::NO_CONTENT)
2075}
2076
2077#[derive(Debug, Deserialize)]
2078#[serde(deny_unknown_fields)]
2079struct StopBackgroundTaskRequest {
2080 background_task_id: String,
2081}
2082
2083async fn stop_background_task(
2088 State(state): State<ServerState>,
2089 Path(session_id): Path<String>,
2090 Json(request): Json<StopBackgroundTaskRequest>,
2091) -> Result<StatusCode, ApiError> {
2092 validate_public_id(&session_id)?;
2093 if request.background_task_id.is_empty() || request.background_task_id.len() > 256 {
2097 return Err(ApiError::bad_request("invalid background task id"));
2098 }
2099 {
2100 let snapshot = state.snapshot_rx.borrow();
2101 let session = require_session_record(&snapshot, &session_id)?;
2102 let task = session
2103 .background_tasks
2104 .iter()
2105 .find(|task| task.id == request.background_task_id)
2106 .ok_or_else(|| {
2107 ApiError::new(StatusCode::CONFLICT, "background task is no longer running")
2108 })?;
2109 if !task.can_stop {
2110 return Err(ApiError::new(
2111 StatusCode::CONFLICT,
2112 "background task cannot be stopped",
2113 ));
2114 }
2115 }
2116
2117 let (reply, result) = tokio::sync::oneshot::channel();
2118 state
2119 .background_task_stop_tx
2120 .send(BackgroundTaskStopRequest {
2121 session_id,
2122 background_task_id: request.background_task_id,
2123 reply,
2124 })
2125 .await
2126 .map_err(|_| ApiError::controller_unavailable())?;
2127 match result
2128 .await
2129 .map_err(|_| ApiError::controller_unavailable())?
2130 {
2131 Ok(()) => Ok(StatusCode::ACCEPTED),
2132 Err(BackgroundTaskStopFailure::SessionUnavailable) => Err(ApiError::new(
2133 StatusCode::SERVICE_UNAVAILABLE,
2134 "the live session is unavailable",
2135 )),
2136 Err(BackgroundTaskStopFailure::Provider | BackgroundTaskStopFailure::Internal) => {
2137 Err(ApiError::new(
2138 StatusCode::INTERNAL_SERVER_ERROR,
2139 "the provider could not stop this background task",
2140 ))
2141 }
2142 }
2143}
2144
2145#[derive(Debug, Deserialize)]
2146#[serde(deny_unknown_fields)]
2147struct PreflightNewRequest {
2148 #[serde(default)]
2149 remote_repairs: Vec<hel::hel_local_git::LocalRemoteRepair>,
2150 #[serde(default)]
2151 workspace_id: String,
2152 profile_id: String,
2153 bundle_id: String,
2154 target_id: String,
2155 #[serde(default)]
2156 project_directory: Option<PathBuf>,
2157}
2158
2159async fn preflight_new(
2165 State(state): State<ServerState>,
2166 Json(request): Json<PreflightNewRequest>,
2167) -> Result<Json<PreflightNew>, ApiError> {
2168 let project_validation = request.project_directory.is_some();
2169 let action = ControllerAction::New {
2170 workspace_id: request.workspace_id,
2171 profile_id: request.profile_id,
2172 bundle_id: request.bundle_id.clone(),
2173 target_id: request.target_id.clone(),
2174 title: None,
2175 project_directory: request.project_directory.clone(),
2176 dirty_ack: Vec::new(),
2177 };
2178 validate_action(&action, &state.snapshot_rx.borrow())?;
2179 let (reply, result) = tokio::sync::oneshot::channel();
2180 state
2181 .preflight_tx
2182 .send(PreflightRequest {
2183 bundle_id: request.bundle_id,
2184 target_id: request.target_id,
2185 project_directory: request.project_directory,
2186 remote_repairs: request.remote_repairs,
2187 reply,
2188 })
2189 .await
2190 .map_err(|_| ApiError::controller_unavailable())?;
2191 result
2192 .await
2193 .map_err(|_| ApiError::controller_unavailable())?
2194 .map(Json)
2195 .map_err(|failure| match failure {
2196 PreflightFailure::Validation if project_validation => ApiError::bad_request(
2197 "project validation failed; check that the directory exists, is accessible, and contains a Git repository with a valid HEAD",
2198 ),
2199 PreflightFailure::InvalidRepository(_) => ApiError::bad_request(
2200 "could not resolve the network repository; check its remote URL, authentication, connectivity, and default branch. Repositories without network remotes require a raw local session",
2201 ),
2202 PreflightFailure::Validation => ApiError::bad_request(
2203 "isolated session repositories need a network Git remote; use a raw local target for a local-only checkout",
2204 ),
2205 PreflightFailure::Controller(_) => ApiError::new(
2206 StatusCode::SERVICE_UNAVAILABLE,
2207 "the controller could not check this project",
2208 ),
2209 })
2210}
2211
2212async fn prepare_move(
2217 State(state): State<ServerState>,
2218 Json(selection): Json<MoveSelection>,
2219) -> Result<Json<MovePreparation>, ApiError> {
2220 validate_move_selection(&selection, &state.snapshot_rx.borrow())?;
2221 let (reply, result) = tokio::sync::oneshot::channel();
2222 state
2223 .move_preparation_tx
2224 .send(MovePreparationRequest { selection, reply })
2225 .await
2226 .map_err(|_| ApiError::controller_unavailable())?;
2227 let preparation = result
2228 .await
2229 .map_err(|_| ApiError::controller_unavailable())?
2230 .map_err(|error| {
2231 tracing::debug!(error = %error, "move preparation was rejected");
2232 ApiError::new(
2233 StatusCode::CONFLICT,
2234 "move preparation was rejected; refresh and try again",
2235 )
2236 })?;
2237 Ok(Json(inspector_move_preparation(preparation)))
2238}
2239
2240fn inspector_move_preparation(mut preparation: MovePreparation) -> MovePreparation {
2245 for command in &mut preparation.queued_commands {
2246 for block in &mut command.content {
2247 if block.get("type").and_then(serde_json::Value::as_str) != Some("image") {
2248 continue;
2249 }
2250 let mime = block
2251 .get("mimeType")
2252 .or_else(|| block.get("mime_type"))
2253 .and_then(serde_json::Value::as_str)
2254 .unwrap_or("image");
2255 *block = serde_json::json!({
2256 "type": "text",
2257 "text": format!("[Image attachment: {mime}]")
2258 });
2259 }
2260 }
2261 preparation
2262}
2263
2264async fn ask_client_state<T>(
2266 state: &ServerState,
2267 build: impl FnOnce(tokio::sync::oneshot::Sender<Result<T, String>>) -> ClientStateRequest,
2268) -> Result<T, ApiError> {
2269 let (reply, answer) = tokio::sync::oneshot::channel();
2270 state
2271 .client_state_tx
2272 .send(build(reply))
2273 .await
2274 .map_err(|_| ApiError::controller_unavailable())?;
2275 answer
2276 .await
2277 .map_err(|_| ApiError::controller_unavailable())?
2278 .map_err(|_| {
2279 ApiError::new(
2280 StatusCode::SERVICE_UNAVAILABLE,
2281 "the controller could not reach stored viewer state",
2282 )
2283 })
2284}
2285
2286async fn client_state(
2288 State(state): State<ServerState>,
2289 Path(session_id): Path<String>,
2290 headers: HeaderMap,
2291) -> Result<Json<ViewerClientState>, ApiError> {
2292 validate_public_id(&session_id)?;
2293 require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
2294 let Some(client_id) = viewer_client_id(&state, &headers) else {
2298 return Ok(Json(ViewerClientState::default()));
2299 };
2300 ask_client_state(&state, |reply| ClientStateRequest::Read {
2301 client_id,
2302 session_id,
2303 reply,
2304 })
2305 .await
2306 .map(Json)
2307}
2308
2309#[derive(Debug, Serialize)]
2310struct DictationAvailability {
2311 available: bool,
2312 #[serde(skip_serializing_if = "Option::is_none")]
2313 reason: Option<String>,
2314}
2315
2316#[derive(Debug, Serialize)]
2317struct DictationTranscript {
2318 text: String,
2319}
2320
2321async fn dictation_availability(
2325 State(state): State<ServerState>,
2326 Path(session_id): Path<String>,
2327) -> Result<Json<DictationAvailability>, ApiError> {
2328 validate_public_id(&session_id)?;
2329 require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
2330 let _permit = state
2331 .dictation_probe_permits
2332 .clone()
2333 .try_acquire_owned()
2334 .map_err(|_| ApiError::new(StatusCode::TOO_MANY_REQUESTS, "too many dictation requests"))?;
2335 let result = dispatch_dictation(&state, session_id, DictationOperation::Availability).await?;
2336 match result {
2337 DictationResponse::Availability { available, reason } => {
2338 Ok(Json(DictationAvailability { available, reason }))
2339 }
2340 DictationResponse::Transcript { .. } => Err(ApiError::new(
2341 StatusCode::INTERNAL_SERVER_ERROR,
2342 "the controller returned an invalid dictation response",
2343 )),
2344 }
2345}
2346
2347async fn upload_dictation(
2351 State(state): State<ServerState>,
2352 Path(session_id): Path<String>,
2353 request: Request,
2354) -> Result<Json<DictationTranscript>, ApiError> {
2355 validate_public_id(&session_id)?;
2356 require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
2357 let _permit = state
2358 .dictation_permits
2359 .clone()
2360 .try_acquire_owned()
2361 .map_err(|_| ApiError::new(StatusCode::TOO_MANY_REQUESTS, "too many dictation requests"))?;
2362
2363 if request
2364 .headers()
2365 .get(axum::http::header::CONTENT_LENGTH)
2366 .and_then(|value| value.to_str().ok())
2367 .and_then(|value| value.parse::<u64>().ok())
2368 .is_some_and(|length| length > MAX_AUDIO_BYTES as u64)
2369 {
2370 return Err(ApiError::new(
2371 StatusCode::PAYLOAD_TOO_LARGE,
2372 "audio upload is too large",
2373 ));
2374 }
2375 let body = tokio::select! {
2376 biased;
2377 _ = state.shutdown.cancelled() => return Err(ApiError::controller_unavailable()),
2378 result = tokio::time::timeout(
2379 crate::hel_dictation::DICTATION_TIMEOUT,
2380 to_bytes(request.into_body(), MAX_AUDIO_BYTES),
2381 ) => match result {
2382 Ok(result) => result.map_err(|_| {
2383 ApiError::new(StatusCode::PAYLOAD_TOO_LARGE, "audio upload is too large")
2384 })?,
2385 Err(_) => return Err(ApiError::new(
2386 StatusCode::GATEWAY_TIMEOUT,
2387 "dictation upload timed out",
2388 )),
2389 },
2390 };
2391 let audio = body.clone();
2394 tokio::task::spawn_blocking(move || validate_wav(&audio))
2395 .await
2396 .map_err(|error| {
2397 tracing::warn!(%error, "dictation audio validation task failed");
2398 ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "audio validation failed")
2399 })?
2400 .map_err(dictation_api_error)?;
2401 let result =
2402 dispatch_dictation(&state, session_id, DictationOperation::Transcribe(body)).await?;
2403 match result {
2404 DictationResponse::Transcript { text } => Ok(Json(DictationTranscript { text })),
2405 DictationResponse::Availability { .. } => Err(ApiError::new(
2406 StatusCode::INTERNAL_SERVER_ERROR,
2407 "the controller returned an invalid dictation response",
2408 )),
2409 }
2410}
2411
2412struct DictationCancellationGuard(CancellationToken);
2415
2416impl Drop for DictationCancellationGuard {
2417 fn drop(&mut self) {
2418 self.0.cancel();
2419 }
2420}
2421
2422async fn dispatch_dictation(
2423 state: &ServerState,
2424 session_id: String,
2425 operation: DictationOperation,
2426) -> Result<DictationResponse, ApiError> {
2427 let cancel = CancellationToken::new();
2428 let _guard = DictationCancellationGuard(cancel.clone());
2429 let (reply, answer) = tokio::sync::oneshot::channel();
2430 let request = DictationRequest {
2431 session_id,
2432 operation,
2433 cancel: cancel.clone(),
2434 reply,
2435 };
2436 tokio::select! {
2437 biased;
2438 _ = state.shutdown.cancelled() => return Err(ApiError::controller_unavailable()),
2439 result = state.dictation_tx.send(request) => {
2440 result.map_err(|_| ApiError::controller_unavailable())?;
2441 }
2442 }
2443 let answer = tokio::select! {
2444 biased;
2445 _ = state.shutdown.cancelled() => return Err(ApiError::controller_unavailable()),
2446 result = answer => result.map_err(|_| ApiError::controller_unavailable())?,
2447 };
2448 answer.map_err(dictation_api_error)
2449}
2450
2451fn dictation_api_error(error: DictationError) -> ApiError {
2452 match error {
2453 DictationError::SessionNotFound => ApiError::not_found("unknown session"),
2454 DictationError::CredentialsUnavailable => ApiError::new(
2455 StatusCode::SERVICE_UNAVAILABLE,
2456 "dictation is unavailable because no Codex subscription is signed in",
2457 ),
2458 DictationError::InvalidAudio(message) => ApiError::bad_request(message),
2459 DictationError::Cancelled => {
2460 ApiError::new(StatusCode::REQUEST_TIMEOUT, "dictation cancelled")
2461 }
2462 DictationError::TimedOut => ApiError::new(
2463 StatusCode::GATEWAY_TIMEOUT,
2464 "dictation transcription timed out",
2465 ),
2466 DictationError::CredentialProbe => ApiError::new(
2467 StatusCode::SERVICE_UNAVAILABLE,
2468 "dictation credentials could not be checked",
2469 ),
2470 DictationError::Provider(error) => {
2471 tracing::warn!(%error, "Codex dictation transcription failed");
2472 ApiError::new(StatusCode::BAD_GATEWAY, "dictation transcription failed")
2473 }
2474 }
2475}
2476
2477#[derive(Debug, Deserialize)]
2478#[serde(deny_unknown_fields)]
2479struct DraftRequest {
2480 draft: String,
2481}
2482
2483async fn save_draft(
2484 State(state): State<ServerState>,
2485 Path(session_id): Path<String>,
2486 headers: HeaderMap,
2487 Json(request): Json<DraftRequest>,
2488) -> Result<StatusCode, ApiError> {
2489 validate_public_id(&session_id)?;
2490 require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
2491 if request.draft.len() > MAX_DRAFT_BYTES {
2492 return Err(ApiError::new(
2493 StatusCode::PAYLOAD_TOO_LARGE,
2494 "draft must be 65536 bytes or fewer",
2495 ));
2496 }
2497 let Some(client_id) = viewer_client_id(&state, &headers) else {
2498 return Err(ApiError::new(
2501 StatusCode::CONFLICT,
2502 "this viewer has no stored identity; unlock again to keep drafts",
2503 ));
2504 };
2505 ask_client_state(&state, |reply| ClientStateRequest::SaveDraft {
2506 client_id,
2507 session_id,
2508 draft: request.draft,
2509 reply,
2510 })
2511 .await?;
2512 Ok(StatusCode::NO_CONTENT)
2513}
2514
2515async fn mark_workspace_read(
2519 State(state): State<ServerState>,
2520 Path(workspace_id): Path<String>,
2521 headers: HeaderMap,
2522) -> Result<StatusCode, ApiError> {
2523 validate_public_id(&workspace_id)?;
2524 let Some(client_id) = viewer_client_id(&state, &headers) else {
2525 return Ok(StatusCode::NO_CONTENT);
2526 };
2527 ask_client_state(&state, |reply| ClientStateRequest::MarkWorkspaceRead {
2528 client_id,
2529 workspace_id,
2530 reply,
2531 })
2532 .await?;
2533 Ok(StatusCode::NO_CONTENT)
2534}
2535
2536#[derive(Debug, Deserialize)]
2537struct HistoryQuery {
2538 #[serde(default)]
2539 q: String,
2540 #[serde(default)]
2541 scope: Option<String>,
2542}
2543
2544async fn prompt_history(
2546 State(state): State<ServerState>,
2547 Path(session_id): Path<String>,
2548 Query(query): Query<HistoryQuery>,
2549) -> Result<Json<ViewerPromptHistory>, ApiError> {
2550 validate_public_id(&session_id)?;
2551 require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
2552 if query.q.chars().count() > MAX_TITLE_CHARS {
2553 return Err(ApiError::bad_request("search text is too long"));
2554 }
2555 let scope = query.scope.unwrap_or_else(|| "project".to_owned());
2556 if !matches!(scope.as_str(), "session" | "project" | "all") {
2557 return Err(ApiError::bad_request(
2558 "scope must be session, project or all",
2559 ));
2560 }
2561 ask_client_state(&state, |reply| ClientStateRequest::History {
2562 session_id,
2563 query: query.q,
2564 scope,
2565 reply,
2566 })
2567 .await
2568 .map(Json)
2569}
2570
2571async fn events(State(state): State<ServerState>) -> impl IntoResponse {
2572 let mut snapshots = state.snapshot_rx.clone();
2573 let (tx, rx) = mpsc::channel::<Result<Event, Infallible>>(8);
2574 tokio::spawn(async move {
2575 let initial = snapshots.borrow().revision;
2576 if tx
2577 .send(Ok(Event::default()
2578 .event("revision")
2579 .data(initial.to_string())))
2580 .await
2581 .is_err()
2582 {
2583 return;
2584 }
2585 while snapshots.changed().await.is_ok() {
2586 let revision = snapshots.borrow_and_update().revision;
2587 if tx
2588 .send(Ok(Event::default()
2589 .event("revision")
2590 .data(revision.to_string())))
2591 .await
2592 .is_err()
2593 {
2594 break;
2595 }
2596 }
2597 });
2598 Sse::new(ReceiverStream::new(rx)).keep_alive(KeepAlive::default())
2599}
2600
2601async fn decode_prompt_images_off_task(
2605 action: ControllerAction,
2606) -> Result<ControllerAction, ApiError> {
2607 let ControllerAction::Prompt { images, .. } = &action else {
2608 return Ok(action);
2609 };
2610 if images.is_empty() {
2611 return Ok(action);
2612 }
2613 tokio::task::spawn_blocking(move || {
2614 let mut action = action;
2615 let ControllerAction::Prompt {
2616 session_id, images, ..
2617 } = &action
2618 else {
2619 unreachable!("only prompt actions carry images")
2620 };
2621 validate_prompt_images(images)?;
2622 let store = AttachmentStore::controller(session_id).map_err(|_| {
2623 ApiError::new(
2624 StatusCode::INTERNAL_SERVER_ERROR,
2625 "could not open the image attachment store",
2626 )
2627 })?;
2628 let ControllerAction::Prompt { images, .. } = &mut action else {
2629 unreachable!("only prompt actions carry images")
2630 };
2631 for image in images {
2632 if let Some(reference) = image.attachment.clone() {
2633 store
2637 .read(&reference)
2638 .map_err(|_| ApiError::bad_request("the image attachment is unavailable"))?;
2639 image.data_base64.clear();
2640 image.mime_type = reference.mime_type;
2641 image.width = reference.width;
2642 image.height = reference.height;
2643 } else {
2644 let bytes = base64::engine::general_purpose::STANDARD
2645 .decode(&image.data_base64)
2646 .map_err(|_| ApiError::bad_request("image data must be valid base64"))?;
2647 let optimized = optimize_image(&bytes).map_err(|_| {
2648 ApiError::bad_request("unsupported image format or image could not be decoded")
2649 })?;
2650 let reference = AttachmentRef::new(
2651 &optimized.bytes,
2652 optimized.mime_type.clone(),
2653 optimized.width,
2654 optimized.height,
2655 )
2656 .map_err(|_| {
2657 ApiError::bad_request("the inline image could not become an attachment")
2658 })?;
2659 store.install(&reference, &optimized.bytes).map_err(|_| {
2660 ApiError::new(
2661 StatusCode::INTERNAL_SERVER_ERROR,
2662 "could not store the image attachment",
2663 )
2664 })?;
2665 image.data_base64.clear();
2666 image.attachment = Some(reference);
2667 image.mime_type = optimized.mime_type;
2668 image.width = optimized.width;
2669 image.height = optimized.height;
2670 }
2671 }
2672 Ok(action)
2673 })
2674 .await
2675 .map_err(|_| {
2676 ApiError::new(
2677 StatusCode::INTERNAL_SERVER_ERROR,
2678 "the server could not check the attached images",
2679 )
2680 })?
2681}
2682
2683fn validate_prompt_images(images: &[ViewerPromptImage]) -> Result<(), ApiError> {
2684 if images.len() > MAX_PROMPT_IMAGES {
2685 return Err(ApiError::bad_request(
2686 "a prompt may contain at most 10 images",
2687 ));
2688 }
2689 for image in images {
2690 if !image.mime_type.starts_with("image/") {
2691 return Err(ApiError::bad_request(
2692 "image mime type must start with image/",
2693 ));
2694 }
2695 if image.width == 0 || image.height == 0 {
2696 return Err(ApiError::bad_request(
2697 "image dimensions must be greater than zero",
2698 ));
2699 }
2700 if let Some(reference) = &image.attachment {
2701 if !image.data_base64.is_empty() {
2702 return Err(ApiError::bad_request(
2703 "an image cannot contain both inline data and an attachment",
2704 ));
2705 }
2706 if reference.mime_type != image.mime_type
2707 || reference.width != image.width
2708 || reference.height != image.height
2709 {
2710 return Err(ApiError::bad_request(
2711 "image attachment metadata does not match the prompt",
2712 ));
2713 }
2714 continue;
2715 }
2716 let bytes = base64::engine::general_purpose::STANDARD
2717 .decode(&image.data_base64)
2718 .map_err(|_| ApiError::bad_request("image data must be valid base64"))?;
2719 if bytes.is_empty() {
2720 return Err(ApiError::bad_request("image data must not be empty"));
2721 }
2722 }
2723 Ok(())
2724}
2725
2726const MAX_MOVE_QUEUE_ITEMS: usize = 256;
2727const MAX_MOVE_MOUNTS: usize = 32;
2728
2729fn validate_move_selection(
2730 selection: &MoveSelection,
2731 snapshot: &ViewerSnapshot,
2732) -> Result<(), ApiError> {
2733 validate_public_id(&selection.session_id)?;
2734 if selection.profile_id.is_none() && selection.target_template_id.is_none() {
2735 return Err(ApiError::bad_request(
2736 "move must select a profile, a target, or both",
2737 ));
2738 }
2739 if selection.clear_resource_allocation && selection.resource_allocation.is_some() {
2740 return Err(ApiError::bad_request(
2741 "clear resource sizing cannot be combined with an explicit allocation",
2742 ));
2743 }
2744 if let Some(profile_id) = selection.profile_id.as_deref() {
2745 validate_public_id(profile_id)?;
2746 require_profile(snapshot, profile_id)?;
2747 }
2748 if let Some(target_id) = selection.target_template_id.as_deref() {
2749 validate_public_id(target_id)?;
2750 require_target(snapshot, target_id)?;
2751 let session = require_session_record(snapshot, &selection.session_id)?;
2752 if session
2753 .incompatible_resume_targets
2754 .iter()
2755 .any(|id| id == target_id)
2756 {
2757 return Err(ApiError::bad_request(
2758 "this session cannot resume on that target",
2759 ));
2760 }
2761 } else {
2762 require_session_record(snapshot, &selection.session_id)?;
2763 }
2764 if let Some(mounts) = &selection.additional_mounts {
2765 validate_move_mounts(mounts)?;
2766 }
2767 let session = require_session_record(snapshot, &selection.session_id)?;
2768 let retryable_move = session
2769 .move_recovery
2770 .as_ref()
2771 .is_some_and(|recovery| recovery.checkpoint_retained);
2772 if !session.capabilities.move_session && !retryable_move {
2773 return Err(ApiError::new(
2774 StatusCode::CONFLICT,
2775 "this session cannot be moved now",
2776 ));
2777 }
2778 Ok(())
2779}
2780
2781fn validate_move_mounts(mounts: &[AdditionalMount]) -> Result<(), ApiError> {
2782 if mounts.len() > MAX_MOVE_MOUNTS {
2783 return Err(ApiError::bad_request("a move may carry at most 32 mounts"));
2784 }
2785 for mount in mounts {
2786 for path in [&mount.source, &mount.destination] {
2787 if !path.is_absolute()
2788 || path
2789 .components()
2790 .any(|component| component == Component::ParentDir)
2791 {
2792 return Err(ApiError::bad_request(
2793 "move mount paths must be absolute and must not contain '..'",
2794 ));
2795 }
2796 }
2797 }
2798 Ok(())
2799}
2800
2801fn validate_resume_settings(
2802 additional_mounts: Option<&Vec<AdditionalMount>>,
2803 resource_allocation: Option<&SessionResourceAllocation>,
2804) -> Result<(), ApiError> {
2805 if let Some(mounts) = additional_mounts {
2806 validate_move_mounts(mounts)?;
2807 }
2808 if let Some(allocation) = resource_allocation {
2809 allocation
2810 .validate()
2811 .map_err(|_| ApiError::bad_request("resource allocation is invalid"))?;
2812 }
2813 Ok(())
2814}
2815
2816fn validate_move_request(
2817 request: &MoveSessionRequest,
2818 snapshot: &ViewerSnapshot,
2819) -> Result<(), ApiError> {
2820 let preparation = &request.preparation;
2821 validate_move_selection(&preparation.selection, snapshot)?;
2822 let session = require_session_record(snapshot, &preparation.selection.session_id)?;
2823 if preparation.operation_id.trim().is_empty() || preparation.fingerprint.trim().is_empty() {
2824 return Err(ApiError::bad_request(
2825 "move confirmation is missing its preparation identity",
2826 ));
2827 }
2828 if preparation.queued_commands.len() > MAX_MOVE_QUEUE_ITEMS {
2829 return Err(ApiError::bad_request(
2830 "move queue is too large; prepare again",
2831 ));
2832 }
2833 let active_now = preparation.active
2834 || session.chat_phase == ViewerChatPhase::Running
2835 || !session.active_user_shells.is_empty();
2836 if active_now && !request.acknowledge_interruption {
2837 return Err(ApiError::new(
2838 StatusCode::CONFLICT,
2839 "confirm that the active turn may be interrupted",
2840 ));
2841 }
2842 if !preparation.queued_commands.is_empty() && request.queue.is_none() {
2843 return Err(ApiError::bad_request(
2844 "choose whether queued work is discarded or started after the move",
2845 ));
2846 }
2847 Ok(())
2848}
2849
2850fn validate_action(action: &ControllerAction, snapshot: &ViewerSnapshot) -> Result<(), ApiError> {
2851 match action {
2852 ControllerAction::New {
2853 workspace_id,
2854 profile_id,
2855 bundle_id,
2856 target_id,
2857 title,
2858 project_directory,
2859 dirty_ack,
2860 } => {
2861 if !workspace_id.is_empty() {
2862 validate_public_id(workspace_id)?;
2863 }
2864 validate_public_id(profile_id)?;
2865 validate_public_id(bundle_id)?;
2866 validate_public_id(target_id)?;
2867 if let Some(title) = title {
2868 validate_title(title)?;
2869 }
2870 if dirty_ack.len() > MAX_DIRTY_ACKNOWLEDGEMENTS
2874 || dirty_ack
2875 .iter()
2876 .any(|repository| repository.trim().is_empty() || repository.len() > 256)
2877 {
2878 return Err(ApiError::bad_request(
2879 "dirty acknowledgement must name 0-32 repositories",
2880 ));
2881 }
2882 require_profile(snapshot, profile_id)?;
2883 require_bundle(snapshot, bundle_id)?;
2884 let target = require_target(snapshot, target_id)?;
2885 if target.requires_project_directory != project_directory.is_some() {
2886 return Err(ApiError::bad_request(
2887 "project_directory is required exactly for bare targets",
2888 ));
2889 }
2890 if let Some(directory) = project_directory
2891 && (hel::hel_path_input::validate_absolute_input(directory).is_err()
2892 || directory
2893 .components()
2894 .any(|component| component == Component::ParentDir))
2895 {
2896 return Err(ApiError::bad_request(
2897 "project_directory must be an absolute safe path",
2898 ));
2899 }
2900 }
2901 ControllerAction::Resume {
2902 session_id,
2903 workspace_id,
2904 profile_id,
2905 target_id,
2906 additional_mounts,
2907 resource_allocation,
2908 ..
2909 } => {
2910 validate_public_id(session_id)?;
2911 validate_public_id(workspace_id)?;
2912 validate_public_id(profile_id)?;
2913 validate_public_id(target_id)?;
2914 let session = require_session_record(snapshot, session_id)?;
2915 require_workspace(snapshot, workspace_id)?;
2916 require_profile(snapshot, profile_id)?;
2917 require_target(snapshot, target_id)?;
2918 if session
2919 .incompatible_resume_targets
2920 .iter()
2921 .any(|incompatible| incompatible == target_id)
2922 {
2923 return Err(ApiError::bad_request(
2924 "this session cannot resume on that target",
2925 ));
2926 }
2927 validate_resume_settings(additional_mounts.as_ref(), resource_allocation.as_ref())?;
2928 }
2929 ControllerAction::Move { request } => validate_move_request(request, snapshot)?,
2930 ControllerAction::Open { session_id }
2931 | ControllerAction::Close { session_id }
2932 | ControllerAction::Cancel { session_id }
2933 | ControllerAction::StartReview { session_id } => {
2934 validate_public_id(session_id)?;
2935 require_session_record(snapshot, session_id)?;
2936 }
2937 ControllerAction::ResolveReview {
2938 session_id,
2939 resolution,
2940 } => {
2941 validate_public_id(session_id)?;
2942 let session = require_session_record(snapshot, session_id)?;
2943 let Some(resolution) = resolution_from_name(resolution) else {
2944 return Err(ApiError::bad_request(
2945 "a review is resolved by forward, dismiss, or cancel",
2946 ));
2947 };
2948 let Some(review) = session.turn_review.as_ref() else {
2949 return Err(ApiError::bad_request("no review is open for that session"));
2950 };
2951 let allowed = resolution == hel::hel_review::driver::Resolution::Cancelled
2955 || review.verdict.as_ref().is_some_and(|verdict| {
2956 resolution_name(&resolution)
2957 .is_some_and(|name| verdict.allowed.iter().any(|allowed| allowed == name))
2958 });
2959 if !allowed {
2960 return Err(ApiError::bad_request(
2961 "that review cannot be resolved that way yet",
2962 ));
2963 }
2964 }
2965 ControllerAction::Rename { session_id, title } => {
2966 validate_public_id(session_id)?;
2967 validate_title(title)?;
2968 let session = require_session_record(snapshot, session_id)?;
2969 if !session.capabilities.rename {
2970 return Err(ApiError::bad_request("this session cannot be renamed"));
2971 }
2972 }
2973 ControllerAction::CancelTurn { session_id } => {
2974 validate_public_id(session_id)?;
2975 let session = require_session_record(snapshot, session_id)?;
2976 if !session.capabilities.cancel_turn {
2977 return Err(ApiError::new(
2978 StatusCode::CONFLICT,
2979 "this session has no turn to cancel",
2980 ));
2981 }
2982 }
2983 ControllerAction::SetConfig {
2984 session_id,
2985 key,
2986 value,
2987 } => {
2988 validate_public_id(session_id)?;
2989 let session = require_session_record(snapshot, session_id)?;
2990 if !session.capabilities.set_config {
2991 return Err(ApiError::bad_request(
2992 "this session cannot change configuration now",
2993 ));
2994 }
2995 let option = session
2999 .config_options
3000 .iter()
3001 .find(|option| option.key == *key)
3002 .ok_or_else(|| ApiError::bad_request("this agent does not offer that setting"))?;
3003 if !option.choices.iter().any(|choice| choice.value == *value) {
3004 return Err(ApiError::bad_request(
3005 "this agent does not offer that value for that setting",
3006 ));
3007 }
3008 }
3009 ControllerAction::SetPlanMode { session_id, .. } => {
3010 validate_public_id(session_id)?;
3011 let session = require_session_record(snapshot, session_id)?;
3012 if !session.capabilities.set_plan_mode {
3013 return Err(ApiError::bad_request(
3014 "this session cannot change plan mode now",
3015 ));
3016 }
3017 }
3018 ControllerAction::RefreshQuota { profile_id } => {
3019 validate_public_id(profile_id)?;
3020 require_profile(snapshot, profile_id)?;
3021 }
3022 ControllerAction::RefreshCapacity { target_id } => {
3023 validate_public_id(target_id)?;
3024 require_target(snapshot, target_id)?;
3025 }
3026 ControllerAction::Prompt {
3027 session_id,
3028 text,
3029 images,
3030 } => {
3031 validate_public_id(session_id)?;
3032 let session = require_session_record(snapshot, session_id)?;
3033 if images.len() > MAX_PROMPT_IMAGES {
3034 return Err(ApiError::bad_request(
3035 "a prompt may contain at most 10 images",
3036 ));
3037 }
3038 if text.starts_with('!') {
3039 return Err(ApiError::bad_request(
3040 "leading ! is reserved for shell commands",
3041 ));
3042 }
3043 if text.chars().count() > MAX_PROMPT_CHARS {
3044 return Err(ApiError::bad_request(
3045 "prompt must contain 1-65536 characters",
3046 ));
3047 }
3048 if text.trim().is_empty() && images.is_empty() {
3049 return Err(ApiError::bad_request(
3050 "prompt must contain text or an image",
3051 ));
3052 }
3053 if !images.is_empty() && !session.prompt_images_supported {
3054 return Err(ApiError::bad_request(
3055 "this session does not support image prompts",
3056 ));
3057 }
3058 if session.turn_review.is_some() {
3063 return Err(ApiError::bad_request(
3064 crate::hel_review_host::PROMPT_HELD_MESSAGE,
3065 ));
3066 }
3067 }
3068 ControllerAction::RunShell {
3069 session_id,
3070 command,
3071 } => {
3072 validate_public_id(session_id)?;
3073 require_session_record(snapshot, session_id)?;
3074 if command.trim().is_empty() || command.chars().count() > MAX_PROMPT_CHARS {
3075 return Err(ApiError::bad_request(
3076 "shell command must contain 1-65536 characters",
3077 ));
3078 }
3079 }
3080 ControllerAction::CancelShell {
3081 session_id,
3082 shell_command_id,
3083 } => {
3084 validate_public_id(session_id)?;
3085 validate_public_id(shell_command_id)?;
3086 let session = require_session_record(snapshot, session_id)?;
3087 if !session
3088 .active_user_shells
3089 .iter()
3090 .any(|shell| shell.id == *shell_command_id)
3091 {
3092 return Err(ApiError::bad_request("unknown active shell command"));
3093 }
3094 }
3095 ControllerAction::RemoveQueuedPrompt {
3096 session_id,
3097 queue_id,
3098 } => {
3099 validate_public_id(session_id)?;
3100 validate_public_id(queue_id)?;
3101 require_session_record(snapshot, session_id)?;
3102 }
3103 ControllerAction::RespondElicitation {
3104 session_id,
3105 elicitation_id,
3106 response,
3107 } => {
3108 validate_public_id(session_id)?;
3109 validate_public_id(elicitation_id)?;
3110 let session = require_session_record(snapshot, session_id)?;
3111 let request = session
3112 .pending_elicitations
3113 .iter()
3114 .find(|request| request.id == *elicitation_id)
3115 .ok_or_else(|| ApiError::not_found("unknown elicitation"))?;
3116 if serde_json::to_vec(response).map_or(usize::MAX, |encoded| encoded.len())
3117 > MAX_ELICITATION_BYTES
3118 {
3119 return Err(ApiError::bad_request("elicitation answer is too large"));
3120 }
3121 if request.validate_response(response).is_err() {
3126 return Err(ApiError::bad_request(
3127 "the answer does not match this elicitation request",
3128 ));
3129 }
3130 }
3131 }
3132 Ok(())
3133}
3134
3135fn validate_public_id(id: &str) -> Result<(), ApiError> {
3136 validate_id("request", id).map_err(|_| ApiError::bad_request("invalid id"))
3137}
3138
3139fn validate_title(title: &str) -> Result<(), ApiError> {
3140 if title.trim().is_empty() || title.chars().count() > MAX_TITLE_CHARS {
3141 Err(ApiError::bad_request("title must contain 1-120 characters"))
3142 } else {
3143 Ok(())
3144 }
3145}
3146
3147fn require_session_record<'a>(
3148 snapshot: &'a ViewerSnapshot,
3149 id: &str,
3150) -> Result<&'a ViewerSession, ApiError> {
3151 snapshot
3152 .sessions
3153 .iter()
3154 .find(|session| session.id == id)
3155 .ok_or_else(|| ApiError::not_found("unknown session"))
3156}
3157
3158fn require_workspace(snapshot: &ViewerSnapshot, id: &str) -> Result<(), ApiError> {
3159 snapshot
3160 .workspaces
3161 .iter()
3162 .any(|workspace| workspace.id == id)
3163 .then_some(())
3164 .ok_or_else(|| ApiError::bad_request("unknown workspace"))
3165}
3166
3167fn require_profile<'a>(
3168 snapshot: &'a ViewerSnapshot,
3169 id: &str,
3170) -> Result<&'a ViewerProfile, ApiError> {
3171 snapshot
3172 .profiles
3173 .iter()
3174 .find(|profile| profile.id == id)
3175 .ok_or_else(|| ApiError::bad_request("unknown profile"))
3176}
3177
3178fn require_target<'a>(
3179 snapshot: &'a ViewerSnapshot,
3180 id: &str,
3181) -> Result<&'a ViewerTarget, ApiError> {
3182 snapshot
3183 .targets
3184 .iter()
3185 .find(|target| target.id == id)
3186 .ok_or_else(|| ApiError::bad_request("unknown target"))
3187}
3188
3189fn require_bundle(snapshot: &ViewerSnapshot, id: &str) -> Result<(), ApiError> {
3190 snapshot
3191 .bundles
3192 .iter()
3193 .any(|bundle| bundle.id == id)
3194 .then_some(())
3195 .ok_or_else(|| ApiError::bad_request("unknown bundle"))
3196}
3197
3198#[derive(Debug, Serialize)]
3199struct ErrorBody<'a> {
3200 error: &'a str,
3201}
3202
3203#[derive(Debug)]
3204struct ApiError {
3205 status: StatusCode,
3206 message: &'static str,
3207}
3208
3209impl ApiError {
3210 const fn new(status: StatusCode, message: &'static str) -> Self {
3211 Self { status, message }
3212 }
3213
3214 const fn unauthorized() -> Self {
3215 Self::new(StatusCode::UNAUTHORIZED, "unauthorized")
3216 }
3217
3218 const fn bad_request(message: &'static str) -> Self {
3219 Self::new(StatusCode::BAD_REQUEST, message)
3220 }
3221
3222 const fn not_found(message: &'static str) -> Self {
3223 Self::new(StatusCode::NOT_FOUND, message)
3224 }
3225
3226 const fn controller_unavailable() -> Self {
3227 Self::new(StatusCode::SERVICE_UNAVAILABLE, "controller unavailable")
3228 }
3229}
3230
3231impl IntoResponse for ApiError {
3232 fn into_response(self) -> Response<Body> {
3233 (
3234 self.status,
3235 Json(ErrorBody {
3236 error: self.message,
3237 }),
3238 )
3239 .into_response()
3240 }
3241}
3242
3243fn code_locked(state: &ServerState) -> bool {
3244 state
3245 .code_guard
3246 .lock()
3247 .expect("viewer code guard poisoned")
3248 .locked_at(Instant::now())
3249}
3250
3251fn record_code_failure(state: &ServerState) {
3252 state
3253 .code_guard
3254 .lock()
3255 .expect("viewer code guard poisoned")
3256 .record_failure_at(Instant::now());
3257}
3258
3259fn reset_code_failures(state: &ServerState) {
3260 *state.code_guard.lock().expect("viewer code guard poisoned") = CodeGuard::default();
3261}
3262
3263fn generate_viewer_code() -> AnyResult<String> {
3264 const RANGE: u32 = 1_000_000;
3267 const LIMIT: u32 = u32::MAX - (u32::MAX % RANGE);
3268 loop {
3269 let mut bytes = [0_u8; 4];
3270 getrandom::fill(&mut bytes)
3271 .map_err(|error| anyhow::anyhow!("generate Mjolnir viewer code: {error}"))?;
3272 let value = u32::from_le_bytes(bytes);
3273 if value < LIMIT {
3274 return Ok(format!("{:06}", value % RANGE));
3275 }
3276 }
3277}
3278
3279fn generate_login_token() -> AnyResult<String> {
3280 let mut token = [0_u8; 32];
3281 getrandom::fill(&mut token)
3282 .map_err(|error| anyhow::anyhow!("generate Mjolnir viewer login token: {error}"))?;
3283 Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(token))
3284}
3285
3286fn generate_cookie_key() -> AnyResult<[u8; COOKIE_KEY_BYTES]> {
3287 let mut key = [0_u8; COOKIE_KEY_BYTES];
3288 getrandom::fill(&mut key)
3289 .map_err(|error| anyhow::anyhow!("generate Mjolnir cookie key: {error}"))?;
3290 Ok(key)
3291}
3292
3293fn generate_viewer_id() -> AnyResult<String> {
3301 let mut id = [0_u8; 16];
3302 getrandom::fill(&mut id)
3303 .map_err(|error| anyhow::anyhow!("generate Mjolnir viewer id: {error}"))?;
3304 Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(id))
3305}
3306
3307fn signed_cookie_value(key: &[u8], viewer: &str, expiry: u64) -> String {
3308 let canonical = format!("{viewer}|{expiry}");
3311 let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts arbitrary key lengths");
3312 mac.update(canonical.as_bytes());
3313 let signature =
3314 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
3315 format!("{viewer}.{expiry}.{signature}")
3316}
3317
3318fn legacy_signed_cookie_value(key: &[u8], expiry: u64) -> String {
3324 let canonical = expiry.to_string();
3325 let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts arbitrary key lengths");
3326 mac.update(canonical.as_bytes());
3327 let signature =
3328 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
3329 format!("{canonical}.{signature}")
3330}
3331
3332fn session_cookie_valid(key: &[u8], value: &str, now: u64) -> bool {
3333 cookie_viewer(key, value, now).is_some()
3334}
3335
3336pub fn mint_desktop_session_cookie(key: &[u8]) -> AnyResult<String> {
3343 let viewer = generate_viewer_id()?;
3344 Ok(signed_cookie_value(
3345 key,
3346 &viewer,
3347 now_unix().saturating_add(EPHEMERAL_SESSION_TTL.as_secs()),
3348 ))
3349}
3350
3351fn cookie_viewer(key: &[u8], value: &str, now: u64) -> Option<Option<String>> {
3356 let parts = value.split('.').collect::<Vec<_>>();
3357 let (viewer, expiry, expected) = match parts.as_slice() {
3358 [viewer, expiry, _] => {
3359 let expiry_value = expiry.parse::<u64>().ok()?;
3360 (
3361 Some((*viewer).to_owned()),
3362 expiry_value,
3363 signed_cookie_value(key, viewer, expiry_value),
3364 )
3365 }
3366 [expiry, _] => {
3367 let expiry_value = expiry.parse::<u64>().ok()?;
3368 (
3369 None,
3370 expiry_value,
3371 legacy_signed_cookie_value(key, expiry_value),
3372 )
3373 }
3374 _ => return None,
3375 };
3376 if now >= expiry {
3377 return None;
3378 }
3379 constant_time_eq(expected.as_bytes(), value.as_bytes()).then_some(viewer)
3380}
3381
3382fn session_cookie_header(
3383 value: &str,
3384 max_age: Option<u64>,
3385 secure: bool,
3386) -> Result<HeaderValue, ApiError> {
3387 let mut header = format!("{COOKIE_NAME}={value}; Path=/; HttpOnly; SameSite=Strict");
3388 if secure {
3389 header.push_str("; Secure");
3390 }
3391 if let Some(max_age) = max_age {
3392 header.push_str(&format!("; Max-Age={max_age}"));
3393 }
3394 HeaderValue::from_str(&header)
3395 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "cookie creation failed"))
3396}
3397
3398fn clear_cookie_header(secure: bool) -> HeaderValue {
3399 let secure = if secure { "; Secure" } else { "" };
3400 HeaderValue::from_str(&format!(
3401 "{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Strict{secure}; Max-Age=0"
3402 ))
3403 .expect("static cookie header is valid")
3404}
3405
3406fn viewer_client_id(state: &ServerState, headers: &HeaderMap) -> Option<String> {
3413 let cookie = headers
3414 .get(COOKIE)
3415 .and_then(|value| value.to_str().ok())
3416 .and_then(|header| cookie_value(header, COOKIE_NAME))?;
3417 cookie_viewer(&state.cookie_key, cookie, now_unix())
3418 .flatten()
3419 .map(|viewer| format!("phone:{viewer}"))
3420}
3421
3422fn cookie_value<'a>(header: &'a str, name: &str) -> Option<&'a str> {
3423 header
3424 .split(';')
3425 .filter_map(|part| part.trim().split_once('='))
3426 .find(|(cookie_name, _)| *cookie_name == name)
3427 .map(|(_, value)| value)
3428}
3429
3430fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
3431 if left.len() != right.len() {
3432 return false;
3433 }
3434 left.iter()
3435 .zip(right)
3436 .fold(0_u8, |difference, (left, right)| {
3437 difference | (left ^ right)
3438 })
3439 == 0
3440}
3441
3442fn now_unix() -> u64 {
3443 SystemTime::now()
3444 .duration_since(UNIX_EPOCH)
3445 .map(|elapsed| elapsed.as_secs())
3446 .unwrap_or(u64::MAX)
3447}
3448
3449const fn session_state_name(state: SessionState) -> &'static str {
3450 match state {
3451 SessionState::Provisioning => "provisioning",
3452 SessionState::Running => "running",
3453 SessionState::Disconnected => "disconnected",
3454 SessionState::Checkpointing => "checkpointing",
3455 SessionState::Closing => "closing",
3456 SessionState::Destroying => "destroying",
3457 SessionState::Stopped => "stopped",
3458 SessionState::Lost => "lost",
3459 SessionState::Error => "error",
3460 SessionState::DestroyedWithDataLoss => "destroyed-with-data-loss",
3461 }
3462}
3463
3464const fn target_kind_name(target: &TargetTemplate) -> &'static str {
3465 match target {
3466 TargetTemplate::LocalBare => "local-bare",
3467 TargetTemplate::LocalPodman { .. } => "local-podman",
3468 TargetTemplate::LocalDocker { .. } => "local-docker",
3469 TargetTemplate::AppleContainer { .. } => "apple-container",
3470 TargetTemplate::AwsEc2 { .. } => "aws-ec2",
3471 TargetTemplate::SshBare { .. } => "ssh-bare",
3472 TargetTemplate::SshPodman { .. } => "ssh-podman",
3473 TargetTemplate::SshDocker { .. } => "ssh-docker",
3474 }
3475}
3476
3477const VIEWER_HTML: &str = include_str!("web/viewer.html");
3482const VIEWER_CSS: &str = include_str!("web/viewer.css");
3483const VIEWER_JS: &str = include_str!("web/viewer.js");
3484const MARKDOWN_JS: &str = include_str!("web/markdown.js");
3485const TOOL_OUTPUT_JS: &str = include_str!("web/tool-output.js");
3486const VOICE_WORKLET_JS: &str = include_str!("web/voice-worklet.js");
3487const VOICE_WORKER_JS: &str = include_str!("web/voice-worker.js");
3488#[cfg(test)]
3492const TEST_DOM_JS: &str = include_str!("web/test-dom.js");
3493const SERVICE_WORKER: &str = include_str!("web/service-worker.js");
3494const MANIFEST: &str = include_str!("web/manifest.webmanifest");
3495const ICON_SVG: &str = include_str!("../src/icons/icon.svg");
3496const ICON_192: &[u8] = include_bytes!("../src/icons/icon-192.png");
3497const ICON_512: &[u8] = include_bytes!("../src/icons/icon-512.png");
3498const MASKABLE_512: &[u8] = include_bytes!("../src/icons/maskable-512.png");
3499const APPLE_TOUCH_ICON: &[u8] = include_bytes!("../src/icons/apple-touch-icon.png");
3500const MONO_FONT: &[u8] = include_bytes!("../src/fonts/jetbrains-mono.woff2");
3501
3502const CONTENT_SECURITY_POLICY: &str = "default-src 'none'; \
3510script-src 'self'; \
3511style-src 'self'; \
3512img-src 'self' data: blob:; \
3513font-src 'self'; \
3514connect-src 'self'; \
3515manifest-src 'self'; \
3516base-uri 'none'; \
3517form-action 'none'; \
3518frame-ancestors 'none'";
3519
3520async fn viewer() -> Response<Body> {
3521 static_response("text/html; charset=utf-8", VIEWER_HTML, true)
3522}
3523
3524async fn viewer_css() -> Response<Body> {
3525 static_response("text/css; charset=utf-8", VIEWER_CSS, false)
3526}
3527
3528async fn viewer_js() -> Response<Body> {
3529 static_response("text/javascript; charset=utf-8", VIEWER_JS, false)
3530}
3531
3532async fn markdown_js() -> Response<Body> {
3533 static_response("text/javascript; charset=utf-8", MARKDOWN_JS, false)
3534}
3535
3536async fn voice_worklet_js() -> Response<Body> {
3537 static_response("text/javascript; charset=utf-8", VOICE_WORKLET_JS, false)
3538}
3539
3540async fn voice_worker_js() -> Response<Body> {
3541 static_response("text/javascript; charset=utf-8", VOICE_WORKER_JS, false)
3542}
3543
3544async fn tool_output_js() -> Response<Body> {
3545 static_response("text/javascript; charset=utf-8", TOOL_OUTPUT_JS, false)
3546}
3547
3548async fn manifest() -> Response<Body> {
3549 static_response("application/manifest+json", MANIFEST, false)
3550}
3551
3552async fn service_worker() -> Response<Body> {
3556 static_response("text/javascript; charset=utf-8", SERVICE_WORKER, true)
3557}
3558
3559async fn icon() -> Response<Body> {
3560 static_response("image/svg+xml", ICON_SVG, false)
3561}
3562
3563async fn icon_192() -> Response<Body> {
3564 binary_response("image/png", ICON_192)
3565}
3566
3567async fn icon_512() -> Response<Body> {
3568 binary_response("image/png", ICON_512)
3569}
3570
3571async fn maskable_512() -> Response<Body> {
3572 binary_response("image/png", MASKABLE_512)
3573}
3574
3575async fn apple_touch_icon() -> Response<Body> {
3576 binary_response("image/png", APPLE_TOUCH_ICON)
3577}
3578
3579async fn mono_font() -> Response<Body> {
3580 binary_response("font/woff2", MONO_FONT)
3581}
3582
3583fn static_response(
3584 content_type: &'static str,
3585 body: &'static str,
3586 no_store: bool,
3587) -> Response<Body> {
3588 finish_static(Response::new(Body::from(body)), content_type, no_store)
3589}
3590
3591fn binary_response(content_type: &'static str, body: &'static [u8]) -> Response<Body> {
3592 finish_static(Response::new(Body::from(body)), content_type, false)
3593}
3594
3595fn finish_static(
3599 mut response: Response<Body>,
3600 content_type: &'static str,
3601 no_store: bool,
3602) -> Response<Body> {
3603 let headers = response.headers_mut();
3604 headers.insert(CONTENT_TYPE, HeaderValue::from_static(content_type));
3605 headers.insert(
3606 CACHE_CONTROL,
3607 HeaderValue::from_static(if no_store { "no-store" } else { "no-cache" }),
3608 );
3609 response
3610}
3611
3612async fn security_headers(request: Request, next: Next) -> Response<Body> {
3620 let live = {
3621 let path = request.uri().path();
3622 path.starts_with("/api/") || path.starts_with("/auth/")
3623 };
3624 let mut response = next.run(request).await;
3625 let headers = response.headers_mut();
3626 headers.insert(
3627 CONTENT_SECURITY_POLICY_HEADER,
3628 HeaderValue::from_static(CONTENT_SECURITY_POLICY),
3629 );
3630 headers.insert(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
3631 headers.insert(REFERRER_POLICY, HeaderValue::from_static("no-referrer"));
3632 if live {
3633 headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
3634 }
3635 response
3636}
3637
3638#[cfg(test)]
3639mod tests {
3640 use super::*;
3641 use std::collections::BTreeMap;
3642 use std::path::Path;
3643
3644 use axum::http::Request;
3645 use http_body_util::BodyExt as _;
3646 use tower::ServiceExt as _;
3647
3648 use hel::hel_config::{
3649 CONFIG_VERSION, ContainerTemplate, HarnessKind, HarnessProfile, PermissionMode,
3650 ProjectBundle, ProjectRepository, SshConnection,
3651 };
3652 use hel::hel_state::{ProjectSourceIdentity, STATE_VERSION, SessionRecord};
3653
3654 #[test]
3655 fn unified_tls_backends_use_the_selected_crypto_provider() {
3656 install_rustls_crypto_provider();
3657
3658 assert!(rustls::crypto::CryptoProvider::get_default().is_some());
3659 let _builder = rustls::ServerConfig::builder();
3660 }
3661
3662 #[test]
3663 fn minted_desktop_cookie_validates_and_names_a_viewer() {
3664 let key = vec![7u8; COOKIE_KEY_BYTES];
3665 let value = mint_desktop_session_cookie(&key).unwrap();
3666 let viewer = cookie_viewer(&key, &value, now_unix());
3667 assert!(
3668 matches!(viewer, Some(Some(_))),
3669 "minted cookie must validate and carry a viewer id: {value:?}"
3670 );
3671 assert!(!session_cookie_valid(
3672 &[8u8; COOKIE_KEY_BYTES],
3673 &value,
3674 now_unix()
3675 ));
3676 }
3677
3678 fn sample_config_state() -> (HelConfig, HelState) {
3679 let config = HelConfig {
3680 version: CONFIG_VERSION,
3681 sessions_side: Default::default(),
3682 advanced: Default::default(),
3683 show_stopped_sessions: false,
3684 newer_config_version: None,
3685 spinner: Default::default(),
3686 theme: Default::default(),
3687 phone: Default::default(),
3688 review: Default::default(),
3689 startup: Default::default(),
3690 profiles: BTreeMap::from([(
3691 "codex-1".into(),
3692 HarnessProfile {
3693 enabled: true,
3694 context_window_bytes: None,
3695 kind: HarnessKind::Codex,
3696 home: "/highly/secret/codex".into(),
3697 environment: BTreeMap::from([("GH_TOKEN".into(), "secret-token".into())]),
3698 },
3699 )]),
3700 bundles: BTreeMap::from([(
3701 "hel".into(),
3702 ProjectBundle {
3703 primary_repo: "hel".into(),
3704 repositories: vec![ProjectRepository {
3705 id: "hel".into(),
3706 github: Some("owner/hel".into()),
3707 local: Some("/private/source/hel".into()),
3708 destination: "hel".into(),
3709 git_ref: None,
3710 }],
3711 },
3712 )]),
3713 targets: BTreeMap::from([
3714 (
3715 "podman".into(),
3716 TargetTemplate::LocalPodman {
3717 container: ContainerTemplate {
3718 image: "secret.registry/image".into(),
3719 pull_policy: Default::default(),
3720 platform: None,
3721 cpus: None,
3722 memory: None,
3723 environment: BTreeMap::from([("TOKEN".into(), "secret-target".into())]),
3724 workspace_storage: Default::default(),
3725 },
3726 },
3727 ),
3728 ("raw".into(), TargetTemplate::LocalBare),
3729 ]),
3730 };
3731 let state = HelState {
3732 version: STATE_VERSION,
3733 sessions: BTreeMap::from([(
3734 "session-1".into(),
3735 SessionRecord {
3736 workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
3737 archived: false,
3738 container_cpus: None,
3739 container_memory: None,
3740 id: "session-1".into(),
3741 title: "Build Hel".into(),
3742 harness_kind: HarnessKind::Codex,
3743 last_profile: "codex-1".into(),
3744 bundle_id: "hel".into(),
3745 project_directory: None,
3746 managed_worktree: None,
3747 target_template_id: "podman".into(),
3748 resource_allocation: None,
3749 additional_mounts: vec![],
3750 state: SessionState::Running,
3751 target: None,
3752 native_session_id: Some("native-secret-id".into()),
3753 acp_session_title: Some("Build Hel".into()),
3754 session_title_override: None,
3755 created_at: "now".into(),
3756 updated_at: "now".into(),
3757 viewed_through_event_ordinal: 0,
3758 draft_input: String::new(),
3759 last_error: Some("secret-token at /highly/secret/codex".into()),
3760 last_checkpoint_error: None,
3761 checkpoint: None,
3762 },
3763 )]),
3764 mount_history: BTreeMap::new(),
3765 container_sizes: BTreeMap::new(),
3766 };
3767 (config, state)
3768 }
3769
3770 type TestServer = (
3771 Router,
3772 mpsc::Receiver<ControllerRequest>,
3773 mpsc::Receiver<ReadReceiptRequest>,
3774 mpsc::Receiver<PreflightRequest>,
3775 mpsc::Receiver<ClientStateRequest>,
3776 );
3777
3778 fn app() -> TestServer {
3779 app_with_conversations(BTreeMap::new())
3780 }
3781
3782 fn app_with_move_receiver() -> (Router, mpsc::Receiver<MovePreparationRequest>) {
3783 let (config, state) = sample_config_state();
3784 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
3785 snapshot.sessions[0].capabilities.move_session = true;
3786 let (_snapshot_tx, snapshot_rx) = watch::channel(snapshot);
3787 let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
3788 let (action_tx, _action_rx) = mpsc::channel(8);
3789 let (bundle_tx, _bundle_rx) = mpsc::channel(8);
3790 let (receipt_tx, _receipt_rx) = mpsc::channel(8);
3791 let (preflight_tx, _preflight_rx) = mpsc::channel(8);
3792 let (move_preparation_tx, move_preparation_rx) = mpsc::channel(8);
3793 let (client_state_tx, _client_state_rx) = mpsc::channel(8);
3794 let options = test_options(
3795 snapshot_rx,
3796 conversation_rx,
3797 action_tx,
3798 bundle_tx,
3799 receipt_tx,
3800 preflight_tx,
3801 move_preparation_tx,
3802 client_state_tx,
3803 )
3804 .with_test_credentials("123456", b"01234567890123456789012345678901");
3805 (router(options), move_preparation_rx)
3806 }
3807
3808 fn app_with_conversations(conversations: BTreeMap<String, BrowserTranscript>) -> TestServer {
3809 app_with(conversations, |_| {})
3810 }
3811
3812 fn app_with_snapshot(adjust: impl FnOnce(&mut ViewerSnapshot)) -> TestServer {
3813 app_with(BTreeMap::new(), adjust)
3814 }
3815
3816 fn app_with(
3817 conversations: BTreeMap<String, BrowserTranscript>,
3818 adjust: impl FnOnce(&mut ViewerSnapshot),
3819 ) -> TestServer {
3820 let (config, state) = sample_config_state();
3821 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
3822 adjust(&mut snapshot);
3823 let (_snapshot_tx, snapshot_rx) = watch::channel(snapshot);
3824 let (_conversation_tx, conversation_rx) = watch::channel(conversations);
3825 let (action_tx, action_rx) = mpsc::channel(8);
3826 let (bundle_tx, _bundle_rx) = mpsc::channel(8);
3827 let (receipt_tx, receipt_rx) = mpsc::channel(8);
3828 let (preflight_tx, preflight_rx) = mpsc::channel(8);
3829 let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
3830 let (client_state_tx, client_state_rx) = mpsc::channel(8);
3831 let options = test_options(
3832 snapshot_rx,
3833 conversation_rx,
3834 action_tx,
3835 bundle_tx,
3836 receipt_tx,
3837 preflight_tx,
3838 move_preparation_tx,
3839 client_state_tx,
3840 )
3841 .with_test_credentials("123456", b"01234567890123456789012345678901");
3842 (
3843 router(options),
3844 action_rx,
3845 receipt_rx,
3846 preflight_rx,
3847 client_state_rx,
3848 )
3849 }
3850
3851 fn app_with_bundle_receiver() -> (Router, mpsc::Receiver<BundleRequest>) {
3852 let (config, state) = sample_config_state();
3853 let (_snapshot_tx, snapshot_rx) =
3854 watch::channel(ViewerSnapshot::from_config_state(&config, &state, 1));
3855 let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
3856 let (action_tx, _action_rx) = mpsc::channel(8);
3857 let (bundle_tx, bundle_rx) = mpsc::channel(8);
3858 let (receipt_tx, _receipt_rx) = mpsc::channel(8);
3859 let (preflight_tx, _preflight_rx) = mpsc::channel(8);
3860 let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
3861 let (client_state_tx, _client_state_rx) = mpsc::channel(8);
3862 let options = test_options(
3863 snapshot_rx,
3864 conversation_rx,
3865 action_tx,
3866 bundle_tx,
3867 receipt_tx,
3868 preflight_tx,
3869 move_preparation_tx,
3870 client_state_tx,
3871 )
3872 .with_test_credentials("123456", b"01234567890123456789012345678901");
3873 (router(options), bundle_rx)
3874 }
3875
3876 #[allow(clippy::too_many_arguments)]
3879 fn test_options(
3880 snapshot_rx: watch::Receiver<ViewerSnapshot>,
3881 conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
3882 action_tx: mpsc::Sender<ControllerRequest>,
3883 bundle_tx: mpsc::Sender<BundleRequest>,
3884 receipt_tx: mpsc::Sender<ReadReceiptRequest>,
3885 preflight_tx: mpsc::Sender<PreflightRequest>,
3886 move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
3887 client_state_tx: mpsc::Sender<ClientStateRequest>,
3888 ) -> ServerOptions {
3889 test_options_with_dictation(
3890 snapshot_rx,
3891 conversation_rx,
3892 action_tx,
3893 bundle_tx,
3894 receipt_tx,
3895 preflight_tx,
3896 move_preparation_tx,
3897 client_state_tx,
3898 )
3899 .0
3900 }
3901
3902 #[allow(clippy::too_many_arguments)]
3903 fn test_options_with_dictation(
3904 snapshot_rx: watch::Receiver<ViewerSnapshot>,
3905 conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
3906 action_tx: mpsc::Sender<ControllerRequest>,
3907 bundle_tx: mpsc::Sender<BundleRequest>,
3908 receipt_tx: mpsc::Sender<ReadReceiptRequest>,
3909 preflight_tx: mpsc::Sender<PreflightRequest>,
3910 move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
3911 client_state_tx: mpsc::Sender<ClientStateRequest>,
3912 ) -> (ServerOptions, mpsc::Receiver<DictationRequest>) {
3913 let (dictation_tx, dictation_rx) = mpsc::channel(8);
3914 let options = ServerOptions::new(
3915 "127.0.0.1:0".parse().unwrap(),
3916 snapshot_rx,
3917 conversation_rx,
3918 ServerRequests {
3919 action_tx,
3920 bundle_tx,
3921 receipt_tx,
3922 preflight_tx,
3923 move_preparation_tx,
3924 client_state_tx,
3925 dictation_tx,
3926 },
3927 )
3928 .unwrap();
3929 (options, dictation_rx)
3930 }
3931
3932 fn app_with_dictation_receiver() -> (Router, mpsc::Receiver<DictationRequest>) {
3933 let (config, state) = sample_config_state();
3934 let (_snapshot_tx, snapshot_rx) =
3935 watch::channel(ViewerSnapshot::from_config_state(&config, &state, 1));
3936 let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
3937 let (action_tx, _action_rx) = mpsc::channel(8);
3938 let (bundle_tx, _bundle_rx) = mpsc::channel(8);
3939 let (receipt_tx, _receipt_rx) = mpsc::channel(8);
3940 let (preflight_tx, _preflight_rx) = mpsc::channel(8);
3941 let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
3942 let (client_state_tx, _client_state_rx) = mpsc::channel(8);
3943 let (options, dictation_rx) = test_options_with_dictation(
3944 snapshot_rx,
3945 conversation_rx,
3946 action_tx,
3947 bundle_tx,
3948 receipt_tx,
3949 preflight_tx,
3950 move_preparation_tx,
3951 client_state_tx,
3952 );
3953 (
3954 router(options.with_test_credentials("123456", b"01234567890123456789012345678901")),
3955 dictation_rx,
3956 )
3957 }
3958
3959 fn detached_options() -> ServerOptions {
3960 let (config, state) = sample_config_state();
3961 let (_snapshot_tx, snapshot_rx) =
3962 watch::channel(ViewerSnapshot::from_config_state(&config, &state, 1));
3963 let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
3964 let (action_tx, _action_rx) = mpsc::channel(1);
3965 let (bundle_tx, _bundle_rx) = mpsc::channel(1);
3966 let (receipt_tx, _receipt_rx) = mpsc::channel(1);
3967 let (preflight_tx, _preflight_rx) = mpsc::channel(1);
3968 let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(1);
3969 let (client_state_tx, _client_state_rx) = mpsc::channel(1);
3970 test_options(
3971 snapshot_rx,
3972 conversation_rx,
3973 action_tx,
3974 bundle_tx,
3975 receipt_tx,
3976 preflight_tx,
3977 move_preparation_tx,
3978 client_state_tx,
3979 )
3980 }
3981
3982 fn cookie() -> String {
3988 format!(
3989 "{COOKIE_NAME}={}",
3990 signed_cookie_value(
3991 b"01234567890123456789012345678901",
3992 "test-viewer",
3993 now_unix().saturating_add(3600)
3994 )
3995 )
3996 }
3997
3998 fn valid_wav() -> Bytes {
3999 let samples = vec![0_u8; 320];
4000 let mut wav = Vec::with_capacity(44 + samples.len());
4001 wav.extend_from_slice(b"RIFF");
4002 wav.extend_from_slice(&(36_u32 + samples.len() as u32).to_le_bytes());
4003 wav.extend_from_slice(b"WAVEfmt ");
4004 wav.extend_from_slice(&16_u32.to_le_bytes());
4005 wav.extend_from_slice(&1_u16.to_le_bytes());
4006 wav.extend_from_slice(&1_u16.to_le_bytes());
4007 wav.extend_from_slice(&16_000_u32.to_le_bytes());
4008 wav.extend_from_slice(&32_000_u32.to_le_bytes());
4009 wav.extend_from_slice(&2_u16.to_le_bytes());
4010 wav.extend_from_slice(&16_u16.to_le_bytes());
4011 wav.extend_from_slice(b"data");
4012 wav.extend_from_slice(&(samples.len() as u32).to_le_bytes());
4013 wav.extend_from_slice(&samples);
4014 Bytes::from(wav)
4015 }
4016
4017 async fn login_cookie(app: &Router) -> String {
4018 let response = app
4019 .clone()
4020 .oneshot(
4021 Request::post("/auth/session")
4022 .header(CONTENT_TYPE, "application/json")
4023 .body(Body::from(r#"{"code":"123456"}"#))
4024 .unwrap(),
4025 )
4026 .await
4027 .unwrap();
4028 assert_eq!(response.status(), StatusCode::NO_CONTENT);
4029 response
4030 .headers()
4031 .get(SET_COOKIE)
4032 .unwrap()
4033 .to_str()
4034 .unwrap()
4035 .split(';')
4036 .next()
4037 .unwrap()
4038 .to_string()
4039 }
4040
4041 #[tokio::test]
4042 async fn dictation_availability_requires_auth_and_forwards_typed_request() {
4043 let (app, mut requests) = app_with_dictation_receiver();
4044 let unauthorized = app
4045 .clone()
4046 .oneshot(
4047 Request::get("/api/sessions/session-1/dictation")
4048 .body(Body::empty())
4049 .unwrap(),
4050 )
4051 .await
4052 .unwrap();
4053 assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
4054 assert!(requests.try_recv().is_err());
4055
4056 let cookie = login_cookie(&app).await;
4057 let pending = tokio::spawn({
4058 let app = app.clone();
4059 async move {
4060 app.oneshot(
4061 Request::get("/api/sessions/session-1/dictation")
4062 .header(COOKIE, cookie)
4063 .body(Body::empty())
4064 .unwrap(),
4065 )
4066 .await
4067 .unwrap()
4068 }
4069 });
4070 let request = requests.recv().await.unwrap();
4071 assert_eq!(request.session_id, "session-1");
4072 assert!(matches!(
4073 request.operation,
4074 DictationOperation::Availability
4075 ));
4076 request
4077 .reply
4078 .send(Ok(DictationResponse::Availability {
4079 available: true,
4080 reason: None,
4081 }))
4082 .unwrap();
4083 let response = pending.await.unwrap();
4084 assert_eq!(response.status(), StatusCode::OK);
4085 let body = response.into_body().collect().await.unwrap().to_bytes();
4086 assert_eq!(&body[..], br#"{"available":true}"#);
4087 }
4088
4089 #[tokio::test]
4090 async fn dictation_rejects_bad_wav_before_controller_dispatch() {
4091 let (app, mut requests) = app_with_dictation_receiver();
4092 let cookie = login_cookie(&app).await;
4093 let response = app
4094 .oneshot(
4095 Request::post("/api/sessions/session-1/dictation")
4096 .header(COOKIE, cookie)
4097 .header(CONTENT_TYPE, "audio/wav")
4098 .body(Body::from("not wav"))
4099 .unwrap(),
4100 )
4101 .await
4102 .unwrap();
4103 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4104 assert!(requests.try_recv().is_err());
4105 }
4106
4107 #[tokio::test]
4108 async fn dictation_rejects_a_third_upload_before_reading_its_body() {
4109 let (app, mut requests) = app_with_dictation_receiver();
4110 let cookie = login_cookie(&app).await;
4111 let request = || {
4112 Request::post("/api/sessions/session-1/dictation")
4113 .header(COOKIE, cookie.clone())
4114 .header(CONTENT_TYPE, "audio/wav")
4115 .body(Body::from(valid_wav()))
4116 .unwrap()
4117 };
4118 let first = tokio::spawn({
4119 let app = app.clone();
4120 let request = request();
4121 async move { app.oneshot(request).await.unwrap() }
4122 });
4123 let second = tokio::spawn({
4124 let app = app.clone();
4125 let request = request();
4126 async move { app.oneshot(request).await.unwrap() }
4127 });
4128 let first_request = requests.recv().await.unwrap();
4129 let second_request = requests.recv().await.unwrap();
4130 let third = app
4131 .oneshot(
4132 Request::post("/api/sessions/session-1/dictation")
4133 .header(COOKIE, cookie)
4134 .header(CONTENT_TYPE, "audio/wav")
4135 .body(Body::from_stream(futures::stream::poll_fn(
4136 |_| -> std::task::Poll<Option<Result<Bytes, std::io::Error>>> {
4137 panic!("overloaded dictation polled its body")
4138 },
4139 )))
4140 .unwrap(),
4141 )
4142 .await
4143 .unwrap();
4144 assert_eq!(third.status(), StatusCode::TOO_MANY_REQUESTS);
4145 first_request
4146 .reply
4147 .send(Ok(DictationResponse::Transcript {
4148 text: "first".into(),
4149 }))
4150 .unwrap();
4151 second_request
4152 .reply
4153 .send(Ok(DictationResponse::Transcript {
4154 text: "second".into(),
4155 }))
4156 .unwrap();
4157 assert_eq!(first.await.unwrap().status(), StatusCode::OK);
4158 assert_eq!(second.await.unwrap().status(), StatusCode::OK);
4159 }
4160
4161 #[tokio::test]
4162 async fn dictation_upload_rejects_unauthorized_missing_and_oversized_requests() {
4163 let (app, mut requests) = app_with_dictation_receiver();
4164 let response = app
4165 .clone()
4166 .oneshot(
4167 Request::post("/api/sessions/session-1/dictation")
4168 .body(Body::from(valid_wav()))
4169 .unwrap(),
4170 )
4171 .await
4172 .unwrap();
4173 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
4174 let cookie = login_cookie(&app).await;
4175 let response = app
4176 .clone()
4177 .oneshot(
4178 Request::post("/api/sessions/missing/dictation")
4179 .header(COOKIE, &cookie)
4180 .body(Body::from(valid_wav()))
4181 .unwrap(),
4182 )
4183 .await
4184 .unwrap();
4185 assert_eq!(response.status(), StatusCode::NOT_FOUND);
4186 let response = app
4188 .oneshot(
4189 Request::post("/api/sessions/session-1/dictation")
4190 .header(COOKIE, cookie)
4191 .body(Body::from(vec![0_u8; MAX_AUDIO_BYTES + 1]))
4192 .unwrap(),
4193 )
4194 .await
4195 .unwrap();
4196 assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
4197 assert!(requests.try_recv().is_err());
4198 }
4199
4200 fn app_with_background_stop_receiver(
4201 can_stop: bool,
4202 ) -> (Router, mpsc::Receiver<BackgroundTaskStopRequest>) {
4203 let (config, state) = sample_config_state();
4204 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4205 snapshot.sessions[0].background_tasks = vec![ViewerBackgroundTask {
4206 id: "terminal:background-1".into(),
4207 command: "cargo test".into(),
4208 started_at_ms: 1_000,
4209 can_stop,
4210 }];
4211 let (_snapshot_tx, snapshot_rx) = watch::channel(snapshot);
4212 let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
4213 let (action_tx, _action_rx) = mpsc::channel(8);
4214 let (bundle_tx, _bundle_rx) = mpsc::channel(8);
4215 let (receipt_tx, _receipt_rx) = mpsc::channel(8);
4216 let (preflight_tx, _preflight_rx) = mpsc::channel(8);
4217 let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
4218 let (client_state_tx, _client_state_rx) = mpsc::channel(8);
4219 let (stop_tx, stop_rx) = mpsc::channel(8);
4220 let mut options = test_options(
4221 snapshot_rx,
4222 conversation_rx,
4223 action_tx,
4224 bundle_tx,
4225 receipt_tx,
4226 preflight_tx,
4227 move_preparation_tx,
4228 client_state_tx,
4229 )
4230 .with_test_credentials("123456", b"01234567890123456789012345678901");
4231 options.set_background_task_stop_tx(stop_tx);
4232 (router(options), stop_rx)
4233 }
4234
4235 #[tokio::test]
4236 async fn background_task_stop_validates_the_snapshot_and_waits_for_acknowledgement() {
4237 let (app, mut requests) = app_with_background_stop_receiver(true);
4238 let cookie = login_cookie(&app).await;
4239 let response = tokio::spawn({
4240 let app = app.clone();
4241 let cookie = cookie.clone();
4242 async move {
4243 app.oneshot(
4244 Request::post("/api/sessions/session-1/background-tasks/stop")
4245 .header(COOKIE, cookie)
4246 .header(CONTENT_TYPE, "application/json")
4247 .body(Body::from(
4248 r#"{"background_task_id":"terminal:background-1"}"#,
4249 ))
4250 .unwrap(),
4251 )
4252 .await
4253 .unwrap()
4254 }
4255 });
4256 let request = requests.recv().await.unwrap();
4257 assert_eq!(request.session_id, "session-1");
4258 assert_eq!(request.background_task_id, "terminal:background-1");
4259 request.reply.send(Ok(())).unwrap();
4260 assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
4261
4262 let (app, mut requests) = app_with_background_stop_receiver(false);
4263 let cookie = login_cookie(&app).await;
4264 let response = app
4265 .oneshot(
4266 Request::post("/api/sessions/session-1/background-tasks/stop")
4267 .header(COOKIE, cookie)
4268 .header(CONTENT_TYPE, "application/json")
4269 .body(Body::from(
4270 r#"{"background_task_id":"terminal:background-1"}"#,
4271 ))
4272 .unwrap(),
4273 )
4274 .await
4275 .unwrap();
4276 assert_eq!(response.status(), StatusCode::CONFLICT);
4277 assert!(requests.try_recv().is_err());
4278 }
4279
4280 #[tokio::test]
4281 async fn background_task_stop_reports_provider_failure_without_leaking_details() {
4282 let (app, mut requests) = app_with_background_stop_receiver(true);
4283 let cookie = login_cookie(&app).await;
4284 let response = tokio::spawn({
4285 let app = app.clone();
4286 async move {
4287 app.oneshot(
4288 Request::post("/api/sessions/session-1/background-tasks/stop")
4289 .header(COOKIE, cookie)
4290 .header(CONTENT_TYPE, "application/json")
4291 .body(Body::from(
4292 r#"{"background_task_id":"terminal:background-1"}"#,
4293 ))
4294 .unwrap(),
4295 )
4296 .await
4297 .unwrap()
4298 }
4299 });
4300 let request = requests.recv().await.unwrap();
4301 request
4302 .reply
4303 .send(Err(BackgroundTaskStopFailure::Provider))
4304 .unwrap();
4305 let response = response.await.unwrap();
4306 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
4307 let body = response.into_body().collect().await.unwrap().to_bytes();
4308 assert_eq!(
4309 &body[..],
4310 br#"{"error":"the provider could not stop this background task"}"#
4311 );
4312 }
4313
4314 #[tokio::test]
4315 async fn dictation_provider_failure_is_actionable_and_does_not_expose_details() {
4316 let (app, mut requests) = app_with_dictation_receiver();
4317 let cookie = login_cookie(&app).await;
4318 let pending = tokio::spawn(async move {
4319 app.oneshot(
4320 Request::post("/api/sessions/session-1/dictation")
4321 .header(COOKIE, cookie)
4322 .body(Body::from(valid_wav()))
4323 .unwrap(),
4324 )
4325 .await
4326 .unwrap()
4327 });
4328 let request = requests.recv().await.unwrap();
4329 request
4330 .reply
4331 .send(Err(DictationError::Provider(
4332 "private provider details".into(),
4333 )))
4334 .unwrap();
4335 let response = pending.await.unwrap();
4336 assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
4337 let bytes = response.into_body().collect().await.unwrap().to_bytes();
4338 let body = String::from_utf8(bytes.to_vec()).unwrap();
4339 assert!(body.contains("transcription"));
4340 assert!(!body.contains("private provider details"));
4341 }
4342
4343 #[tokio::test]
4344 async fn dropped_dictation_handler_cancels_controller_request() {
4345 let (app, mut requests) = app_with_dictation_receiver();
4346 let cookie = login_cookie(&app).await;
4347 let pending = tokio::spawn({
4348 let app = app.clone();
4349 async move {
4350 app.oneshot(
4351 Request::post("/api/sessions/session-1/dictation")
4352 .header(COOKIE, cookie)
4353 .body(Body::from(valid_wav()))
4354 .unwrap(),
4355 )
4356 .await
4357 .unwrap()
4358 }
4359 });
4360 let request = requests.recv().await.unwrap();
4361 let cancel = request.cancel.clone();
4362 pending.abort();
4363 let _ = pending.await;
4364 assert!(cancel.is_cancelled());
4365 drop(request);
4366 }
4367
4368 #[tokio::test]
4369 async fn bundle_endpoint_authenticates_and_forwards_the_source() {
4370 let (app, mut bundles) = app_with_bundle_receiver();
4371 let unauthorized = app
4372 .clone()
4373 .oneshot(
4374 Request::post("/api/bundles")
4375 .header(CONTENT_TYPE, "application/json")
4376 .body(Body::from(r#"{"source":"example/app"}"#))
4377 .unwrap(),
4378 )
4379 .await
4380 .unwrap();
4381 assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
4382 assert!(bundles.try_recv().is_err());
4383
4384 let cookie = login_cookie(&app).await;
4385 let response = tokio::spawn({
4386 let app = app.clone();
4387 let cookie = cookie.clone();
4388 async move {
4389 app.oneshot(
4390 Request::post("/api/bundles")
4391 .header(CONTENT_TYPE, "application/json")
4392 .header(COOKIE, cookie)
4393 .body(Body::from(r#"{"source":"example/app"}"#))
4394 .unwrap(),
4395 )
4396 .await
4397 .unwrap()
4398 }
4399 });
4400 let request = bundles.recv().await.expect("bundle request forwarded");
4401 assert_eq!(request.source, "example/app");
4402 request.reply.send(Ok("app".into())).unwrap();
4403 let response = response.await.unwrap();
4404 assert_eq!(response.status(), StatusCode::OK);
4405 let body = response.into_body().collect().await.unwrap().to_bytes();
4406 assert_eq!(body.as_ref(), br#"{"bundle_id":"app"}"#);
4407 }
4408
4409 #[tokio::test]
4410 async fn bundle_endpoint_rejects_empty_and_oversized_sources_before_dispatch() {
4411 for source in [String::new(), "x".repeat(MAX_BUNDLE_SOURCE_CHARS + 1)] {
4412 let (app, mut bundles) = app_with_bundle_receiver();
4413 let cookie = login_cookie(&app).await;
4414 let response = app
4415 .oneshot(
4416 Request::post("/api/bundles")
4417 .header(CONTENT_TYPE, "application/json")
4418 .header(COOKIE, cookie)
4419 .body(Body::from(
4420 serde_json::to_string(&serde_json::json!({"source": source})).unwrap(),
4421 ))
4422 .unwrap(),
4423 )
4424 .await
4425 .unwrap();
4426 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4427 assert!(bundles.try_recv().is_err());
4428 }
4429 }
4430
4431 #[tokio::test]
4432 async fn bundle_endpoint_reports_invalid_source_as_a_client_error() {
4433 let (app, mut bundles) = app_with_bundle_receiver();
4434 let cookie = login_cookie(&app).await;
4435 let response = tokio::spawn({
4436 let app = app.clone();
4437 async move {
4438 app.oneshot(
4439 Request::post("/api/bundles")
4440 .header(CONTENT_TYPE, "application/json")
4441 .header(COOKIE, cookie)
4442 .body(Body::from(r#"{"source":"not a source"}"#))
4443 .unwrap(),
4444 )
4445 .await
4446 .unwrap()
4447 }
4448 });
4449 let request = bundles.recv().await.expect("bundle request forwarded");
4450 request
4451 .reply
4452 .send(Err(BundleFailure::InvalidSource))
4453 .unwrap();
4454 let response = response.await.unwrap();
4455 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4456 let body = response.into_body().collect().await.unwrap().to_bytes();
4457 assert!(String::from_utf8_lossy(&body).contains("GitHub owner/repository"));
4458 }
4459
4460 #[tokio::test]
4461 async fn api_requires_a_valid_signed_cookie() {
4462 let (app, _, _, _, _) = app();
4463 let unauthorized = app
4464 .clone()
4465 .oneshot(Request::get("/api/snapshot").body(Body::empty()).unwrap())
4466 .await
4467 .unwrap();
4468 assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
4469
4470 let cookie = login_cookie(&app).await;
4471 let authorized = app
4472 .oneshot(
4473 Request::get("/api/snapshot")
4474 .header(COOKIE, cookie)
4475 .body(Body::empty())
4476 .unwrap(),
4477 )
4478 .await
4479 .unwrap();
4480 assert_eq!(authorized.status(), StatusCode::OK);
4481 }
4482
4483 #[tokio::test]
4484 async fn qr_login_exchanges_the_secret_for_a_cookie_and_redirects_cleanly() {
4485 let (app, _, _, _, _) = app();
4486 let rejected = app
4487 .clone()
4488 .oneshot(
4489 Request::get("/auth/login?token=wrong")
4490 .body(Body::empty())
4491 .unwrap(),
4492 )
4493 .await
4494 .unwrap();
4495 assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED);
4496
4497 let accepted = app
4498 .oneshot(
4499 Request::get("/auth/login?token=test-login-token")
4500 .body(Body::empty())
4501 .unwrap(),
4502 )
4503 .await
4504 .unwrap();
4505 assert_eq!(accepted.status(), StatusCode::SEE_OTHER);
4506 assert_eq!(accepted.headers().get(LOCATION).unwrap(), "/");
4507 assert_eq!(accepted.headers().get(CACHE_CONTROL).unwrap(), "no-store");
4508 assert!(accepted.headers().contains_key(SET_COOKIE));
4509 }
4510
4511 #[test]
4512 fn signed_cookie_rejects_expiry_and_tampering() {
4513 let key = b"01234567890123456789012345678901";
4514 let cookie = signed_cookie_value(key, "test-viewer", 200);
4515 assert!(session_cookie_valid(key, &cookie, 100));
4516 assert!(!session_cookie_valid(key, &cookie, 200));
4517 assert!(!session_cookie_valid(key, &format!("{cookie}x"), 100));
4518 assert!(!session_cookie_valid(b"another-key", &cookie, 100));
4519 }
4520
4521 #[test]
4522 fn generated_code_and_cookie_attributes_are_phone_safe() {
4523 let code = generate_viewer_code().unwrap();
4524 assert_eq!(code.len(), 6);
4525 assert!(code.bytes().all(|byte| byte.is_ascii_digit()));
4526 let header = session_cookie_header("signed", Some(60), true)
4527 .unwrap()
4528 .to_str()
4529 .unwrap()
4530 .to_string();
4531 assert!(header.contains("HttpOnly"));
4532 assert!(header.contains("SameSite=Strict"));
4533 assert!(header.contains("Secure"));
4534 assert!(header.contains("Max-Age=60"));
4535 }
4536
4537 #[test]
4538 fn public_snapshot_omits_homes_environment_locators_and_raw_errors() {
4539 let (config, state) = sample_config_state();
4540 let json =
4541 serde_json::to_string(&ViewerSnapshot::from_config_state(&config, &state, 9)).unwrap();
4542 assert!(!json.contains("/highly/secret"));
4543 assert!(!json.contains("secret-token"));
4544 assert!(!json.contains("secret-target"));
4545 assert!(!json.contains("secret.registry"));
4546 assert!(!json.contains("native-secret-id"));
4547 assert!(json.contains("\"has_error\":true"));
4548 }
4549
4550 #[test]
4551 fn public_snapshot_keeps_running_sessions_but_omits_disabled_profiles() {
4552 let (mut config, state) = sample_config_state();
4553 config.profiles.get_mut("codex-1").unwrap().enabled = false;
4554
4555 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 9);
4556
4557 assert!(snapshot.profiles.is_empty());
4558 assert_eq!(snapshot.sessions.len(), 1);
4559 assert_eq!(snapshot.sessions[0].profile_id, "codex-1");
4560 }
4561
4562 #[test]
4563 fn target_snapshot_uses_each_raw_host_project_history_and_leaves_managed_empty() {
4564 let (mut config, mut state) = sample_config_state();
4565 config
4566 .targets
4567 .insert("raw-local".into(), TargetTemplate::LocalBare);
4568 config.targets.insert(
4569 "raw-builder".into(),
4570 TargetTemplate::SshBare {
4571 ssh: SshConnection {
4572 host: "builder-a".into(),
4573 user: None,
4574 identity_file: None,
4575 extra_args: Vec::new(),
4576 },
4577 permissions: PermissionMode::Guardian,
4578 workspace_prefix: "workspaces".into(),
4579 },
4580 );
4581 state.remember_project_directory("local", Path::new("/work/local"));
4582 state.remember_project_directory("builder-a", Path::new("/srv/builder"));
4583 state.remember_project_directory("other-host", Path::new("/not-published"));
4584
4585 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4586 let target = |id: &str| {
4587 snapshot
4588 .targets
4589 .iter()
4590 .find(|target| target.id == id)
4591 .unwrap()
4592 };
4593 assert_eq!(
4594 target("raw-local").recent_project_directories,
4595 vec!["/work/local"]
4596 );
4597 assert_eq!(
4598 target("raw-builder").recent_project_directories,
4599 vec!["/srv/builder"]
4600 );
4601 assert!(target("podman").recent_project_directories.is_empty());
4602 }
4603
4604 #[test]
4605 fn public_snapshot_exposes_only_review_status_configuration() {
4606 let (mut config, state) = sample_config_state();
4607 config.review = hel::hel_config::ReviewConfig {
4608 enabled: true,
4609 tier: hel::hel_review::lanes::ReviewTier::Extended,
4610 profile: Some("reviewer-1".into()),
4611 model: Some("private-review-model".into()),
4612 effort: Some("private-review-effort".into()),
4613 };
4614
4615 let value =
4616 serde_json::to_value(ViewerSnapshot::from_config_state(&config, &state, 9)).unwrap();
4617
4618 assert_eq!(
4619 value.get("review_config"),
4620 Some(&serde_json::json!({
4621 "enabled": true,
4622 "tier": "extended",
4623 "profile": "reviewer-1",
4624 }))
4625 );
4626 let json = value.to_string();
4627 assert!(!json.contains("private-review-model"));
4628 assert!(!json.contains("private-review-effort"));
4629 }
4630
4631 fn sample_elicitation() -> ElicitationRequest {
4632 ElicitationRequest::from_acp_params(
4633 "elicitation-1",
4634 serde_json::json!({
4635 "sessionId": "session-1",
4636 "mode": "form",
4637 "message": "Which CI architecture should the workflow use?",
4638 "requestedSchema": {
4639 "type": "object",
4640 "required": ["question_0"],
4641 "properties": {
4642 "question_0": {
4643 "type": "string",
4644 "title": "CI architecture",
4645 "oneOf": [
4646 {"const": "reusable", "title": "Reusable workflow"},
4647 {"const": "matrix", "title": "Matrix job"}
4648 ]
4649 },
4650 "question_0_custom": {
4651 "type": "string",
4652 "title": "Other",
4653 "_meta": {"_askUserQuestionCustomAnswer": {
4654 "questionId": "question_0",
4655 "isCustomAnswer": true
4656 }}
4657 }
4658 }
4659 }
4660 }),
4661 )
4662 .expect("sample elicitation parses")
4663 }
4664
4665 fn accept(pairs: &[(&str, &str)]) -> ElicitationResponse {
4666 ElicitationResponse::Accept {
4667 content: pairs
4668 .iter()
4669 .map(|(id, value)| {
4670 (
4671 (*id).to_owned(),
4672 hel::hel_elicitation::ElicitationValue::String((*value).to_owned()),
4673 )
4674 })
4675 .collect(),
4676 }
4677 }
4678
4679 fn pending_elicitation_snapshot(snapshot: &mut ViewerSnapshot) {
4680 snapshot.sessions[0].pending_elicitations = vec![sample_elicitation()];
4681 }
4682
4683 #[tokio::test]
4684 async fn elicitation_answer_is_typed_and_forwarded() {
4685 let (app, mut actions, _, _, _) = app_with_snapshot(pending_elicitation_snapshot);
4686 let cookie = login_cookie(&app).await;
4687 let response = tokio::spawn(
4688 app.oneshot(
4689 Request::post("/api/actions")
4690 .header(COOKIE, cookie)
4691 .header(CONTENT_TYPE, "application/json")
4692 .body(Body::from(
4693 r#"{"action":"respond-elicitation","session_id":"session-1","elicitation_id":"elicitation-1","response":{"action":"accept","content":{"question_0":"reusable"}}}"#,
4694 ))
4695 .unwrap(),
4696 ),
4697 );
4698 let action = actions.recv().await.unwrap();
4699 assert_eq!(
4700 action.action,
4701 ControllerAction::RespondElicitation {
4702 session_id: "session-1".into(),
4703 elicitation_id: "elicitation-1".into(),
4704 response: accept(&[("question_0", "reusable")]),
4705 }
4706 );
4707 action.reply.send(ActionOutcome::Accepted).unwrap();
4708 assert_eq!(
4709 response.await.unwrap().unwrap().status(),
4710 StatusCode::ACCEPTED
4711 );
4712 }
4713
4714 #[tokio::test]
4715 async fn elicitation_answer_for_an_unknown_request_is_refused_without_reaching_the_controller()
4716 {
4717 let (app, mut actions, _, _, _) = app_with_snapshot(pending_elicitation_snapshot);
4718 let cookie = login_cookie(&app).await;
4719 let response = app
4720 .oneshot(
4721 Request::post("/api/actions")
4722 .header(COOKIE, cookie)
4723 .header(CONTENT_TYPE, "application/json")
4724 .body(Body::from(
4725 r#"{"action":"respond-elicitation","session_id":"session-1","elicitation_id":"elicitation-9","response":{"action":"cancel"}}"#,
4726 ))
4727 .unwrap(),
4728 )
4729 .await
4730 .unwrap();
4731 assert_eq!(response.status(), StatusCode::NOT_FOUND);
4732 assert!(actions.try_recv().is_err());
4733 }
4734
4735 #[test]
4736 fn elicitation_answers_are_checked_against_the_request_the_agent_asked() {
4737 let (config, state) = sample_config_state();
4738 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4739 pending_elicitation_snapshot(&mut snapshot);
4740 let respond = |response: ElicitationResponse| ControllerAction::RespondElicitation {
4741 session_id: "session-1".into(),
4742 elicitation_id: "elicitation-1".into(),
4743 response,
4744 };
4745
4746 assert!(validate_action(&respond(accept(&[("question_0", "matrix")])), &snapshot).is_ok());
4747 assert!(validate_action(&respond(ElicitationResponse::Decline), &snapshot).is_ok());
4750 assert!(validate_action(&respond(accept(&[("question_0", "cron")])), &snapshot).is_err());
4753 assert!(validate_action(&respond(accept(&[("smuggled", "yes")])), &snapshot).is_err());
4754 assert!(validate_action(&respond(accept(&[])), &snapshot).is_err());
4755 assert!(
4758 validate_action(
4759 &respond(accept(&[("question_0_custom", "a monorepo pipeline")])),
4760 &snapshot,
4761 )
4762 .is_ok()
4763 );
4764 }
4765
4766 #[test]
4767 fn oversized_elicitation_answers_are_refused() {
4768 let (config, state) = sample_config_state();
4769 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4770 pending_elicitation_snapshot(&mut snapshot);
4771 let long = "x".repeat(MAX_ELICITATION_BYTES);
4772 assert!(
4773 validate_action(
4774 &ControllerAction::RespondElicitation {
4775 session_id: "session-1".into(),
4776 elicitation_id: "elicitation-1".into(),
4777 response: accept(&[("question_0_custom", long.as_str())]),
4778 },
4779 &snapshot,
4780 )
4781 .is_err()
4782 );
4783 }
4784
4785 fn viewer_source(from: &str, to: &str) -> &'static str {
4793 let start = VIEWER_JS
4794 .find(from)
4795 .unwrap_or_else(|| panic!("src/web/viewer.js no longer contains {from:?}"));
4796 let end = VIEWER_JS[start..]
4797 .find(to)
4798 .map(|offset| start + offset)
4799 .unwrap_or_else(|| {
4800 panic!("src/web/viewer.js no longer contains {to:?} after {from:?}")
4801 });
4802 &VIEWER_JS[start..end]
4803 }
4804
4805 fn run_web_check(name: &str, check: &str) {
4812 let directory = tempfile::tempdir().expect("temporary directory for a web check");
4813 for (file, source) in [
4814 ("test-dom.js", TEST_DOM_JS),
4815 ("markdown.js", MARKDOWN_JS),
4816 ("tool-output.js", TOOL_OUTPUT_JS),
4817 ] {
4818 std::fs::write(directory.path().join(file), source).expect("write a web module");
4819 }
4820 let path = directory.path().join(format!("{name}.mjs"));
4821 std::fs::write(&path, check).expect("write the web check");
4822 let output = std::process::Command::new("node")
4823 .arg(&path)
4824 .output()
4825 .expect("Node.js is required to exercise the web viewer");
4826 assert!(
4827 output.status.success(),
4828 "{name} failed:\nstdout:\n{}\nstderr:\n{}",
4829 String::from_utf8_lossy(&output.stdout),
4830 String::from_utf8_lossy(&output.stderr),
4831 );
4832 }
4833
4834 fn run_viewer_script(name: &str, script: &str) {
4838 run_web_check(name, script);
4839 }
4840
4841 #[test]
4842 fn web_preflight_applies_resolved_path_and_ignores_cancelled_reply() {
4843 let source = viewer_source(
4844 "async function preflightNew()",
4845 "async function advanceNew()",
4846 );
4847 let setup = r#"
4848let newDraft = { targetId: 'remote', profileId: 'codex', bundleId: 'bundle', projectDirectory: '~/project', projectDirectories: {} };
4849let pendingNewPreflight = null, pendingNewPreflightController = null;
4850function targetIsBare() { return true; }
4851function renderNewForm() {}
4852function selectedWorkspaceId() { return 'workspace'; }
4853let resolveRequest;
4854function request() { return new Promise(resolve => { resolveRequest = resolve; }); }
4855"#;
4856 let checks = r#"
4857let pending = preflightNew();
4858resolveRequest({ project_directory: '/remote/project' });
4859if (!await pending || newDraft.projectDirectory !== '/remote/project' || newDraft.projectDirectories.remote !== '/remote/project') throw Error('resolved path was not applied');
4860newDraft.projectDirectory = '~/newer';
4861pending = preflightNew();
4862pendingNewPreflightController.abort();
4863resolveRequest({ project_directory: '/remote/stale' });
4864if (await pending || newDraft.projectDirectory !== '~/newer') throw Error('cancelled reply replaced draft');
4865"#;
4866 run_viewer_script("path-preflight", &format!("{setup}\n{source}\n{checks}"));
4867 }
4868
4869 #[test]
4870 fn embedded_viewer_lists_current_workspace_histories_and_retained_move_recovery() {
4871 let source = viewer_source("function isResumeSession(", "const resumeDrafts =");
4872 let setup = r#"
4873const snapshot = {
4874 sessions: [
4875 { id: "history-a", workspace_id: "workspace-a", capabilities: { resume: true } },
4876 { id: "history-b", workspace_id: "workspace-b", capabilities: { resume: true } },
4877 { id: "running-a", workspace_id: "workspace-a", lifecycle: "live", has_error: true, capabilities: { resume: false, open: false } },
4878 { id: "move-a", workspace_id: "workspace-a", capabilities: { resume: false }, move_recovery: { checkpoint_retained: true, phase: "failed" } },
4879 { id: "moving-a", workspace_id: "workspace-a", capabilities: { resume: false }, move_recovery: { checkpoint_retained: true, phase: "starting_queue" } },
4880 ],
4881};
4882function selectedWorkspaceId() { return "workspace-a"; }
4883function sessionActivityMs() { return 0; }
4884function epochMs() { return null; }
4885"#;
4886 let checks = r#"
4887const ids = workspace => resumeSessions(workspace).map(session => session.id).sort();
4888if (JSON.stringify(ids("workspace-a")) !== JSON.stringify(["history-a", "move-a"])) {
4889 throw new Error(`workspace A histories or recoveries were wrong: ${JSON.stringify(ids("workspace-a"))}`);
4890}
4891if (JSON.stringify(ids("workspace-b")) !== JSON.stringify(["history-b"])) {
4892 throw new Error(`workspace B histories were wrong: ${JSON.stringify(ids("workspace-b"))}`);
4893}
4894if (ids("missing-workspace").length !== 0) throw new Error("unknown workspace exposed sessions");
4895"#;
4896 run_viewer_script(
4897 "workspace-resume-history",
4898 &format!("{setup}\n{source}\n{checks}"),
4899 );
4900 }
4901
4902 #[test]
4903 fn embedded_viewer_sends_the_selected_resume_workspace() {
4904 let source = viewer_source("async function runSessionAction", "sessions.onclick =");
4905 let setup = r#"
4906const pendingActions = new Set();
4907const snapshot = { sessions: [] };
4908let sent = null;
4909function selectedWorkspaceId() { return "workspace-b"; }
4910function navigate() {}
4911function renderRoute() {}
4912async function refresh() {}
4913async function request(path, options) {
4914 sent = { path, body: JSON.parse(options.body) };
4915}
4916"#;
4917 let checks = r#"
4918const errorNode = { textContent: "" };
4919await runSessionAction(
4920 { action: "resume", id: "history-a", profile: "codex-1", target: "podman" },
4921 errorNode,
4922 { queue: "start" },
4923);
4924if (sent.path !== "/api/actions" || sent.body.workspace_id !== "workspace-b") {
4925 throw new Error(`resume did not carry its destination: ${JSON.stringify(sent)}`);
4926}
4927"#;
4928 run_viewer_script(
4929 "resume-workspace-destination",
4930 &format!("{setup}\n{source}\n{checks}"),
4931 );
4932 }
4933
4934 #[test]
4935 fn embedded_viewer_warns_before_stopping_an_active_session() {
4936 let source = viewer_source("async function runSessionAction", "sessions.onclick =");
4937 let setup = r#"
4938const pendingActions = new Set();
4939const snapshot = {
4940 sessions: [
4941 { id: "active", chat_phase: "running" },
4942 { id: "idle", chat_phase: "idle" },
4943 ],
4944};
4945const questions = [];
4946function confirm(question) { questions.push(question); return false; }
4947function navigate() {}
4948"#;
4949 let checks = r#"
4950const errorNode = { textContent: "" };
4951await runSessionAction({ action: "close", id: "active" }, errorNode);
4952await runSessionAction({ action: "close", id: "idle" }, errorNode);
4953if (!questions[0].startsWith("Stop active session?\n\n")) {
4954 throw new Error(`active close warning was ${JSON.stringify(questions[0])}`);
4955}
4956if (!questions[0].includes("current turn will be interrupted")) {
4957 throw new Error(`active close omitted interruption: ${JSON.stringify(questions[0])}`);
4958}
4959if (!questions[1].startsWith("Stop session?\n\n")) {
4960 throw new Error(`idle close warning was ${JSON.stringify(questions[1])}`);
4961}
4962"#;
4963 run_viewer_script(
4964 "active-session-stop-confirmation",
4965 &format!("{setup}\n{source}\n{checks}"),
4966 );
4967 }
4968
4969 #[test]
4974 fn the_project_key_groups_without_naming_a_path() {
4975 let (config, mut state) = sample_config_state();
4976 let first = state.sessions["session-1"].clone();
4977 let mut second = first.clone();
4978 second.id = "session-2".into();
4979 state.sessions.insert(second.id.clone(), second);
4980 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4981
4982 let keys = snapshot
4983 .sessions
4984 .iter()
4985 .map(|session| session.project_key.as_str())
4986 .collect::<std::collections::BTreeSet<_>>();
4987 assert_eq!(keys.len(), 1, "two sessions in one project did not group");
4988 let key = keys.into_iter().next().expect("one key");
4989 assert!(!key.is_empty(), "the project key is empty");
4990 assert!(
4991 !key.contains('/') && !key.contains("hel"),
4992 "the project key leaks its identity: {key}"
4993 );
4994 assert_eq!(
4995 snapshot.sessions[0].project_label, "hel",
4996 "the project label should be a name a person recognises"
4997 );
4998 }
4999
5000 #[test]
5001 fn web_project_keys_follow_the_complete_repository_set() {
5002 let (mut config, mut state) = sample_config_state();
5003 let shared_bundle = config.bundles["hel"].clone();
5004 config.bundles.insert("other".into(), shared_bundle);
5005
5006 let mut other = state.sessions["session-1"].clone();
5007 other.id = "session-2".into();
5008 other.bundle_id = "other".into();
5009 state.sessions.insert(other.id.clone(), other);
5010
5011 assert_eq!(
5012 config.bundles["hel"].primary_repo,
5013 config.bundles["other"].primary_repo
5014 );
5015 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
5016 let first = snapshot
5017 .sessions
5018 .iter()
5019 .find(|session| session.id == "session-1")
5020 .expect("first session");
5021 let second = snapshot
5022 .sessions
5023 .iter()
5024 .find(|session| session.id == "session-2")
5025 .expect("second session");
5026
5027 assert_eq!(first.project_label, "hel");
5028 assert_eq!(second.project_label, "hel");
5029 assert_eq!(first.project_key, second.project_key);
5030
5031 let secondary = ProjectRepository {
5032 id: "secondary".into(),
5033 github: Some("owner/secondary".into()),
5034 local: None,
5035 destination: "secondary".into(),
5036 git_ref: None,
5037 };
5038 config
5039 .bundles
5040 .get_mut("other")
5041 .unwrap()
5042 .repositories
5043 .push(secondary.clone());
5044 let project_keys = |config: &HelConfig| {
5045 ViewerSnapshot::from_config_state(config, &state, 1)
5046 .sessions
5047 .into_iter()
5048 .map(|session| session.project_key)
5049 .collect::<Vec<_>>()
5050 };
5051 let keys = project_keys(&config);
5052 assert_ne!(
5053 keys[0], keys[1],
5054 "an added repository must change the bundle identity"
5055 );
5056
5057 let first_bundle = config.bundles.get_mut("hel").unwrap();
5058 first_bundle.repositories.insert(0, secondary);
5059 first_bundle.primary_repo = "secondary".into();
5060 let keys = project_keys(&config);
5061 assert_eq!(
5062 keys[0], keys[1],
5063 "the same repository set must group together despite order or primary choice"
5064 );
5065 }
5066
5067 #[test]
5068 fn viewer_session_applies_a_resolved_source_without_publishing_it() {
5069 let (config, state) = sample_config_state();
5070 let mut viewer = ViewerSnapshot::from_config_state(&config, &state, 1)
5071 .sessions
5072 .into_iter()
5073 .next()
5074 .expect("session");
5075 let source = ProjectSourceIdentity::git_remote("git@github.com:BrokkAi/bifrost-dev.git")
5076 .expect("GitHub source");
5077
5078 viewer.set_project_source(&source);
5079
5080 assert_eq!(viewer.project_label, "bifrost-dev");
5081 assert_eq!(viewer.project_key, project_key(&source.key));
5082 let json = serde_json::to_string(&viewer).expect("serialize viewer session");
5083 assert!(!json.contains("BrokkAi"));
5084 assert!(!json.contains("github.com"));
5085 }
5086
5087 #[test]
5090 fn lifecycle_categories_decide_what_the_dashboard_shows() {
5091 use ViewerLifecycleCategory::{Failed, Live, Starting, Stopped, Stopping};
5092
5093 for (state, expected, on_dashboard) in [
5094 (SessionState::Provisioning, Starting, true),
5095 (SessionState::Running, Live, true),
5096 (SessionState::Disconnected, Live, true),
5097 (SessionState::Checkpointing, Live, true),
5098 (SessionState::Closing, Stopping, true),
5099 (SessionState::Destroying, Stopping, true),
5100 (SessionState::Stopped, Stopped, false),
5101 (SessionState::Lost, Failed, false),
5102 (SessionState::Error, Failed, false),
5103 (SessionState::DestroyedWithDataLoss, Failed, false),
5104 ] {
5105 let category = ViewerLifecycleCategory::of(state);
5106 assert_eq!(category, expected, "{state:?}");
5107 assert_eq!(
5108 category.is_dashboard_visible(),
5109 on_dashboard,
5110 "{state:?} belongs on the dashboard? "
5111 );
5112 }
5113 }
5114
5115 #[test]
5119 fn compatible_resume_targets_are_the_complement_of_the_incompatible_ones() {
5120 let (config, state) = sample_config_state();
5121 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
5122 let session = &snapshot.sessions[0];
5123 let all = config.targets.keys().cloned().collect::<Vec<_>>();
5124
5125 for target in &all {
5126 assert_ne!(
5127 session.compatible_resume_targets.contains(target),
5128 session.incompatible_resume_targets.contains(target),
5129 "target {target} is in both lists or neither"
5130 );
5131 }
5132 assert_eq!(
5133 session.compatible_resume_targets.len() + session.incompatible_resume_targets.len(),
5134 all.len(),
5135 "the two lists do not cover every target"
5136 );
5137 }
5138
5139 #[tokio::test]
5143 async fn actions_are_refused_when_their_capability_is_false() {
5144 for (body, capability) in [
5145 (
5146 r#"{"action":"cancel-turn","session_id":"session-1"}"#,
5147 "cancel_turn",
5148 ),
5149 (
5150 r#"{"action":"set-plan-mode","session_id":"session-1","active":true}"#,
5151 "set_plan_mode",
5152 ),
5153 (
5154 r#"{"action":"set-config","session_id":"session-1","key":"model","value":"x"}"#,
5155 "set_config",
5156 ),
5157 ] {
5158 let (app, mut actions, _, _, _) = app();
5159 let response = post_action(app, cookie(), body.to_owned()).await;
5160 assert!(
5161 response.status().is_client_error(),
5162 "{capability} was accepted while false: {}",
5163 response.status()
5164 );
5165 assert!(
5166 actions.try_recv().is_err(),
5167 "{capability} reached the controller while false"
5168 );
5169 }
5170 }
5171
5172 #[tokio::test]
5175 async fn a_config_key_the_harness_never_advertised_is_refused() {
5176 let capable = |snapshot: &mut ViewerSnapshot| {
5177 snapshot.sessions[0].capabilities.set_config = true;
5178 snapshot.sessions[0].config_options = vec![ViewerConfigOption {
5179 key: "model".into(),
5180 label: "model".into(),
5181 current: None,
5182 choices: vec![ViewerConfigChoice {
5183 value: "sonnet".into(),
5184 name: "Sonnet".into(),
5185 description: None,
5186 }],
5187 }];
5188 };
5189
5190 for (body, why) in [
5191 (
5192 r#"{"action":"set-config","session_id":"session-1","key":"effort","value":"high"}"#,
5193 "an unadvertised key",
5194 ),
5195 (
5196 r#"{"action":"set-config","session_id":"session-1","key":"model","value":"gpt-9"}"#,
5197 "an unoffered value",
5198 ),
5199 ] {
5200 let (app, mut actions, _, _, _) = app_with_snapshot(capable);
5201 let response = post_action(app, cookie(), body.to_owned()).await;
5202 assert_eq!(
5203 response.status(),
5204 StatusCode::BAD_REQUEST,
5205 "{why} was accepted"
5206 );
5207 assert!(actions.try_recv().is_err(), "{why} reached the controller");
5208 }
5209
5210 let (app, mut actions, _, _, _) = app_with_snapshot(capable);
5212 let response = tokio::spawn(post_action(
5213 app,
5214 cookie(),
5215 r#"{"action":"set-config","session_id":"session-1","key":"model","value":"sonnet"}"#
5216 .to_owned(),
5217 ));
5218 let action = actions
5219 .recv()
5220 .await
5221 .expect("the action reached the controller");
5222 assert!(
5223 matches!(
5224 action.action,
5225 ControllerAction::SetConfig { ref key, ref value, .. }
5226 if key == "model" && value == "sonnet"
5227 ),
5228 "the advertised value was not forwarded unchanged"
5229 );
5230 action.reply.send(ActionOutcome::Accepted).unwrap();
5231 assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
5232 }
5233
5234 #[tokio::test]
5237 async fn a_dirty_acknowledgement_is_bounded_and_names_repositories() {
5238 let oversized = (0..40)
5239 .map(|index| format!(r#""repo-{index}""#))
5240 .collect::<Vec<_>>()
5241 .join(",");
5242 for (ack, why) in [
5243 (oversized.as_str(), "an unbounded acknowledgement"),
5244 (r#""""#, "an empty repository name"),
5245 ] {
5246 let (app, mut actions, _, _, _) = app();
5247 let body = format!(
5248 r#"{{"action":"new","workspace_id":"default","profile_id":"codex-1","bundle_id":"hel","target_id":"podman","dirty_ack":[{ack}]}}"#
5249 );
5250 let response = post_action(app, cookie(), body).await;
5251 assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{why}");
5252 assert!(actions.try_recv().is_err(), "{why} reached the controller");
5253 }
5254 }
5255
5256 #[tokio::test]
5259 async fn a_new_session_without_a_title_is_accepted() {
5260 let (app, mut actions, _, _, _) = app();
5261 let response = tokio::spawn(post_action(
5262 app,
5263 cookie(),
5264 r#"{"action":"new","workspace_id":"default","profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#
5265 .to_owned(),
5266 ));
5267 let action = actions
5270 .recv()
5271 .await
5272 .expect("the action reached the controller");
5273 assert!(
5274 matches!(
5275 action.action,
5276 ControllerAction::New { title: None, ref workspace_id, .. }
5277 if workspace_id == "default"
5278 ),
5279 "the workspace or the absent title did not survive the boundary"
5280 );
5281 action.reply.send(ActionOutcome::Accepted).unwrap();
5282 assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
5283 }
5284
5285 #[test]
5289 fn a_cookie_names_one_viewer_and_two_cookies_never_collide() {
5290 let key = b"01234567890123456789012345678901";
5291 let expiry = now_unix().saturating_add(3600);
5292 let first = signed_cookie_value(key, "viewer-a", expiry);
5293 let second = signed_cookie_value(key, "viewer-b", expiry);
5294 assert_ne!(
5295 first, second,
5296 "two viewers unlocking in the same second share a cookie"
5297 );
5298 assert_eq!(
5299 cookie_viewer(key, &first, now_unix()),
5300 Some(Some("viewer-a".to_owned()))
5301 );
5302 assert_eq!(
5303 cookie_viewer(key, &second, now_unix()),
5304 Some(Some("viewer-b".to_owned()))
5305 );
5306 }
5307
5308 #[test]
5312 fn a_legacy_cookie_still_authenticates_and_stores_nothing() {
5313 let key = b"01234567890123456789012345678901";
5314 let expiry = now_unix().saturating_add(3600);
5315 let legacy = legacy_signed_cookie_value(key, expiry);
5316 assert_eq!(cookie_viewer(key, &legacy, now_unix()), Some(None));
5317 assert!(session_cookie_valid(key, &legacy, now_unix()));
5318 assert!(
5319 !session_cookie_valid(key, &legacy, expiry),
5320 "an expired legacy cookie still authenticated"
5321 );
5322 }
5323
5324 #[test]
5326 fn a_tampered_cookie_is_refused() {
5327 let key = b"01234567890123456789012345678901";
5328 let expiry = now_unix().saturating_add(3600);
5329 let honest = signed_cookie_value(key, "viewer-a", expiry);
5330 let swapped = honest.replacen("viewer-a", "viewer-b", 1);
5331 assert_eq!(cookie_viewer(key, &swapped, now_unix()), None);
5332 assert_eq!(cookie_viewer(key, "nonsense", now_unix()), None);
5333 assert_eq!(cookie_viewer(key, &format!("{expiry}."), now_unix()), None);
5334 }
5335
5336 #[tokio::test]
5339 async fn an_oversized_draft_is_refused_with_a_stable_code() {
5340 let (app, _, _, _, mut stored) = app();
5341 let draft = "x".repeat(64 * 1024 + 1);
5342 let response = app
5343 .oneshot(
5344 Request::put("/api/sessions/session-1/draft")
5345 .header(COOKIE, cookie())
5346 .header(CONTENT_TYPE, "application/json")
5347 .body(Body::from(
5348 serde_json::json!({ "draft": draft }).to_string(),
5349 ))
5350 .unwrap(),
5351 )
5352 .await
5353 .unwrap();
5354 assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
5355 assert!(stored.try_recv().is_err(), "an oversized draft was stored");
5356 }
5357
5358 #[tokio::test]
5361 async fn a_legacy_viewer_reads_empty_state_and_cannot_store_a_draft() {
5362 let key = b"01234567890123456789012345678901";
5363 let legacy = format!(
5364 "{COOKIE_NAME}={}",
5365 legacy_signed_cookie_value(key, now_unix().saturating_add(3600))
5366 );
5367
5368 let (reader, _, _, _, mut stored) = app();
5369 let response = reader
5370 .oneshot(
5371 Request::get("/api/sessions/session-1/client-state")
5372 .header(COOKIE, legacy.clone())
5373 .body(Body::empty())
5374 .unwrap(),
5375 )
5376 .await
5377 .unwrap();
5378 assert_eq!(response.status(), StatusCode::OK);
5379 let body = response.into_body().collect().await.unwrap().to_bytes();
5380 let state: ViewerClientState = serde_json::from_slice(&body).unwrap();
5381 assert_eq!(state, ViewerClientState::default());
5382 assert!(
5383 stored.try_recv().is_err(),
5384 "a legacy viewer read stored state"
5385 );
5386
5387 let (writer, _, _, _, mut stored) = app();
5388 let response = writer
5389 .oneshot(
5390 Request::put("/api/sessions/session-1/draft")
5391 .header(COOKIE, legacy)
5392 .header(CONTENT_TYPE, "application/json")
5393 .body(Body::from(r#"{"draft":"text"}"#))
5394 .unwrap(),
5395 )
5396 .await
5397 .unwrap();
5398 assert_eq!(response.status(), StatusCode::CONFLICT);
5399 assert!(stored.try_recv().is_err(), "a legacy viewer stored a draft");
5400 }
5401
5402 #[tokio::test]
5404 async fn prompt_history_refuses_an_unknown_scope() {
5405 let (app, _, _, _, mut stored) = app();
5406 let response = app
5407 .oneshot(
5408 Request::get("/api/sessions/session-1/history?q=ship&scope=everything")
5409 .header(COOKIE, cookie())
5410 .body(Body::empty())
5411 .unwrap(),
5412 )
5413 .await
5414 .unwrap();
5415 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
5416 assert!(
5417 stored.try_recv().is_err(),
5418 "the search reached the controller"
5419 );
5420 }
5421
5422 #[tokio::test]
5426 async fn a_preflight_validates_before_it_reaches_the_controller() {
5427 for (body, why) in [
5428 (
5429 r#"{"profile_id":"nope","bundle_id":"hel","target_id":"podman"}"#,
5430 "an unknown profile",
5431 ),
5432 (
5433 r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw"}"#,
5434 "a bare target with no directory",
5435 ),
5436 ] {
5437 let (app, _, _, mut preflights, _) = app();
5438 let response = app
5439 .oneshot(
5440 Request::post("/api/preflight/new")
5441 .header(COOKIE, cookie())
5442 .header(CONTENT_TYPE, "application/json")
5443 .body(Body::from(body))
5444 .unwrap(),
5445 )
5446 .await
5447 .unwrap();
5448 assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{why}");
5449 assert!(
5450 preflights.try_recv().is_err(),
5451 "{why} reached the controller"
5452 );
5453 }
5454 }
5455
5456 #[tokio::test]
5460 async fn a_bare_preflight_forwards_directory_validation_to_the_controller() {
5461 let (app, _, _, mut preflights, _) = app();
5462 let response = tokio::spawn(app.oneshot(
5463 Request::post("/api/preflight/new")
5464 .header(COOKIE, cookie())
5465 .header(CONTENT_TYPE, "application/json")
5466 .body(Body::from(
5467 r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw","project_directory":"~/project"}"#,
5468 ))
5469 .unwrap(),
5470 ));
5471 let request = preflights.recv().await.expect("the controller was asked");
5472 assert_eq!(request.bundle_id, "hel");
5473 assert_eq!(request.target_id, "raw");
5474 assert_eq!(request.project_directory, Some(PathBuf::from("~/project")));
5475 request
5476 .reply
5477 .send(Ok(PreflightNew {
5478 project_directory: Some("/remote/project".into()),
5479 remote_repairs: Vec::new(),
5480 dirty_repositories: Vec::new(),
5481 remote_repositories: Vec::new(),
5482 local_changes_excluded: false,
5483 }))
5484 .unwrap();
5485 let response = response.await.unwrap().unwrap();
5486 assert_eq!(response.status(), StatusCode::OK);
5487 let body = response.into_body().collect().await.unwrap().to_bytes();
5488 let answer: PreflightNew = serde_json::from_slice(&body).unwrap();
5489 assert!(answer.dirty_repositories.is_empty());
5490 assert_eq!(answer.project_directory, Some("/remote/project".into()));
5491 }
5492
5493 #[tokio::test]
5494 async fn a_bare_preflight_validation_failure_is_actionable_without_its_details() {
5495 let (app, _, _, mut preflights, _) = app();
5496 let response = tokio::spawn(app.oneshot(
5497 Request::post("/api/preflight/new")
5498 .header(COOKIE, cookie())
5499 .header(CONTENT_TYPE, "application/json")
5500 .body(Body::from(
5501 r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw","project_directory":"/private/project"}"#,
5502 ))
5503 .unwrap(),
5504 ));
5505 let request = preflights.recv().await.expect("the controller was asked");
5506 request
5507 .reply
5508 .send(Err(PreflightFailure::Validation))
5509 .unwrap();
5510 let response = response.await.unwrap().unwrap();
5511 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
5512 let body = response.into_body().collect().await.unwrap().to_bytes();
5513 assert_eq!(
5514 serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
5515 serde_json::json!({
5516 "error": "project validation failed; check that the directory exists, is accessible, and contains a Git repository with a valid HEAD"
5517 })
5518 );
5519 assert!(!String::from_utf8_lossy(&body).contains("/private/project"));
5520 }
5521
5522 #[tokio::test]
5523 async fn a_bundle_preflight_controller_failure_keeps_the_generic_service_error() {
5524 let (app, _, _, mut preflights, _) = app();
5525 let response = tokio::spawn(
5526 app.oneshot(
5527 Request::post("/api/preflight/new")
5528 .header(COOKIE, cookie())
5529 .header(CONTENT_TYPE, "application/json")
5530 .body(Body::from(
5531 r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#,
5532 ))
5533 .unwrap(),
5534 ),
5535 );
5536 let request = preflights.recv().await.expect("the controller was asked");
5537 request
5538 .reply
5539 .send(Err(PreflightFailure::Controller(
5540 "private /source/hel details".into(),
5541 )))
5542 .unwrap();
5543 let response = response.await.unwrap().unwrap();
5544 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
5545 let body = response.into_body().collect().await.unwrap().to_bytes();
5546 assert_eq!(
5547 serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
5548 serde_json::json!({"error": "the controller could not check this project"})
5549 );
5550 assert!(!String::from_utf8_lossy(&body).contains("/source/hel"));
5551 }
5552
5553 #[tokio::test]
5556 async fn a_bundle_preflight_reports_network_sources_and_excludes_local_changes() {
5557 let (app, _, _, mut preflights, _) = app();
5558 let response = tokio::spawn(
5559 app.oneshot(
5560 Request::post("/api/preflight/new")
5561 .header(COOKIE, cookie())
5562 .header(CONTENT_TYPE, "application/json")
5563 .body(Body::from(
5564 r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#,
5565 ))
5566 .unwrap(),
5567 ),
5568 );
5569 let request = preflights.recv().await.expect("the controller was asked");
5570 assert_eq!(request.bundle_id, "hel");
5571 assert_eq!(request.target_id, "podman");
5572 assert_eq!(request.project_directory, None);
5573 request
5574 .reply
5575 .send(Ok(PreflightNew {
5576 project_directory: None,
5577 remote_repairs: Vec::new(),
5578 dirty_repositories: Vec::new(),
5579 remote_repositories: vec![PreflightRepository {
5580 id: "hel".into(),
5581 fetch_url: "https://github.com/example/hel.git".into(),
5582 default_branch: "main".into(),
5583 push_urls: vec!["ssh://git@example/hel.git".into()],
5584 }],
5585 local_changes_excluded: true,
5586 }))
5587 .unwrap();
5588 let response = response.await.unwrap().unwrap();
5589 assert_eq!(response.status(), StatusCode::OK);
5590 let body = response.into_body().collect().await.unwrap().to_bytes();
5591 let answer: PreflightNew = serde_json::from_slice(&body).unwrap();
5592 assert!(answer.dirty_repositories.is_empty());
5593 assert!(answer.local_changes_excluded);
5594 assert_eq!(answer.remote_repositories[0].default_branch, "main");
5595 assert_eq!(answer.remote_repositories[0].push_urls.len(), 1);
5596 }
5597
5598 #[test]
5603 fn the_markdown_renderer_builds_structure_and_refuses_injection() {
5604 run_web_check(
5605 "markdown",
5606 r#"import { installDocument, elements, only, check, checkEqual } from './test-dom.js';
5607installDocument();
5608const { renderMarkdown, renderDiffSummary, safeHref } = await import('./markdown.js');
5609
5610const render = source => {
5611 const host = document.createElement('section');
5612 host.append(renderMarkdown(source));
5613 return host;
5614};
5615
5616// Headings
5617checkEqual(only(render('# Title'), 'h1').textContent, 'Title', 'h1');
5618checkEqual(only(render('### Deep'), 'h3').textContent, 'Deep', 'h3');
5619
5620// Nested lists
5621const nested = render('- one\n - inner\n- two');
5622check(elements(nested, 'ul').length === 2, 'nested list produced ' + elements(nested, 'ul').length + ' lists');
5623check(elements(elements(nested, 'ul')[0], 'li').length >= 2, 'outer list lost items');
5624
5625// Ordered lists
5626checkEqual(elements(render('1. a\n2. b'), 'ol').length, 1, 'ordered list');
5627
5628// Fenced code stays unparsed
5629const fenced = render('```rust\nlet x = *y*;\n```');
5630checkEqual(only(fenced, 'code').textContent, 'let x = *y*;', 'fenced code');
5631check(elements(fenced, 'span').some(s => s.className === 'tok-kw'), 'fenced rust untinted');
5632checkEqual(elements(fenced, 'em').length, 0, 'fence emphasised its contents');
5633checkEqual(only(fenced, 'pre').dataset.lang, 'rust', 'fence language');
5634
5635// Inline code beats emphasis
5636checkEqual(only(render('`*not em*`'), 'code').textContent, '*not em*', 'inline code');
5637checkEqual(elements(render('`*not em*`'), 'em').length, 0, 'inline code emphasised');
5638
5639// Emphasis
5640checkEqual(only(render('**bold**'), 'strong').textContent, 'bold', 'strong');
5641checkEqual(only(render('*it*'), 'em').textContent, 'it', 'em');
5642checkEqual(only(render('~~gone~~'), 'del').textContent, 'gone', 'del');
5643
5644// Tables
5645const table = render('| a | b |\n| --- | ---: |\n| 1 | 2 |');
5646checkEqual(elements(table, 'table').length, 1, 'table');
5647checkEqual(elements(table, 'th').length, 2, 'table header cells');
5648checkEqual(elements(table, 'td').length, 2, 'table body cells');
5649checkEqual(elements(table, 'th')[1].className, 'align-right', 'table alignment class');
5650checkEqual(only(table, 'div').className, 'scroll-x', 'table scroll wrapper');
5651
5652// Blockquote and rule
5653checkEqual(elements(render('> quoted'), 'blockquote').length, 1, 'blockquote');
5654checkEqual(elements(render('---'), 'hr').length, 1, 'rule');
5655
5656// XSS: markup is text, never elements
5657const injected = render('<img src=x onerror=alert(1)>');
5658checkEqual(elements(injected, 'img').length, 0, 'raw HTML became an element');
5659check(injected.textContent.includes('<img src=x onerror=alert(1)>'), 'raw HTML lost its text');
5660
5661// XSS: refused link schemes
5662for (const target of ['javascript:alert(1)', 'JaVaScRiPt:alert(1)', 'java\tscript:alert(1)', 'data:text/html,<script>', 'vbscript:x']) {
5663 const out = render(`[click](${target})`);
5664 checkEqual(elements(out, 'a').length, 0, `link scheme ${JSON.stringify(target)} was allowed`);
5665 check(out.textContent.includes('click'), `link scheme ${JSON.stringify(target)} lost its label`);
5666}
5667
5668// Accepted schemes keep their href and carry safe rel/target
5669for (const target of ['https://example.com', 'http://example.com/a', 'mailto:someone@example.com']) {
5670 const anchor = only(render(`[click](${target})`), 'a');
5671 checkEqual(anchor.getAttribute('href'), target, 'href');
5672 checkEqual(anchor.getAttribute('rel'), 'noreferrer noopener', 'rel');
5673 checkEqual(anchor.getAttribute('target'), '_blank', 'target');
5674}
5675
5676// safeHref directly
5677checkEqual(safeHref('javascript:alert(1)'), null, 'safeHref allowed javascript:');
5678checkEqual(safeHref(' https://x.test '), 'https://x.test', 'safeHref cleaned value');
5679
5680// Inline markup inside a link label
5681checkEqual(only(render('[**bold link**](https://x.test)'), 'strong').textContent, 'bold link', 'link label markup');
5682
5683// An unclosed delimiter is literal, not markup
5684checkEqual(render('a * b').textContent, 'a * b', 'unclosed emphasis');
5685checkEqual(elements(render('a * b'), 'em').length, 0, 'unclosed emphasis made an element');
5686
5687// Diff summaries: the real format from format_diffstat, two spaces and U+2212
5688const diff = renderDiffSummary(['src/main.rs +12 −3', 'unparseable line']);
5689const items = elements(diff, 'li');
5690checkEqual(items.length, 2, 'diffstat rows');
5691checkEqual(elements(items[0], 'span')[0].textContent, 'src/main.rs', 'diffstat path');
5692checkEqual(elements(items[0], 'span')[1].textContent, '+12', 'diffstat additions');
5693checkEqual(elements(items[0], 'span')[2].textContent, '−3', 'diffstat deletions');
5694checkEqual(elements(items[1], 'span').length, 1, 'unparseable diffstat produced counts');
5695checkEqual(elements(items[1], 'span')[0].textContent, 'unparseable line', 'unparseable diffstat lost its text');
5696
5697console.log('all markdown checks passed');
5698"#,
5699 );
5700 }
5701
5702 #[test]
5707 fn tool_output_is_tinted_folded_and_never_read_as_markdown() {
5708 run_web_check(
5709 "tool-output",
5710 r#"import { installDocument, elements, only, check, checkEqual, openFold } from './test-dom.js';
5711installDocument();
5712const { renderToolOutput, codeBlock, detectLang, appendCommandTokens, isPathLike } = await import(
5713 './tool-output.js'
5714);
5715
5716const classes = root => elements(root, 'span').map(s => s.className);
5717
5718// A shell command is told apart into program, subcommand, flag and path.
5719const line = document.createElement('pre');
5720appendCommandTokens(line, 'cargo test --workspace src/lib.rs');
5721const seen = classes(line);
5722check(seen.includes('cmd-program'), 'no program: ' + seen);
5723check(seen.includes('cmd-subcommand'), 'no subcommand: ' + seen);
5724check(seen.includes('cmd-flag'), 'no flag: ' + seen);
5725check(seen.includes('cmd-path'), 'no path: ' + seen);
5726checkEqual(line.textContent, 'cargo test --workspace src/lib.rs', 'command text changed');
5727
5728// An operator starts the program count again, so both programs are found.
5729const piped = document.createElement('pre');
5730appendCommandTokens(piped, 'git status && cargo build');
5731checkEqual(classes(piped).filter(c => c === 'cmd-program').length, 2, 'pipeline reset');
5732
5733// Prose with a slash is not a path; a real path is.
5734check(!isPathLike('and/or'), '"and/or" read as a path');
5735check(isPathLike('src/lib/thing.rs'), 'a real path did not');
5736check(isPathLike('./x'), 'a relative path did not');
5737check(isPathLike('Cargo.toml'), 'a file with an extension did not');
5738
5739// JSON is pretty-printed and tinted, keys apart from values.
5740const json = renderToolOutput('{"name":"hel","count":3,"ok":true}');
5741const jsonClasses = classes(json);
5742check(jsonClasses.includes('tok-key'), 'no JSON key: ' + jsonClasses);
5743check(jsonClasses.includes('tok-str'), 'no JSON string: ' + jsonClasses);
5744check(jsonClasses.includes('tok-num'), 'no JSON number: ' + jsonClasses);
5745check(jsonClasses.includes('tok-kw'), 'no JSON keyword: ' + jsonClasses);
5746check(json.textContent.includes('"name"'), 'JSON lost its content');
5747
5748// Rust is tinted; an unknown language is not.
5749const rust = codeBlock('pub fn main() {\n let x = 1;\n}', 'rust');
5750check(classes(rust).includes('tok-kw'), 'rust keywords untinted');
5751checkEqual(only(rust, 'pre').dataset.lang, 'rust', 'rust data-lang');
5752const plain = codeBlock('nothing in particular here', 'brainfuck');
5753checkEqual(classes(plain).length, 0, 'unknown language was tinted');
5754
5755// Sniffing is conservative: a log stays plain, real code does not.
5756checkEqual(detectLang('12:03 INFO started\n12:04 INFO done\n12:05 INFO stopped'), '', 'a log was sniffed');
5757checkEqual(
5758 detectLang('fn a() {}\nfn b() {}\nlet mut x = 1;\nuse std::fmt;\nimpl Foo {}\nlet y = x.unwrap();'),
5759 'rust',
5760 'rust was not sniffed',
5761);
5762checkEqual(detectLang('--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+new'), 'diff', 'diff was not sniffed');
5763
5764// A long dump is one closed fold that has built nothing yet.
5765const long = Array.from({ length: 400 }, (_, i) => `line ${i}`).join('\n');
5766const folded = renderToolOutput(long);
5767checkEqual(folded.nodeName, 'DETAILS', 'a 400-line dump was not folded');
5768checkEqual(elements(folded, 'pre').length, 0, 'a closed fold built its content anyway');
5769check(only(folded, 'summary').textContent.includes('400 lines'), 'fold summary: ' + only(folded, 'summary').textContent);
5770openFold(folded);
5771checkEqual(elements(folded, 'pre').length, 1, 'an opened fold built nothing');
5772check(elements(folded, 'pre')[0].textContent.includes('line 399'), 'the fold lost its content');
5773
5774// Opening twice builds once.
5775openFold(folded);
5776checkEqual(elements(folded, 'pre').length, 1, 'reopening rebuilt the content');
5777
5778// A short dump is not folded.
5779checkEqual(renderToolOutput('one\ntwo').nodeName, 'PRE', 'a short dump was folded');
5780
5781// Tool output is never parsed as Markdown, so an underscore is an underscore.
5782const literal = renderToolOutput('a _b_ c <img src=x>');
5783checkEqual(elements(literal, 'em').length, 0, 'tool output was emphasised');
5784checkEqual(elements(literal, 'img').length, 0, 'tool output produced an element');
5785check(literal.textContent.includes('<img src=x>'), 'tool output lost its text');
5786
5787console.log('all tool-output checks passed');
5788"#,
5789 );
5790 }
5791
5792 #[test]
5799 fn no_web_module_builds_markup_from_a_string() {
5800 const SINKS: [&str; 5] = [
5801 "innerHTML",
5802 "outerHTML",
5803 "insertAdjacentHTML",
5804 "document.write",
5805 "new Function",
5806 ];
5807 const ALLOWED: [(&str, &str); 0] = [];
5810 for (name, source) in [
5811 ("viewer.js", VIEWER_JS),
5812 ("markdown.js", MARKDOWN_JS),
5813 ("tool-output.js", TOOL_OUTPUT_JS),
5814 ] {
5815 for (number, line) in source.lines().enumerate() {
5816 let trimmed = line.trim();
5817 if trimmed.starts_with("//") || trimmed.starts_with("///") {
5818 continue;
5819 }
5820 for sink in SINKS {
5821 if !trimmed.contains(sink) {
5822 continue;
5823 }
5824 assert!(
5825 ALLOWED
5826 .iter()
5827 .any(|(file, allowed)| *file == name && trimmed == *allowed),
5828 "{name}:{} builds markup from a string: {trimmed}",
5829 number + 1
5830 );
5831 }
5832 }
5833 }
5834 }
5835
5836 #[test]
5840 fn embedded_viewer_keeps_elicitation_answers_across_snapshot_polls() {
5841 let source = viewer_source(
5842 "const elicitationCards = new Map()",
5843 "async function submitElicitation",
5844 );
5845 let dom = r#"
5846let replaceCalls = 0;
5847function makeEl(tag) {
5848 return {
5849 tagName: tag.toUpperCase(),
5850 children: [],
5851 options: [],
5852 selectedOptions: [],
5853 className: "",
5854 textContent: "",
5855 disabled: false,
5856 required: false,
5857 value: "",
5858 appendChild(child) {
5859 this.children.push(child);
5860 if (this.tagName === "SELECT") this.options.push(child);
5861 return child;
5862 },
5863 append(...kids) {
5864 this.children.push(...kids);
5865 },
5866 replaceChildren(...kids) {
5867 replaceCalls += 1;
5868 this.children = kids;
5869 },
5870 addEventListener() {},
5871 querySelectorAll(selector) {
5872 const found = [];
5873 const visit = node => {
5874 for (const child of node.children) {
5875 if (child.tagName === "INPUT" && (selector === "input" || child.checked)) found.push(child);
5876 visit(child);
5877 }
5878 };
5879 visit(this);
5880 return found;
5881 },
5882 querySelector(selector) { return this.querySelectorAll(selector)[0] || null; },
5883 setCustomValidity() {},
5884 reportValidity() {
5885 return true;
5886 },
5887 };
5888}
5889const created = [];
5890const document = {
5891 createElement(tag) {
5892 const el = makeEl(tag);
5893 created.push(el);
5894 return el;
5895 },
5896};
5897const elicitations = makeEl("div");
5898function el(tag, className, text) {
5899 const node = document.createElement(tag);
5900 node.className = className || "";
5901 node.textContent = text || "";
5902 return node;
5903}
5904async function submitElicitation() {}
5905"#;
5906 let checks = r#"
5907const request = {
5908 id: "elicitation-1",
5909 message: "Which CI architecture?",
5910 title: "CI",
5911 fields: [
5912 {
5913 id: "question_0",
5914 title: "CI architecture",
5915 required: false,
5916 kind: "single_select",
5917 options: [{ value: "reusable", title: "Reusable" }, { value: "matrix", title: "Matrix" }],
5918 },
5919 { id: "question_0_custom", title: "Other", required: false, kind: "text" },
5920 ],
5921};
5922const session = { id: "session-1", pending_elicitations: [request] };
5923renderElicitations(session);
5924const card = elicitations.children[0];
5925const radio = created.find((el) => el.tagName === "INPUT" && el.value === "reusable");
5926const text = created.find((el) => el.tagName === "INPUT" && el.type === "text");
5927radio.checked = true;
5928text.value = "keep me";
5929const attachments = replaceCalls;
5930renderElicitations(session);
5931if (elicitations.children[0] !== card) {
5932 throw new Error("a snapshot rebuilt the pending card");
5933}
5934if (!radio.checked || text.value !== "keep me") {
5935 throw new Error("a snapshot wiped the half-filled answer");
5936}
5937if (replaceCalls !== attachments) {
5938 throw new Error("a snapshot re-attached an unchanged card and dropped focus");
5939}
5940sentElicitations.add(elicitationKey("session-1", request.id));
5941renderElicitations(session);
5942if (elicitations.children[0] !== card) {
5943 throw new Error("a sent answer rebuilt the card");
5944}
5945if (!radio.disabled || !text.disabled) {
5946 throw new Error("a sent answer left the controls live");
5947}
5948if (!radio.checked) {
5949 throw new Error("a sent answer wiped the reply");
5950}
5951renderElicitations({ id: "session-1", pending_elicitations: [] });
5952if (elicitations.children.length !== 0 || elicitationCards.size !== 0) {
5953 throw new Error("an answered request stayed rendered");
5954}
5955if (sentElicitations.size !== 0) {
5956 throw new Error("a resolved request kept its sent marker");
5957}
5958"#;
5959 run_viewer_script(
5960 "elicitation-rendering",
5961 &format!("{dom}\n{source}\n{checks}"),
5962 );
5963 }
5964
5965 fn sample_image(pixels: usize) -> ViewerPromptImage {
5966 ViewerPromptImage {
5967 data_base64: base64::engine::general_purpose::STANDARD.encode(vec![7_u8; pixels]),
5968 mime_type: "image/png".into(),
5969 width: 32,
5970 height: 24,
5971 attachment: None,
5972 }
5973 }
5974
5975 fn sample_valid_image() -> ViewerPromptImage {
5976 ViewerPromptImage {
5977 data_base64: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
5978 .into(),
5979 mime_type: "image/png".into(),
5980 width: 1,
5981 height: 1,
5982 attachment: None,
5983 }
5984 }
5985
5986 fn image_capable(snapshot: &mut ViewerSnapshot) {
5987 snapshot.sessions[0].prompt_images_supported = true;
5988 }
5989
5990 async fn post_action(app: Router, cookie: String, body: String) -> Response<Body> {
5991 app.oneshot(
5992 Request::post("/api/actions")
5993 .header(COOKIE, cookie)
5994 .header(CONTENT_TYPE, "application/json")
5995 .body(Body::from(body))
5996 .unwrap(),
5997 )
5998 .await
5999 .unwrap()
6000 }
6001
6002 #[tokio::test]
6003 async fn image_prompt_reaches_the_controller_with_its_images() {
6004 let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
6005 let cookie = login_cookie(&app).await;
6006 let image = sample_valid_image();
6007 let body = serde_json::to_string(&ControllerAction::Prompt {
6008 session_id: "session-1".into(),
6009 text: String::new(),
6010 images: vec![image.clone(), image.clone()],
6011 })
6012 .unwrap();
6013 let response = tokio::spawn(post_action(app, cookie, body));
6014 let request = actions.recv().await.unwrap();
6015 let ControllerRequest { action, reply } = request;
6016 let ControllerAction::Prompt {
6017 session_id,
6018 text,
6019 images,
6020 } = action
6021 else {
6022 panic!("expected a prompt action")
6023 };
6024 assert_eq!(session_id, "session-1");
6025 assert!(text.is_empty());
6026 assert_eq!(images.len(), 2);
6027 assert!(
6028 images
6029 .iter()
6030 .all(|image| { image.data_base64.is_empty() && image.attachment.is_some() })
6031 );
6032 reply.send(ActionOutcome::Accepted).unwrap();
6033 assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
6034 }
6035
6036 #[tokio::test]
6037 async fn browser_attachment_upload_returns_a_stored_reference_without_inline_bytes() {
6038 let (app, _, _, _, _) = app_with_snapshot(image_capable);
6039 let cookie = login_cookie(&app).await;
6040 let bytes = base64::engine::general_purpose::STANDARD
6041 .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")
6042 .unwrap();
6043 let response = app
6044 .oneshot(
6045 Request::post("/api/sessions/session-1/attachments")
6046 .header(COOKIE, cookie)
6047 .header(CONTENT_TYPE, "image/png")
6048 .body(Body::from(bytes))
6049 .unwrap(),
6050 )
6051 .await
6052 .unwrap();
6053 assert_eq!(response.status(), StatusCode::OK);
6054 let body = response.into_body().collect().await.unwrap().to_bytes();
6055 let image: ViewerPromptImage = serde_json::from_slice(&body).unwrap();
6056 assert!(image.data_base64.is_empty());
6057 let reference = image.attachment.expect("upload should return a reference");
6058 assert_eq!(reference.mime_type, "image/png");
6059 assert_eq!(reference.width, 1);
6060 assert_eq!(reference.height, 1);
6061 assert!(reference.size <= 700 * 1024);
6062 }
6063
6064 #[tokio::test]
6068 async fn multi_image_prompts_are_accepted_over_the_general_body_limit() {
6069 let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
6070 let cookie = login_cookie(&app).await;
6071 let image = sample_valid_image();
6072 let mut body = serde_json::to_string(&ControllerAction::Prompt {
6073 session_id: "session-1".into(),
6074 text: "look at these".into(),
6075 images: vec![image.clone(), image],
6076 })
6077 .unwrap();
6078 body.push_str(&" ".repeat(MAX_BODY_BYTES));
6079 assert!(body.len() > MAX_BODY_BYTES);
6080 assert!(body.len() < MAX_PROMPT_BODY_BYTES);
6081 let response = tokio::spawn(post_action(app, cookie, body));
6082 let action = actions.recv().await.unwrap();
6083 action.reply.send(ActionOutcome::Accepted).unwrap();
6084 assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
6085 }
6086
6087 #[tokio::test]
6088 async fn a_body_over_the_prompt_limit_is_still_refused() {
6089 let (app, _actions, _, _, _) = app_with_snapshot(image_capable);
6090 let cookie = login_cookie(&app).await;
6091 let image = sample_image(MAX_PROMPT_BODY_BYTES);
6092 let body = serde_json::to_string(&ControllerAction::Prompt {
6093 session_id: "session-1".into(),
6094 text: String::new(),
6095 images: vec![image],
6096 })
6097 .unwrap();
6098 assert!(body.len() > MAX_PROMPT_BODY_BYTES);
6099 let response = post_action(app, cookie, body).await;
6100 assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
6101 }
6102
6103 #[tokio::test]
6104 async fn malformed_image_payloads_never_reach_the_controller() {
6105 let cases = [
6106 ("aW1hZ2U=", "text/plain", 32, 24),
6107 ("aW1hZ2U=", "image/png", 0, 24),
6108 ("not base64!", "image/png", 32, 24),
6109 ("", "image/png", 32, 24),
6110 ];
6111 for (data, mime, width, height) in cases {
6112 let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
6113 let cookie = login_cookie(&app).await;
6114 let body = serde_json::to_string(&ControllerAction::Prompt {
6115 session_id: "session-1".into(),
6116 text: String::new(),
6117 images: vec![ViewerPromptImage {
6118 data_base64: data.into(),
6119 mime_type: mime.into(),
6120 width,
6121 height,
6122 attachment: None,
6123 }],
6124 })
6125 .unwrap();
6126 let response = post_action(app, cookie, body).await;
6127 assert_eq!(
6128 response.status(),
6129 StatusCode::BAD_REQUEST,
6130 "expected {data:?}/{mime} {width}x{height} to be refused"
6131 );
6132 assert!(actions.try_recv().is_err());
6133 }
6134 }
6135
6136 #[test]
6137 fn image_prompts_need_text_or_an_image_and_an_agent_that_takes_them() {
6138 let (config, state) = sample_config_state();
6139 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6140 let prompt = |text: &str, images: Vec<ViewerPromptImage>| ControllerAction::Prompt {
6141 session_id: "session-1".into(),
6142 text: text.into(),
6143 images,
6144 };
6145
6146 assert!(validate_action(&prompt("ship it", Vec::new()), &snapshot).is_ok());
6148 assert!(validate_action(&prompt("", vec![sample_image(8)]), &snapshot).is_err());
6149
6150 image_capable(&mut snapshot);
6151 assert!(validate_action(&prompt("", vec![sample_image(8)]), &snapshot).is_ok());
6153 assert!(
6154 validate_action(
6155 &prompt("", vec![sample_image(8); MAX_PROMPT_IMAGES + 1]),
6156 &snapshot,
6157 )
6158 .is_err()
6159 );
6160 assert!(validate_action(&prompt(" ", Vec::new()), &snapshot).is_err());
6161 assert!(validate_action(&prompt("", Vec::new()), &snapshot).is_err());
6162 assert!(validate_action(&prompt("!ls", vec![sample_image(8)]), &snapshot).is_err());
6164 }
6165
6166 #[test]
6169 fn embedded_viewer_reads_multiline_composer_text_out_of_its_dom() {
6170 let source = viewer_source("function composerText()", "function setComposerText(");
6171 let harness = r##"
6172const Node = { TEXT_NODE: 3 };
6173function textNode(value) {
6174 return { nodeType: 3, nodeValue: value, nodeName: "#text", childNodes: [], dataset: {} };
6175}
6176function element(name, children = [], dataset = {}) {
6177 const node = { nodeType: 1, nodeName: name, dataset, childNodes: children };
6178 children.forEach((child, index) => {
6179 child.nextSibling = children[index + 1] || null;
6180 });
6181 return node;
6182}
6183let promptText = null;
6184function read(children) {
6185 promptText = element("DIV", children);
6186 return composerText();
6187}
6188"##;
6189 let checks = r#"
6190const plain = read([textNode("ship it")]);
6191if (plain !== "ship it") throw new Error(`plain text became ${JSON.stringify(plain)}`);
6192
6193const broken = read([textNode("first"), element("BR"), textNode("second")]);
6194if (broken !== "first\nsecond") throw new Error(`line break became ${JSON.stringify(broken)}`);
6195
6196// The trailing break a browser leaves behind to keep the caret on a new line
6197// is scaffolding, not a line the user typed.
6198const filler = read([
6199 textNode("first"),
6200 element("BR"),
6201 element("BR", [], { composerFiller: "true" }),
6202]);
6203if (filler !== "first\n") throw new Error(`filler break became ${JSON.stringify(filler)}`);
6204
6205const blocks = read([
6206 textNode("first"),
6207 element("DIV", [textNode("second")]),
6208 element("DIV", [textNode("third")]),
6209]);
6210if (blocks !== "first\nsecond\nthird") throw new Error(`blocks became ${JSON.stringify(blocks)}`);
6211
6212const carriage = read([textNode("first\r\nsecond")]);
6213if (carriage !== "first\nsecond") throw new Error(`CRLF became ${JSON.stringify(carriage)}`);
6214"#;
6215 run_viewer_script("composer-reader", &format!("{harness}\n{source}\n{checks}"));
6216 }
6217
6218 #[tokio::test]
6222 async fn viewer_declares_the_icon_route_instead_of_requesting_a_missing_favicon() {
6223 let (app, _, _, _, _) = app();
6224 let page = fetch_text(app.clone(), "/").await;
6225 assert!(page.contains(r#"rel="icon""#), "the page declares no icon");
6226 assert!(page.contains("/icon.svg"), "the page names no icon route");
6227 let icon = app
6228 .oneshot(Request::get("/icon.svg").body(Body::empty()).unwrap())
6229 .await
6230 .unwrap();
6231 assert_eq!(icon.status(), StatusCode::OK);
6232 assert_eq!(
6233 icon.headers().get(CONTENT_TYPE).unwrap(),
6234 "image/svg+xml",
6235 "the icon route does not serve an SVG"
6236 );
6237 }
6238
6239 #[tokio::test]
6240 async fn valid_action_is_typed_and_forwarded() {
6241 let (app, mut actions, _, _, _) = app();
6242 let cookie = login_cookie(&app).await;
6243 let response = tokio::spawn(
6244 app.oneshot(
6245 Request::post("/api/actions")
6246 .header(COOKIE, cookie)
6247 .header(CONTENT_TYPE, "application/json")
6248 .body(Body::from(
6249 r#"{"action":"prompt","session_id":"session-1","text":"ship it"}"#,
6250 ))
6251 .unwrap(),
6252 ),
6253 );
6254 let action = actions.recv().await.unwrap();
6255 assert_eq!(
6256 action.action,
6257 ControllerAction::Prompt {
6258 session_id: "session-1".into(),
6259 text: "ship it".into(),
6260 images: Vec::new(),
6261 }
6262 );
6263 action.reply.send(ActionOutcome::Accepted).unwrap();
6264 let response = response.await.unwrap().unwrap();
6265 assert_eq!(response.status(), StatusCode::ACCEPTED);
6266 }
6267
6268 #[tokio::test]
6269 async fn move_preparation_is_read_only_and_returns_the_daemon_fingerprint() {
6270 let (app, mut preparations) = app_with_move_receiver();
6271 let cookie = login_cookie(&app).await;
6272 let response = tokio::spawn(
6273 app.oneshot(
6274 Request::post("/api/moves/prepare")
6275 .header(COOKIE, cookie)
6276 .header(CONTENT_TYPE, "application/json")
6277 .body(Body::from(
6278 r#"{"session_id":"session-1","profile_id":"codex-1","target_template_id":"podman","clear_resource_allocation":false,"additional_mounts":null,"resource_allocation":null}"#,
6279 ))
6280 .unwrap(),
6281 ),
6282 );
6283 let request = preparations
6284 .recv()
6285 .await
6286 .expect("preparation reached daemon");
6287 assert_eq!(request.selection.session_id, "session-1");
6288 assert_eq!(request.selection.profile_id.as_deref(), Some("codex-1"));
6289 assert_eq!(
6290 request.selection.target_template_id.as_deref(),
6291 Some("podman")
6292 );
6293 request
6294 .reply
6295 .send(Ok(MovePreparation {
6296 selection: request.selection,
6297 source_profile_id: "codex-1".into(),
6298 source_target_template_id: "podman".into(),
6299 cross_harness: false,
6300 active: true,
6301 queued_commands: vec![hel::hel_state::MaterializedQueuedPrompt {
6302 command_id: "queued-1".into(),
6303 kind: hel::hel_state::QueuedCommandKind::Prompt,
6304 content: vec![serde_json::json!({
6305 "type": "image",
6306 "mimeType": "image/png",
6307 "data": "secret-image-bytes"
6308 })],
6309 queued_at_ms: 1,
6310 }],
6311 fingerprint: "fingerprint".into(),
6312 operation_id: "move-1".into(),
6313 }))
6314 .unwrap();
6315 let response = response.await.unwrap().unwrap();
6316 assert_eq!(response.status(), StatusCode::OK);
6317 let body = response.into_body().collect().await.unwrap().to_bytes();
6318 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
6319 assert_eq!(body["operation_id"], "move-1");
6320 assert_eq!(body["active"], true);
6321 assert_eq!(
6322 body["queued_commands"][0]["content"][0]["text"],
6323 "[Image attachment: image/png]"
6324 );
6325 assert!(body.to_string().contains("[Image attachment: image/png]"));
6326 assert!(!body.to_string().contains("secret-image-bytes"));
6327 }
6328
6329 #[tokio::test]
6330 async fn confirmed_move_action_forwards_the_fingerprinted_request() {
6331 let (app, mut actions, _, _, _) = app_with_snapshot(|snapshot| {
6332 snapshot.sessions[0].capabilities.move_session = true;
6333 });
6334 let cookie = login_cookie(&app).await;
6335 let response = tokio::spawn(
6336 app.oneshot(
6337 Request::post("/api/actions")
6338 .header(COOKIE, cookie)
6339 .header(CONTENT_TYPE, "application/json")
6340 .body(Body::from(
6341 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}}"#,
6342 ))
6343 .unwrap(),
6344 ),
6345 );
6346 let action = actions.recv().await.expect("move action reached daemon");
6347 assert!(matches!(action.action, ControllerAction::Move { .. }));
6348 action.reply.send(ActionOutcome::Accepted).unwrap();
6349 assert_eq!(
6350 response.await.unwrap().unwrap().status(),
6351 StatusCode::ACCEPTED
6352 );
6353 }
6354
6355 #[tokio::test]
6356 async fn shell_action_is_typed_and_forwarded() {
6357 let (app, mut actions, _, _, _) = app();
6358 let cookie = login_cookie(&app).await;
6359 let response = tokio::spawn(
6360 app.oneshot(
6361 Request::post("/api/actions")
6362 .header(COOKIE, cookie)
6363 .header(CONTENT_TYPE, "application/json")
6364 .body(Body::from(
6365 r#"{"action":"run-shell","session_id":"session-1","command":"cargo test"}"#,
6366 ))
6367 .unwrap(),
6368 ),
6369 );
6370 let action = actions.recv().await.unwrap();
6371 assert_eq!(
6372 action.action,
6373 ControllerAction::RunShell {
6374 session_id: "session-1".into(),
6375 command: "cargo test".into(),
6376 }
6377 );
6378 action.reply.send(ActionOutcome::Accepted).unwrap();
6379 assert_eq!(
6380 response.await.unwrap().unwrap().status(),
6381 StatusCode::ACCEPTED
6382 );
6383 }
6384
6385 #[test]
6386 fn shell_action_validation_reserves_bang_prompts_and_checks_cancellation_ids() {
6387 let (config, state) = sample_config_state();
6388 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6389 assert!(
6390 validate_action(
6391 &ControllerAction::Prompt {
6392 session_id: "session-1".into(),
6393 text: "!cargo test".into(),
6394 images: Vec::new(),
6395 },
6396 &snapshot,
6397 )
6398 .is_err()
6399 );
6400 assert!(
6401 validate_action(
6402 &ControllerAction::RunShell {
6403 session_id: "session-1".into(),
6404 command: "cargo test".into(),
6405 },
6406 &snapshot,
6407 )
6408 .is_ok()
6409 );
6410 assert!(
6411 validate_action(
6412 &ControllerAction::CancelShell {
6413 session_id: "session-1".into(),
6414 shell_command_id: "shell-1".into(),
6415 },
6416 &snapshot,
6417 )
6418 .is_err()
6419 );
6420
6421 snapshot.sessions[0]
6422 .active_user_shells
6423 .push(ViewerUserShell {
6424 id: "shell-1".into(),
6425 command: "cargo test".into(),
6426 started_at_ms: Some(10),
6427 });
6428 assert!(
6429 validate_action(
6430 &ControllerAction::CancelShell {
6431 session_id: "session-1".into(),
6432 shell_command_id: "shell-1".into(),
6433 },
6434 &snapshot,
6435 )
6436 .is_ok()
6437 );
6438 }
6439
6440 #[tokio::test]
6441 async fn bare_new_action_forwards_an_explicit_safe_project_directory() {
6442 let (app, mut actions, _, _, _) = app();
6443 let cookie = login_cookie(&app).await;
6444 let response = tokio::spawn(
6445 app.oneshot(
6446 Request::post("/api/actions")
6447 .header(COOKIE, cookie)
6448 .header(CONTENT_TYPE, "application/json")
6449 .body(Body::from(
6450 r#"{"action":"new","profile_id":"codex-1","bundle_id":"hel","target_id":"raw","title":"Raw work","project_directory":"/work/project"}"#,
6451 ))
6452 .unwrap(),
6453 ),
6454 );
6455 let action = actions.recv().await.unwrap();
6456 assert_eq!(
6457 action.action,
6458 ControllerAction::New {
6459 workspace_id: String::new(),
6460 profile_id: "codex-1".into(),
6461 bundle_id: "hel".into(),
6462 target_id: "raw".into(),
6463 title: Some("Raw work".into()),
6464 project_directory: Some(PathBuf::from("/work/project")),
6465 dirty_ack: Vec::new(),
6466 }
6467 );
6468 action.reply.send(ActionOutcome::Accepted).unwrap();
6469 assert_eq!(
6470 response.await.unwrap().unwrap().status(),
6471 StatusCode::ACCEPTED
6472 );
6473 }
6474
6475 #[test]
6476 fn new_action_requires_project_directory_exactly_for_bare_targets() {
6477 let (config, state) = sample_config_state();
6478 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6479 let action = |target_id: &str, project_directory: Option<PathBuf>| ControllerAction::New {
6480 workspace_id: String::new(),
6481 profile_id: "codex-1".into(),
6482 bundle_id: "hel".into(),
6483 target_id: target_id.into(),
6484 title: Some("New work".into()),
6485 project_directory,
6486 dirty_ack: Vec::new(),
6487 };
6488
6489 assert!(validate_action(&action("podman", None), &snapshot).is_ok());
6490 assert_eq!(
6491 validate_action(&action("podman", Some("/work".into())), &snapshot)
6492 .unwrap_err()
6493 .status,
6494 StatusCode::BAD_REQUEST
6495 );
6496 assert_eq!(
6497 validate_action(&action("raw", None), &snapshot)
6498 .unwrap_err()
6499 .status,
6500 StatusCode::BAD_REQUEST
6501 );
6502 assert_eq!(
6503 validate_action(&action("raw", Some("relative".into())), &snapshot)
6504 .unwrap_err()
6505 .status,
6506 StatusCode::BAD_REQUEST
6507 );
6508 assert_eq!(
6509 validate_action(&action("raw", Some("/work/../secret".into())), &snapshot)
6510 .unwrap_err()
6511 .status,
6512 StatusCode::BAD_REQUEST
6513 );
6514 assert!(validate_action(&action("raw", Some("/work/project".into())), &snapshot).is_ok());
6515 }
6516
6517 #[tokio::test]
6518 async fn cancel_action_is_typed_and_forwarded() {
6519 let (app, mut actions, _, _, _) = app();
6520 let cookie = login_cookie(&app).await;
6521 let response = tokio::spawn(
6522 app.oneshot(
6523 Request::post("/api/actions")
6524 .header(COOKIE, cookie)
6525 .header(CONTENT_TYPE, "application/json")
6526 .body(Body::from(
6527 r#"{"action":"cancel","session_id":"session-1"}"#,
6528 ))
6529 .unwrap(),
6530 ),
6531 );
6532 let action = actions.recv().await.unwrap();
6533 assert_eq!(
6534 action.action,
6535 ControllerAction::Cancel {
6536 session_id: "session-1".into(),
6537 }
6538 );
6539 action.reply.send(ActionOutcome::Accepted).unwrap();
6540 assert_eq!(
6541 response.await.unwrap().unwrap().status(),
6542 StatusCode::ACCEPTED
6543 );
6544 }
6545
6546 #[tokio::test]
6547 async fn action_validation_accepts_cross_harness_resume_and_rejects_unknown() {
6548 let (mut config, state) = sample_config_state();
6549 config.profiles.insert(
6550 "claude-1".into(),
6551 HarnessProfile {
6552 enabled: true,
6553 context_window_bytes: None,
6554 kind: HarnessKind::Claude,
6555 home: "/secret/claude".into(),
6556 environment: BTreeMap::new(),
6557 },
6558 );
6559 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6560 snapshot.workspaces.push(ViewerWorkspace {
6561 id: "workspace-1".into(),
6562 name: "One".into(),
6563 });
6564 validate_action(
6565 &ControllerAction::Resume {
6566 session_id: "session-1".into(),
6567 workspace_id: "workspace-1".into(),
6568 profile_id: "claude-1".into(),
6569 target_id: "podman".into(),
6570 queue: ResumeQueueDisposition::Start,
6571 additional_mounts: None,
6572 resource_allocation: None,
6573 },
6574 &snapshot,
6575 )
6576 .unwrap();
6577
6578 let error = validate_action(
6579 &ControllerAction::Resume {
6580 session_id: "session-1".into(),
6581 workspace_id: "missing".into(),
6582 profile_id: "claude-1".into(),
6583 target_id: "podman".into(),
6584 queue: ResumeQueueDisposition::Start,
6585 additional_mounts: None,
6586 resource_allocation: None,
6587 },
6588 &snapshot,
6589 )
6590 .unwrap_err();
6591 assert_eq!(error.status, StatusCode::BAD_REQUEST);
6592
6593 let error = validate_action(
6594 &ControllerAction::Close {
6595 session_id: "not-managed".into(),
6596 },
6597 &snapshot,
6598 )
6599 .unwrap_err();
6600 assert_eq!(error.status, StatusCode::NOT_FOUND);
6601 }
6602
6603 #[test]
6606 fn a_running_review_projects_to_the_phone() {
6607 use crate::hel_review_host::{RuntimeReviewView, VerdictKind, VerdictView};
6608 use hel::hel_review::driver::{Resolution, RoleState, RoleStatus, TurnReviewPhase};
6609
6610 let review = RuntimeReviewView {
6611 session_id: "session-1".into(),
6612 tier: hel::hel_review::lanes::ReviewTier::Extended,
6613 phase: TurnReviewPhase::Verdict(hel::hel_review::verdict::ReviewVerdict::Findings {
6614 synthesis: "[P1] src/lib.rs:1 -- unbounded retry".into(),
6615 evidence: Default::default(),
6616 }),
6617 roles: vec![
6618 RoleStatus {
6619 role: "supervisor".into(),
6620 label: "Supervisor".into(),
6621 state: RoleState::Clean,
6622 },
6623 RoleStatus {
6624 role: "tests".into(),
6625 label: "Tests".into(),
6626 state: RoleState::Findings,
6627 },
6628 ],
6629 status: "Enter to act".into(),
6630 verdict: Some(VerdictView {
6631 kind: VerdictKind::Findings,
6632 text: "[P1] src/lib.rs:1 -- unbounded retry".into(),
6633 allowed: vec![
6634 Resolution::Forwarded,
6635 Resolution::Dismissed,
6636 Resolution::Cancelled,
6637 ],
6638 }),
6639 };
6640
6641 let projected = ViewerTurnReview::from_runtime(&review);
6642
6643 assert_eq!(projected.tier, "extended");
6644 assert_eq!(
6645 projected
6646 .roles
6647 .iter()
6648 .map(|role| (role.label.as_str(), role.state.as_str()))
6649 .collect::<Vec<_>>(),
6650 vec![("Supervisor", "done"), ("Tests", "findings")]
6651 );
6652 let verdict = projected.verdict.expect("a findings verdict travels");
6653 assert_eq!(verdict.kind, "findings");
6654 assert!(verdict.text.contains("unbounded retry"));
6655 assert_eq!(verdict.allowed, vec!["forward", "dismiss", "cancel"]);
6656 }
6657
6658 #[test]
6662 fn resolving_a_review_is_gated_on_what_the_daemon_published() {
6663 let (config, state) = sample_config_state();
6664 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6665
6666 let resolve = |resolution: &str| ControllerAction::ResolveReview {
6667 session_id: "session-1".into(),
6668 resolution: resolution.into(),
6669 };
6670
6671 let error = validate_action(&resolve("cancel"), &snapshot).unwrap_err();
6673 assert_eq!(error.status, StatusCode::BAD_REQUEST);
6674
6675 snapshot.sessions[0].turn_review = Some(ViewerTurnReview {
6676 tier: "quick".into(),
6677 status: "the reviewer is reading the change…".into(),
6678 roles: Vec::new(),
6679 verdict: None,
6680 });
6681 validate_action(&resolve("cancel"), &snapshot).unwrap();
6683 assert_eq!(
6684 validate_action(&resolve("forward"), &snapshot)
6685 .unwrap_err()
6686 .status,
6687 StatusCode::BAD_REQUEST
6688 );
6689
6690 snapshot.sessions[0].turn_review = Some(ViewerTurnReview {
6692 tier: "quick".into(),
6693 status: "the review failed".into(),
6694 roles: Vec::new(),
6695 verdict: Some(ViewerReviewVerdict {
6696 kind: "failed".into(),
6697 text: "bifrost exited with 1".into(),
6698 allowed: vec!["dismiss".into(), "cancel".into()],
6699 }),
6700 });
6701 validate_action(&resolve("dismiss"), &snapshot).unwrap();
6702 assert_eq!(
6703 validate_action(&resolve("forward"), &snapshot)
6704 .unwrap_err()
6705 .status,
6706 StatusCode::BAD_REQUEST
6707 );
6708 assert_eq!(
6710 validate_action(&resolve("approve"), &snapshot)
6711 .unwrap_err()
6712 .status,
6713 StatusCode::BAD_REQUEST
6714 );
6715
6716 validate_action(
6718 &ControllerAction::StartReview {
6719 session_id: "session-1".into(),
6720 },
6721 &snapshot,
6722 )
6723 .unwrap();
6724 assert_eq!(
6725 validate_action(
6726 &ControllerAction::StartReview {
6727 session_id: "not-managed".into(),
6728 },
6729 &snapshot,
6730 )
6731 .unwrap_err()
6732 .status,
6733 StatusCode::NOT_FOUND
6734 );
6735 }
6736
6737 #[test]
6738 fn resume_action_refuses_a_target_the_session_cannot_use() {
6739 let (mut config, state) = sample_config_state();
6740 config.bundles.get_mut("hel").unwrap().repositories[0].local = None;
6743 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6744 snapshot.workspaces.push(ViewerWorkspace {
6745 id: "workspace-1".into(),
6746 name: "One".into(),
6747 });
6748 assert_eq!(
6749 snapshot.sessions[0].incompatible_resume_targets,
6750 vec!["raw".to_owned()]
6751 );
6752
6753 let error = validate_action(
6754 &ControllerAction::Resume {
6755 session_id: "session-1".into(),
6756 workspace_id: "workspace-1".into(),
6757 profile_id: "codex-1".into(),
6758 target_id: "raw".into(),
6759 queue: ResumeQueueDisposition::Start,
6760 additional_mounts: None,
6761 resource_allocation: None,
6762 },
6763 &snapshot,
6764 )
6765 .unwrap_err();
6766
6767 assert_eq!(error.status, StatusCode::BAD_REQUEST);
6768 }
6769
6770 #[test]
6771 fn move_confirmation_requires_interruption_ack_and_an_explicit_queue_choice() {
6772 let (config, state) = sample_config_state();
6773 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6774 snapshot.sessions[0].capabilities.move_session = true;
6775 let selection = MoveSelection {
6776 clear_resource_allocation: false,
6777 session_id: "session-1".into(),
6778 profile_id: Some("codex-1".into()),
6779 target_template_id: Some("podman".into()),
6780 additional_mounts: None,
6781 resource_allocation: None,
6782 };
6783 let preparation = MovePreparation {
6784 selection,
6785 source_profile_id: "codex-1".into(),
6786 source_target_template_id: "podman".into(),
6787 cross_harness: false,
6788 active: true,
6789 queued_commands: vec![hel::hel_state::MaterializedQueuedPrompt {
6790 command_id: "command-1".into(),
6791 kind: hel::hel_state::QueuedCommandKind::Prompt,
6792 content: vec![serde_json::json!({"type": "text", "text": "continue"})],
6793 queued_at_ms: 1,
6794 }],
6795 fingerprint: "fingerprint".into(),
6796 operation_id: "move-1".into(),
6797 };
6798 let request = |queue, acknowledge_interruption| MoveSessionRequest {
6799 preparation: preparation.clone(),
6800 queue,
6801 acknowledge_interruption,
6802 };
6803 assert_eq!(
6804 validate_action(
6805 &ControllerAction::Move {
6806 request: request(Some(ResumeQueueDisposition::Discard), false),
6807 },
6808 &snapshot,
6809 )
6810 .unwrap_err()
6811 .status,
6812 StatusCode::CONFLICT
6813 );
6814 assert_eq!(
6815 validate_action(
6816 &ControllerAction::Move {
6817 request: request(None, true),
6818 },
6819 &snapshot,
6820 )
6821 .unwrap_err()
6822 .status,
6823 StatusCode::BAD_REQUEST
6824 );
6825 validate_action(
6826 &ControllerAction::Move {
6827 request: request(Some(ResumeQueueDisposition::Discard), true),
6828 },
6829 &snapshot,
6830 )
6831 .unwrap();
6832 }
6833
6834 #[tokio::test]
6835 async fn snapshot_endpoint_returns_only_public_projection() {
6836 let (app, _, _, _, _) = app();
6837 let cookie = login_cookie(&app).await;
6838 let response = app
6839 .oneshot(
6840 Request::get("/api/snapshot")
6841 .header(COOKIE, cookie)
6842 .body(Body::empty())
6843 .unwrap(),
6844 )
6845 .await
6846 .unwrap();
6847 let body = response.into_body().collect().await.unwrap().to_bytes();
6848 let body = String::from_utf8(body.to_vec()).unwrap();
6849 assert!(body.contains("session-1"));
6850 assert!(!body.contains("secret-token"));
6851 assert!(!body.contains("native-secret-id"));
6852 assert!(!body.contains("/private/source/hel"));
6853
6854 let snapshot: serde_json::Value = serde_json::from_str(&body).unwrap();
6855 let repository = &snapshot["bundles"][0]["repositories"][0];
6856 assert_eq!(repository["id"], "hel");
6857 assert_eq!(repository["github"], "owner/hel");
6858 assert_eq!(repository["destination"], "hel");
6859 assert!(repository.get("local").is_none());
6860 }
6861
6862 #[tokio::test]
6863 async fn snapshot_clock_anchor_is_fresh_even_when_the_projection_has_not_changed() {
6864 let (app, _, _, _, _) = app_with_snapshot(|snapshot| snapshot.server_time_ms = 1);
6865 let cookie = login_cookie(&app).await;
6866 for _ in 0..2 {
6867 let before = hel::clock::epoch_millis();
6868 let response = app
6869 .clone()
6870 .oneshot(
6871 Request::get("/api/snapshot")
6872 .header(COOKIE, &cookie)
6873 .body(Body::empty())
6874 .unwrap(),
6875 )
6876 .await
6877 .unwrap();
6878 let body = response.into_body().collect().await.unwrap().to_bytes();
6879 let snapshot: ViewerSnapshot = serde_json::from_slice(&body).unwrap();
6880 assert!(snapshot.server_time_ms >= before);
6881 assert!(snapshot.server_time_ms <= hel::clock::epoch_millis());
6882 }
6883 }
6884
6885 #[tokio::test]
6886 async fn conversation_endpoint_returns_authenticated_bounded_deltas() {
6887 let transcript = BrowserTranscript {
6888 latest_seq: 8,
6889 presentation_key: "key-1".into(),
6890 window_start_seq: 3,
6891 reset: false,
6892 entries: vec![
6893 BrowserTranscriptEntry {
6894 id: 3,
6895 updated_seq: 3,
6896 role: "user",
6897 label: "You".into(),
6898 recorded_at_ms: None,
6899 lines: vec!["begin".into()],
6900 glyph: "\u{276f}",
6901 tone: "user",
6902 tool_status: None,
6903 diffstats: Vec::new(),
6904 },
6905 BrowserTranscriptEntry {
6906 id: 7,
6907 updated_seq: 8,
6908 role: "agent",
6909 label: "Agent".into(),
6910 recorded_at_ms: None,
6911 lines: vec!["live".into()],
6912 glyph: "\u{25cf}",
6913 tone: "agent",
6914 tool_status: None,
6915 diffstats: Vec::new(),
6916 },
6917 ],
6918 };
6919 let (app, _, _, _, _) =
6920 app_with_conversations(BTreeMap::from([("session-1".into(), transcript)]));
6921 let cookie = login_cookie(&app).await;
6922 let response = app
6923 .clone()
6924 .oneshot(
6925 Request::get("/api/conversations/session-1?after_seq=3")
6926 .header(COOKIE, &cookie)
6927 .body(Body::empty())
6928 .unwrap(),
6929 )
6930 .await
6931 .unwrap();
6932 assert_eq!(response.status(), StatusCode::OK);
6933 let body = response.into_body().collect().await.unwrap().to_bytes();
6934 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
6935 assert_eq!(body["latest_seq"], 8);
6936 assert_eq!(body["reset"], false);
6937 assert_eq!(body["entries"].as_array().unwrap().len(), 1);
6938 assert_eq!(body["entries"][0]["lines"][0], "live");
6939
6940 let response = app
6941 .oneshot(
6942 Request::get("/api/conversations/session-1?after_seq=8&presentation_key=stale-key")
6943 .header(COOKIE, &cookie)
6944 .body(Body::empty())
6945 .unwrap(),
6946 )
6947 .await
6948 .unwrap();
6949 assert_eq!(response.status(), StatusCode::OK);
6950 let body = response.into_body().collect().await.unwrap().to_bytes();
6951 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
6952 assert_eq!(body["reset"], true);
6953 assert_eq!(body["presentation_key"], "key-1");
6954 assert_eq!(body["entries"].as_array().unwrap().len(), 2);
6955 }
6956
6957 #[tokio::test]
6958 async fn conversation_endpoint_rejects_cached_transcript_during_transition() {
6959 let transcript = BrowserTranscript {
6960 latest_seq: 1,
6961 presentation_key: "key-1".into(),
6962 window_start_seq: 1,
6963 reset: false,
6964 entries: vec![BrowserTranscriptEntry {
6965 id: 1,
6966 updated_seq: 1,
6967 role: "agent",
6968 label: "Agent".into(),
6969 recorded_at_ms: None,
6970 lines: vec!["stale".into()],
6971 glyph: "●",
6972 tone: "agent",
6973 tool_status: None,
6974 diffstats: Vec::new(),
6975 }],
6976 };
6977 let (app, _, _, _, _) = app_with(
6978 BTreeMap::from([("session-1".into(), transcript)]),
6979 |snapshot| snapshot.sessions[0].transitioning = true,
6980 );
6981 let cookie = login_cookie(&app).await;
6982 let response = app
6983 .oneshot(
6984 Request::get("/api/conversations/session-1")
6985 .header(COOKIE, cookie)
6986 .body(Body::empty())
6987 .unwrap(),
6988 )
6989 .await
6990 .unwrap();
6991 assert_eq!(response.status(), StatusCode::CONFLICT);
6992 }
6993
6994 #[tokio::test]
6995 async fn conversation_read_receipt_never_contends_with_a_running_action() {
6996 let (app, mut actions, mut receipts, _, _) = app();
6997 let cookie = login_cookie(&app).await;
6998 let prompt = tokio::spawn(
7002 app.clone().oneshot(
7003 Request::post("/api/actions")
7004 .header(COOKIE, cookie.clone())
7005 .header(CONTENT_TYPE, "application/json")
7006 .body(Body::from(
7007 r#"{"action":"prompt","session_id":"session-1","text":"ship it"}"#,
7008 ))
7009 .unwrap(),
7010 ),
7011 );
7012 let action = actions.recv().await.unwrap();
7013
7014 let response = tokio::spawn(
7015 app.oneshot(
7016 Request::post("/api/conversations/session-1/read")
7017 .header(COOKIE, cookie)
7018 .header(CONTENT_TYPE, "application/json")
7019 .body(Body::from(r#"{"through":42}"#))
7020 .unwrap(),
7021 ),
7022 );
7023 let receipt = receipts.recv().await.unwrap();
7024 assert_eq!(receipt.session_id, "session-1");
7025 assert_eq!(receipt.through, 42);
7026 receipt.reply.send(Ok(())).unwrap();
7027 assert_eq!(
7028 response.await.unwrap().unwrap().status(),
7029 StatusCode::NO_CONTENT
7030 );
7031 assert!(
7032 actions.try_recv().is_err(),
7033 "a read receipt must not queue a controller action"
7034 );
7035
7036 action.reply.send(ActionOutcome::Accepted).unwrap();
7037 assert_eq!(
7038 prompt.await.unwrap().unwrap().status(),
7039 StatusCode::ACCEPTED
7040 );
7041 }
7042
7043 #[tokio::test]
7044 async fn each_rejected_action_keeps_its_own_status_and_guidance() {
7045 for (outcome, status, guidance) in [
7046 (
7047 ActionOutcome::Busy,
7048 StatusCode::TOO_MANY_REQUESTS,
7049 "concurrent action limit",
7050 ),
7051 (
7052 ActionOutcome::SessionBusy,
7053 StatusCode::CONFLICT,
7054 "another operation is already running",
7055 ),
7056 (
7057 ActionOutcome::NotCancellable,
7058 StatusCode::CONFLICT,
7059 "no cancellable operation",
7060 ),
7061 (
7062 ActionOutcome::Failed,
7063 StatusCode::INTERNAL_SERVER_ERROR,
7064 "could not start this action",
7065 ),
7066 ] {
7067 let (app, mut actions, _, _, _) = app();
7068 let cookie = login_cookie(&app).await;
7069 let response = tokio::spawn(
7070 app.oneshot(
7071 Request::post("/api/actions")
7072 .header(COOKIE, cookie)
7073 .header(CONTENT_TYPE, "application/json")
7074 .body(Body::from(r#"{"action":"close","session_id":"session-1"}"#))
7075 .unwrap(),
7076 ),
7077 );
7078 let request = actions.recv().await.unwrap();
7079 request.reply.send(outcome).unwrap();
7080
7081 let response = response.await.unwrap().unwrap();
7082 assert_eq!(response.status(), status, "{outcome:?}");
7083 let body = response.into_body().collect().await.unwrap().to_bytes();
7084 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
7085 let error = body["error"].as_str().unwrap();
7086 assert!(error.contains(guidance), "{outcome:?} answered {error:?}");
7087 }
7088 }
7089
7090 #[tokio::test]
7091 async fn the_viewer_shows_a_session_whose_action_failed_after_it_was_accepted() {
7092 let (app, _, _, _, _) = app();
7096 let script = fetch_text(app, "/viewer.js").await;
7097 assert!(script.contains("has_error"), "viewer ignores has_error");
7098 }
7099
7100 #[tokio::test]
7103 async fn every_response_carries_the_security_headers() {
7104 for path in [
7105 "/",
7106 "/viewer.js",
7107 "/voice-worklet.js",
7108 "/voice-worker.js",
7109 "/viewer.css",
7110 "/manifest.webmanifest",
7111 "/api/snapshot",
7112 ] {
7113 let (app, _, _, _, _) = app();
7114 let response = app
7115 .oneshot(Request::get(path).body(Body::empty()).unwrap())
7116 .await
7117 .unwrap();
7118 let headers = response.headers();
7119 let policy = headers
7120 .get(CONTENT_SECURITY_POLICY_HEADER)
7121 .unwrap_or_else(|| panic!("{path} carries no content-security policy"))
7122 .to_str()
7123 .unwrap();
7124 assert!(
7125 policy.starts_with("default-src 'none';"),
7126 "{path} does not refuse unlisted sources: {policy}"
7127 );
7128 assert!(
7129 policy.contains("script-src 'self'") && !policy.contains("unsafe-inline"),
7130 "{path} permits inline script: {policy}"
7131 );
7132 assert!(
7133 policy.contains("frame-ancestors 'none'"),
7134 "{path} can be framed: {policy}"
7135 );
7136 assert_eq!(
7137 headers.get(X_CONTENT_TYPE_OPTIONS).unwrap(),
7138 "nosniff",
7139 "{path} permits content sniffing"
7140 );
7141 assert_eq!(
7142 headers.get(REFERRER_POLICY).unwrap(),
7143 "no-referrer",
7144 "{path} leaks a referrer"
7145 );
7146 }
7147 }
7148
7149 #[tokio::test]
7153 async fn the_page_carries_no_inline_script_or_style() {
7154 let (app, _, _, _, _) = app();
7155 let page = fetch_text(app, "/").await;
7156 assert!(
7157 !page.contains("<script>") && !page.contains("<style>"),
7158 "the page inlines script or style, which the policy blocks"
7159 );
7160 assert!(
7161 page.contains(r#"src="/viewer.js""#) && page.contains(r#"href="/viewer.css""#),
7162 "the page does not load its script and style as separate assets"
7163 );
7164 }
7165
7166 #[tokio::test]
7169 async fn live_state_and_the_service_worker_are_never_stored() {
7170 for path in ["/", "/service-worker.js", "/api/snapshot"] {
7171 let (app, _, _, _, _) = app();
7172 let response = app
7173 .oneshot(Request::get(path).body(Body::empty()).unwrap())
7174 .await
7175 .unwrap();
7176 assert_eq!(
7177 response.headers().get(CACHE_CONTROL).unwrap(),
7178 "no-store",
7179 "{path} may be stored"
7180 );
7181 }
7182 }
7183
7184 #[test]
7187 fn the_service_worker_declines_to_handle_live_state() {
7188 assert!(
7189 SERVICE_WORKER.contains("url.pathname.startsWith('/api/')"),
7190 "the service worker does not exclude the API"
7191 );
7192 assert!(
7193 SERVICE_WORKER.contains("url.pathname.startsWith('/auth/')"),
7194 "the service worker does not exclude authentication"
7195 );
7196 assert!(
7197 SERVICE_WORKER.contains("caches.delete"),
7198 "the service worker never deletes a superseded cache"
7199 );
7200 }
7201
7202 #[tokio::test]
7205 async fn the_installable_assets_are_served() {
7206 for (path, content_type) in [
7207 ("/icon-192.png", "image/png"),
7208 ("/icon-512.png", "image/png"),
7209 ("/maskable-512.png", "image/png"),
7210 ("/apple-touch-icon.png", "image/png"),
7211 ("/fonts/jetbrains-mono.woff2", "font/woff2"),
7212 ] {
7213 let (app, _, _, _, _) = app();
7214 let response = app
7215 .oneshot(Request::get(path).body(Body::empty()).unwrap())
7216 .await
7217 .unwrap();
7218 assert_eq!(response.status(), StatusCode::OK, "{path} is not served");
7219 assert_eq!(
7220 response.headers().get(CONTENT_TYPE).unwrap(),
7221 content_type,
7222 "{path} is served as the wrong type"
7223 );
7224 }
7225 }
7226
7227 async fn fetch_text(app: Router, path: &str) -> String {
7231 let response = app
7232 .oneshot(Request::get(path).body(Body::empty()).unwrap())
7233 .await
7234 .unwrap();
7235 assert_eq!(response.status(), StatusCode::OK, "{path} is not served");
7236 let body = response.into_body().collect().await.unwrap().to_bytes();
7237 String::from_utf8(body.to_vec()).expect("assets are UTF-8")
7238 }
7239
7240 #[tokio::test]
7241 async fn repeated_wrong_codes_lock_the_login_endpoint() {
7242 let (app, _, _, _, _) = app();
7243 let attempt = |code: &'static str| {
7244 let app = app.clone();
7245 async move {
7246 app.oneshot(
7247 Request::post("/auth/session")
7248 .header(CONTENT_TYPE, "application/json")
7249 .body(Body::from(format!(r#"{{"code":"{code}"}}"#)))
7250 .unwrap(),
7251 )
7252 .await
7253 .unwrap()
7254 .status()
7255 }
7256 };
7257 for _ in 0..MAX_CODE_FAILURES {
7258 assert_eq!(attempt("000000").await, StatusCode::UNAUTHORIZED);
7259 }
7260 assert_eq!(attempt("000000").await, StatusCode::TOO_MANY_REQUESTS);
7261 assert_eq!(attempt("123456").await, StatusCode::TOO_MANY_REQUESTS);
7264 }
7265
7266 #[test]
7267 fn viewer_code_lockouts_lengthen_instead_of_resetting_after_every_wait() {
7268 let serve_one_lockout = |guard: &mut CodeGuard, now: Instant| {
7269 for _ in 0..MAX_CODE_FAILURES {
7270 assert!(!guard.locked_at(now));
7271 guard.record_failure_at(now);
7272 }
7273 assert!(guard.locked_at(now));
7274 guard.locked_until.expect("the guard is locked") - now
7275 };
7276
7277 let start = Instant::now();
7278 let mut guard = CodeGuard::default();
7279 let first = serve_one_lockout(&mut guard, start);
7280 assert_eq!(first, CODE_LOCKOUT_BASE);
7281
7282 let second_round = start + first;
7286 let second = serve_one_lockout(&mut guard, second_round);
7287 assert_eq!(second, CODE_LOCKOUT_BASE * 2);
7288 let third = serve_one_lockout(&mut guard, second_round + second);
7289 assert_eq!(third, CODE_LOCKOUT_BASE * 4);
7290 assert_eq!(code_lockout(u32::MAX), CODE_LOCKOUT_CAP);
7291
7292 let mut recovered = CodeGuard::default();
7295 assert_eq!(serve_one_lockout(&mut recovered, start), CODE_LOCKOUT_BASE);
7296 }
7297
7298 #[test]
7299 fn persisted_cookie_key_survives_a_restart_and_stays_owner_only() {
7300 let directory = tempfile::tempdir().unwrap();
7301 let path = directory.path().join("phone-cookie-key");
7302
7303 let first = load_or_create_cookie_key(&path).unwrap();
7304 assert!(first.len() >= COOKIE_KEY_BYTES);
7305 assert_eq!(std::fs::read(&path).unwrap(), first);
7306 #[cfg(unix)]
7307 {
7308 use std::os::unix::fs::PermissionsExt as _;
7309 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
7310 assert_eq!(mode & 0o777, 0o600);
7311 }
7312
7313 let mut restarted = detached_options();
7316 restarted
7317 .set_cookie_key(load_or_create_cookie_key(&path).unwrap())
7318 .unwrap();
7319 let mut original = detached_options();
7320 original.set_cookie_key(first.clone()).unwrap();
7321 let cookie = signed_cookie_value(&original.cookie_key, "test-viewer", 200);
7322 assert!(session_cookie_valid(&restarted.cookie_key, &cookie, 100));
7323 assert!(!session_cookie_valid(
7324 &detached_options().cookie_key,
7325 &cookie,
7326 100
7327 ));
7328
7329 std::fs::remove_file(&path).unwrap();
7331 let rotated = load_or_create_cookie_key(&path).unwrap();
7332 assert_ne!(rotated, first);
7333 assert!(!session_cookie_valid(&rotated, &cookie, 100));
7334 }
7335
7336 #[test]
7337 fn corrupt_cookie_key_is_regenerated_instead_of_blocking_startup() {
7338 let directory = tempfile::tempdir().unwrap();
7339 let path = directory.path().join("phone-cookie-key");
7340 std::fs::write(&path, b"short").unwrap();
7341
7342 let key = load_or_create_cookie_key(&path).unwrap();
7343
7344 assert!(key.len() >= COOKIE_KEY_BYTES);
7345 assert_eq!(std::fs::read(&path).unwrap(), key);
7346 assert_eq!(load_or_create_cookie_key(&path).unwrap(), key);
7347 }
7348}