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