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