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