1use std::collections::BTreeMap;
8use std::convert::Infallible;
9use std::net::SocketAddr;
10use std::path::{Component, PathBuf};
11use std::sync::{Arc, Mutex};
12use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
13
14use anyhow::{Context, Result as AnyResult};
15use axum::body::Body;
16use axum::extract::{DefaultBodyLimit, Path, Query, Request, State};
17use axum::http::header::{
18 CACHE_CONTROL, CONTENT_SECURITY_POLICY as CONTENT_SECURITY_POLICY_HEADER, CONTENT_TYPE, COOKIE,
19 HeaderValue, LOCATION, REFERRER_POLICY, SET_COOKIE, X_CONTENT_TYPE_OPTIONS,
20};
21use axum::http::{HeaderMap, Response, StatusCode};
22use axum::middleware::Next;
23use axum::response::IntoResponse;
24use axum::response::sse::{Event, KeepAlive, Sse};
25use axum::routing::{get, post, put};
26use axum::{Json, Router};
27use base64::Engine as _;
28use hmac::{Hmac, Mac};
29use serde::{Deserialize, Serialize};
30use sha2::Sha256;
31use tokio::sync::{mpsc, watch};
32use tokio_stream::wrappers::ReceiverStream;
33use tokio_util::sync::CancellationToken;
34
35use hel::hel_config::{HelConfig, TargetTemplate, validate_id};
36use hel::hel_elicitation::{ElicitationRequest, ElicitationResponse, MAX_ELICITATION_BYTES};
37use hel::hel_state::{HelState, SessionState};
38
39pub const COOKIE_NAME: &str = "hel_viewer_session";
40const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60);
41const EPHEMERAL_SESSION_TTL: Duration = Duration::from_secs(24 * 60 * 60);
42const MAX_BODY_BYTES: usize = 128 * 1024;
43const MAX_CODE_FAILURES: u32 = 5;
44const CODE_LOCKOUT_BASE: Duration = Duration::from_secs(30);
45const CODE_LOCKOUT_CAP: Duration = Duration::from_secs(60 * 60);
46const MAX_TITLE_CHARS: usize = 120;
47const MAX_PROMPT_CHARS: usize = 64 * 1024;
48const MAX_DIRTY_ACKNOWLEDGEMENTS: usize = 32;
51const MAX_DRAFT_BYTES: usize = 64 * 1024;
55pub const MAX_HISTORY_MATCHES: usize = 40;
59const MAX_PROMPT_BODY_BYTES: usize = 32 * 1024 * 1024;
64const COOKIE_KEY_BYTES: usize = 32;
65const COOKIE_KEY_FILE: &str = "phone-cookie-key";
66
67#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
72pub struct BrowserTranscript {
73 pub latest_seq: u64,
74 pub window_start_seq: u64,
75 pub reset: bool,
76 pub entries: Vec<BrowserTranscriptEntry>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
80pub struct BrowserTranscriptEntry {
81 pub id: u64,
82 pub updated_seq: u64,
83 pub role: &'static str,
84 pub label: String,
85 pub recorded_at_ms: Option<i64>,
86 pub lines: Vec<String>,
87 pub glyph: &'static str,
91 pub tone: &'static str,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub tool_status: Option<&'static str>,
97 #[serde(default, skip_serializing_if = "Vec::is_empty")]
102 pub diffstats: Vec<BrowserDiffStat>,
103}
104
105#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
107pub struct BrowserDiffStat {
108 pub path: String,
109 pub insertions: u32,
110 pub deletions: u32,
111}
112
113pub const fn default_session_ttl() -> Duration {
118 DEFAULT_SESSION_TTL
119}
120
121pub fn cookie_key_path() -> PathBuf {
122 hel::hel_config::data_dir().join(COOKIE_KEY_FILE)
123}
124
125pub fn load_or_create_cookie_key(path: &std::path::Path) -> AnyResult<Vec<u8>> {
135 match std::fs::read(path) {
136 Ok(key) if key.len() >= COOKIE_KEY_BYTES => return Ok(key),
137 Ok(key) => tracing::warn!(
138 path = %path.display(),
139 bytes = key.len(),
140 "phone cookie key is shorter than {COOKIE_KEY_BYTES} bytes; generating a new key signs every phone out"
141 ),
142 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
143 Err(error) => tracing::warn!(
144 path = %path.display(),
145 "could not read the phone cookie key ({error}); generating a new key signs every phone out"
146 ),
147 }
148 let key = generate_cookie_key()?;
149 hel::hel_config::atomic_write(path, &key)
150 .with_context(|| format!("persist Mjolnir phone cookie key {}", path.display()))?;
151 Ok(key.to_vec())
152}
153
154pub struct ServerOptions {
162 pub bind: SocketAddr,
163 pub snapshot_rx: watch::Receiver<ViewerSnapshot>,
164 pub conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
165 pub action_tx: mpsc::Sender<ControllerRequest>,
166 pub receipt_tx: mpsc::Sender<ReadReceiptRequest>,
167 pub preflight_tx: mpsc::Sender<PreflightRequest>,
168 pub client_state_tx: mpsc::Sender<ClientStateRequest>,
169 pub shutdown: CancellationToken,
170 pub session_ttl: Duration,
171 pub secure_cookie: bool,
174 tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
175 viewer_code: String,
176 login_token: String,
177 cookie_key: Vec<u8>,
178}
179
180impl ServerOptions {
181 pub fn new(
182 bind: SocketAddr,
183 snapshot_rx: watch::Receiver<ViewerSnapshot>,
184 conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
185 action_tx: mpsc::Sender<ControllerRequest>,
186 receipt_tx: mpsc::Sender<ReadReceiptRequest>,
187 preflight_tx: mpsc::Sender<PreflightRequest>,
188 client_state_tx: mpsc::Sender<ClientStateRequest>,
189 ) -> AnyResult<Self> {
190 Ok(Self {
191 bind,
192 snapshot_rx,
193 conversation_rx,
194 action_tx,
195 receipt_tx,
196 preflight_tx,
197 client_state_tx,
198 shutdown: CancellationToken::new(),
199 session_ttl: DEFAULT_SESSION_TTL,
200 secure_cookie: true,
201 tls_config: None,
202 viewer_code: generate_viewer_code()?,
203 login_token: generate_login_token()?,
204 cookie_key: generate_cookie_key()?.to_vec(),
205 })
206 }
207
208 pub fn viewer_code(&self) -> &str {
209 &self.viewer_code
210 }
211
212 pub fn login_token(&self) -> &str {
213 &self.login_token
214 }
215
216 pub fn set_tls_config(&mut self, config: axum_server::tls_rustls::RustlsConfig) {
220 self.tls_config = Some(config);
221 self.secure_cookie = true;
222 }
223
224 pub fn set_cookie_key(&mut self, key: Vec<u8>) -> AnyResult<()> {
227 anyhow::ensure!(
228 key.len() >= COOKIE_KEY_BYTES,
229 "cookie signing key must be at least {COOKIE_KEY_BYTES} bytes"
230 );
231 self.cookie_key = key;
232 Ok(())
233 }
234
235 #[cfg(test)]
236 fn with_test_credentials(mut self, code: &str, key: &[u8]) -> Self {
237 self.viewer_code = code.to_string();
238 self.login_token = "test-login-token".into();
239 self.cookie_key = key.to_vec();
240 self.secure_cookie = false;
241 self
242 }
243}
244
245pub async fn run_server(options: ServerOptions) -> AnyResult<()> {
251 let mut options = options;
252 let bind = options.bind;
253 let shutdown = options.shutdown.clone();
254 let viewer_code = options.viewer_code.clone();
255 let tls_config = options.tls_config.take();
256 let app = router(options);
257 println!("Mjolnir viewer code: {viewer_code}");
258 if let Some(tls_config) = tls_config {
259 let handle = axum_server::Handle::new();
260 let shutdown_handle = handle.clone();
261 tokio::spawn(async move {
262 shutdown.cancelled().await;
263 shutdown_handle.graceful_shutdown(Some(Duration::from_secs(2)));
264 });
265 axum_server::bind_rustls(bind, tls_config)
266 .handle(handle)
267 .serve(app.into_make_service())
268 .await
269 .context("run Mjolnir HTTPS phone server")
270 } else {
271 let listener = tokio::net::TcpListener::bind(bind)
272 .await
273 .with_context(|| format!("bind Mjolnir phone server to {bind}"))?;
274 axum::serve(listener, app)
275 .with_graceful_shutdown(shutdown.cancelled_owned())
276 .await
277 .context("run Mjolnir HTTP phone server")
278 }
279}
280
281#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
282#[serde(deny_unknown_fields)]
283pub struct ViewerSnapshot {
284 pub revision: u64,
285 pub generated_at: String,
286 #[serde(default, skip_serializing_if = "Vec::is_empty")]
287 pub workspaces: Vec<ViewerWorkspace>,
288 pub sessions: Vec<ViewerSession>,
289 pub profiles: Vec<ViewerProfile>,
290 pub targets: Vec<ViewerTarget>,
291 pub bundles: Vec<ViewerBundle>,
292 #[serde(default)]
295 pub review_config: ViewerReviewConfig,
296 #[serde(default, skip_serializing_if = "Vec::is_empty")]
299 pub capacity: Vec<ViewerTargetCapacity>,
300}
301
302impl ViewerSnapshot {
303 pub fn from_config_state(config: &HelConfig, state: &HelState, revision: u64) -> Self {
307 let sessions = state
308 .sessions
309 .values()
310 .map(|session| {
311 let incompatible = config
312 .targets
313 .keys()
314 .filter(|target_id| {
315 crate::hel_controller::resume_compatibility(session, config, target_id)
316 .is_err()
317 })
318 .cloned()
319 .collect::<Vec<_>>();
320 let lifecycle = ViewerLifecycleCategory::of(session.state);
321 ViewerSession {
322 id: session.id.clone(),
323 workspace_id: session.workspace_id.clone(),
324 title: session.display_title().to_owned(),
325 harness_kind: session.harness_kind.id().into(),
326 profile_id: session.last_profile.clone(),
327 bundle_id: session.bundle_id.clone(),
328 target_id: session.target_template_id.clone(),
329 state: session_state_name(session.state).into(),
330 created_at: session.created_at.clone(),
331 updated_at: session.updated_at.clone(),
332 has_error: session.last_error.is_some(),
333 preview: Vec::new(),
334 queued_prompts: Vec::new(),
335 active_user_shells: Vec::new(),
336 pending_elicitations: Vec::new(),
337 conversation_available: false,
338 prompt_images_supported: false,
339 incompatible_resume_targets: incompatible.clone(),
340 compatible_resume_targets: config
341 .targets
342 .keys()
343 .filter(|target_id| !incompatible.contains(*target_id))
344 .cloned()
345 .collect(),
346 project_label: session.project_name(config),
347 project_key: project_key(&session.project_source(config).key),
348 lifecycle,
349 latest_event_ordinal: 0,
350 activity: String::new(),
351 operation: None,
352 chat_phase: ViewerChatPhase::default(),
353 config_options: Vec::new(),
354 plan_mode_active: None,
355 turn_review: None,
356 available_commands: Vec::new(),
357 capabilities: ViewerSessionCapabilities {
361 open: false,
362 prompt: false,
363 run_shell: false,
364 cancel_turn: false,
365 cancel_operation: false,
366 stop: lifecycle.is_dashboard_visible(),
367 rename: true,
368 resume: !lifecycle.is_dashboard_visible(),
369 set_config: false,
370 set_plan_mode: false,
371 },
372 }
373 })
374 .collect();
375 let profiles = config
376 .profiles
377 .iter()
378 .map(|(id, profile)| ViewerProfile {
379 id: id.clone(),
380 harness_kind: profile.kind.id().into(),
381 quota: None,
382 })
383 .collect();
384 let targets = config
385 .targets
386 .iter()
387 .map(|(id, target)| ViewerTarget {
388 id: id.clone(),
389 kind: target_kind_name(target).into(),
390 requires_project_directory: matches!(
391 target,
392 TargetTemplate::LocalBare | TargetTemplate::SshBare { .. }
393 ),
394 })
395 .collect();
396 let bundles = config
397 .bundles
398 .iter()
399 .map(|(id, bundle)| ViewerBundle {
400 id: id.clone(),
401 primary_repository: bundle.primary_repo.clone(),
402 repositories: bundle
403 .repositories
404 .iter()
405 .map(|repository| ViewerRepository {
406 id: repository.id.clone(),
407 github: repository.github.clone(),
408 destination: repository.destination.to_string_lossy().into_owned(),
409 })
410 .collect(),
411 })
412 .collect();
413 Self {
414 revision,
415 generated_at: now_unix().to_string(),
416 workspaces: Vec::new(),
417 sessions,
418 profiles,
419 targets,
420 bundles,
421 review_config: ViewerReviewConfig {
422 enabled: config.review.enabled,
423 tier: config.review.tier.label().to_owned(),
424 profile: config.review.profile.clone(),
425 },
426 capacity: Vec::new(),
427 }
428 }
429}
430
431fn project_key(identity: &str) -> String {
438 use sha2::Digest as _;
439 let digest = Sha256::digest(identity.as_bytes());
440 digest[..8]
441 .iter()
442 .map(|byte| format!("{byte:02x}"))
443 .collect()
444}
445
446#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
447#[serde(deny_unknown_fields)]
448pub struct ViewerSession {
449 pub id: String,
450 #[serde(default, skip_serializing_if = "String::is_empty")]
451 pub workspace_id: String,
452 pub title: String,
453 pub harness_kind: String,
454 pub profile_id: String,
455 pub bundle_id: String,
456 pub target_id: String,
457 pub state: String,
458 pub created_at: String,
459 pub updated_at: String,
460 pub has_error: bool,
461 #[serde(default, skip_serializing_if = "Vec::is_empty")]
462 pub preview: Vec<String>,
463 #[serde(default, skip_serializing_if = "Vec::is_empty")]
464 pub queued_prompts: Vec<ViewerQueuedPrompt>,
465 #[serde(default, skip_serializing_if = "Vec::is_empty")]
466 pub active_user_shells: Vec<ViewerUserShell>,
467 #[serde(default, skip_serializing_if = "Vec::is_empty")]
471 pub pending_elicitations: Vec<ElicitationRequest>,
472 pub conversation_available: bool,
473 #[serde(default)]
477 pub prompt_images_supported: bool,
478 #[serde(default, skip_serializing_if = "Vec::is_empty")]
485 pub incompatible_resume_targets: Vec<String>,
486 #[serde(default, skip_serializing_if = "Vec::is_empty")]
489 pub compatible_resume_targets: Vec<String>,
490 #[serde(default, skip_serializing_if = "String::is_empty")]
493 pub project_label: String,
494 #[serde(default, skip_serializing_if = "String::is_empty")]
498 pub project_key: String,
499 pub lifecycle: ViewerLifecycleCategory,
500 #[serde(default)]
504 pub latest_event_ordinal: u64,
505 #[serde(default, skip_serializing_if = "Option::is_none")]
506 pub operation: Option<ViewerOperation>,
507 #[serde(default)]
508 pub chat_phase: ViewerChatPhase,
509 #[serde(default, skip_serializing_if = "String::is_empty")]
512 pub activity: String,
513 #[serde(default, skip_serializing_if = "Vec::is_empty")]
515 pub config_options: Vec<ViewerConfigOption>,
516 #[serde(default, skip_serializing_if = "Option::is_none")]
518 pub plan_mode_active: Option<bool>,
519 #[serde(default, skip_serializing_if = "Option::is_none")]
522 pub turn_review: Option<ViewerTurnReview>,
523 #[serde(default, skip_serializing_if = "Vec::is_empty")]
527 pub available_commands: Vec<ViewerMjCommand>,
528 pub capabilities: ViewerSessionCapabilities,
529}
530
531#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
533#[serde(deny_unknown_fields)]
534pub struct ViewerMjCommand {
535 pub name: String,
536 pub description: String,
537 pub source: ViewerCommandSource,
540 #[serde(default, skip_serializing_if = "Option::is_none")]
542 pub argument: Option<String>,
543}
544
545#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
546#[serde(rename_all = "snake_case")]
547pub enum ViewerCommandSource {
548 Mj,
549 Agent,
550}
551
552#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
554#[serde(deny_unknown_fields)]
555pub struct ViewerReviewConfig {
556 pub enabled: bool,
557 pub tier: String,
558 #[serde(default, skip_serializing_if = "Option::is_none")]
559 pub profile: Option<String>,
560}
561
562#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
564#[serde(deny_unknown_fields)]
565pub struct ViewerTurnReview {
566 pub tier: String,
568 pub status: String,
570 #[serde(default, skip_serializing_if = "Vec::is_empty")]
572 pub roles: Vec<ViewerReviewRole>,
573 #[serde(default, skip_serializing_if = "Option::is_none")]
575 pub verdict: Option<ViewerReviewVerdict>,
576}
577
578#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
579#[serde(deny_unknown_fields)]
580pub struct ViewerReviewRole {
581 pub label: String,
582 pub state: String,
584}
585
586#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
587#[serde(deny_unknown_fields)]
588pub struct ViewerReviewVerdict {
589 pub kind: String,
591 pub text: String,
593 #[serde(default, skip_serializing_if = "Vec::is_empty")]
597 pub allowed: Vec<String>,
598}
599
600impl ViewerTurnReview {
601 #[must_use]
603 pub fn from_runtime(review: &crate::hel_review_host::RuntimeReviewView) -> Self {
604 Self {
605 tier: review.tier.label().to_owned(),
606 status: review.status.clone(),
607 roles: review
608 .roles
609 .iter()
610 .map(|role| ViewerReviewRole {
611 label: role.label.clone(),
612 state: role.state.label().to_owned(),
613 })
614 .collect(),
615 verdict: review.verdict.as_ref().map(|verdict| ViewerReviewVerdict {
616 kind: match verdict.kind {
617 crate::hel_review_host::VerdictKind::Clean => "clean",
618 crate::hel_review_host::VerdictKind::Findings => "findings",
619 crate::hel_review_host::VerdictKind::Failed => "failed",
620 }
621 .to_owned(),
622 text: verdict.text.clone(),
623 allowed: verdict
624 .allowed
625 .iter()
626 .filter_map(resolution_name)
627 .map(str::to_owned)
628 .collect(),
629 }),
630 }
631 }
632}
633
634#[must_use]
637pub fn resolution_name(resolution: &hel::hel_review::driver::Resolution) -> Option<&'static str> {
638 match resolution {
639 hel::hel_review::driver::Resolution::Forwarded => Some("forward"),
640 hel::hel_review::driver::Resolution::Dismissed => Some("dismiss"),
641 hel::hel_review::driver::Resolution::Cancelled => Some("cancel"),
642 hel::hel_review::driver::Resolution::NothingToReview
644 | hel::hel_review::driver::Resolution::CoverageStarted => None,
645 }
646}
647
648#[must_use]
650pub fn resolution_from_name(name: &str) -> Option<hel::hel_review::driver::Resolution> {
651 match name {
652 "forward" => Some(hel::hel_review::driver::Resolution::Forwarded),
653 "dismiss" => Some(hel::hel_review::driver::Resolution::Dismissed),
654 "cancel" => Some(hel::hel_review::driver::Resolution::Cancelled),
655 _ => None,
656 }
657}
658
659#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
660#[serde(deny_unknown_fields)]
661pub struct ViewerWorkspace {
662 pub id: String,
663 pub name: String,
664}
665
666#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
667#[serde(deny_unknown_fields)]
668pub struct ViewerQueuedPrompt {
669 pub id: String,
670 pub text: String,
671 pub created_at: String,
672}
673
674#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
675#[serde(deny_unknown_fields)]
676pub struct ViewerUserShell {
677 pub id: String,
678 pub command: String,
679 pub started_at_ms: Option<i64>,
680}
681
682#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
683#[serde(deny_unknown_fields)]
684pub struct ViewerProfile {
685 pub id: String,
686 pub harness_kind: String,
687 #[serde(default, skip_serializing_if = "Option::is_none")]
688 pub quota: Option<ViewerQuota>,
689}
690
691#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
697#[serde(deny_unknown_fields)]
698pub struct ViewerQuotaWindow {
699 pub label: String,
700 #[serde(default, skip_serializing_if = "Option::is_none")]
701 pub percent_used: Option<u8>,
702 #[serde(default, skip_serializing_if = "Option::is_none")]
703 pub resets_at: Option<String>,
704 pub projects_exhaustion_before_reset: bool,
707}
708
709#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
710#[serde(deny_unknown_fields)]
711pub struct ViewerQuota {
712 pub summary: String,
715 #[serde(default, skip_serializing_if = "Vec::is_empty")]
716 pub windows: Vec<ViewerQuotaWindow>,
717 #[serde(default, skip_serializing_if = "Option::is_none")]
718 pub resets_at: Option<String>,
719 pub stale: bool,
720 #[serde(default)]
723 pub refreshed_at_epoch_seconds: u64,
724 pub has_error: bool,
727}
728
729#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
735#[serde(deny_unknown_fields)]
736pub struct ViewerTargetCapacity {
737 pub id: String,
738 pub label: String,
741 pub target_ids: Vec<String>,
742 #[serde(default, skip_serializing_if = "Option::is_none")]
743 pub cpu_percent: Option<u8>,
744 #[serde(default, skip_serializing_if = "Option::is_none")]
745 pub memory_used_bytes: Option<u64>,
746 #[serde(default, skip_serializing_if = "Option::is_none")]
747 pub memory_total_bytes: Option<u64>,
748 #[serde(default, skip_serializing_if = "Option::is_none")]
749 pub logical_cores: Option<u64>,
750 #[serde(default, skip_serializing_if = "Option::is_none")]
751 pub disk_total_bytes: Option<u64>,
752 #[serde(default, skip_serializing_if = "Option::is_none")]
754 pub virtual_machines: Option<u64>,
755 #[serde(default, skip_serializing_if = "Option::is_none")]
756 pub sampled_at_epoch_seconds: Option<u64>,
757 pub refreshing: bool,
758 pub stale: bool,
759 pub has_error: bool,
762}
763
764#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
765#[serde(deny_unknown_fields)]
766pub struct ViewerTarget {
767 pub id: String,
768 pub kind: String,
769 pub requires_project_directory: bool,
770}
771
772#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
773#[serde(deny_unknown_fields)]
774pub struct ViewerBundle {
775 pub id: String,
776 pub primary_repository: String,
777 pub repositories: Vec<ViewerRepository>,
778}
779
780#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
781#[serde(deny_unknown_fields)]
782pub struct ViewerRepository {
783 pub id: String,
784 pub github: Option<String>,
785 pub destination: String,
786}
787
788#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
796#[serde(deny_unknown_fields)]
797pub struct ViewerSessionCapabilities {
798 pub open: bool,
799 pub prompt: bool,
800 pub run_shell: bool,
801 pub cancel_turn: bool,
803 pub cancel_operation: bool,
805 pub stop: bool,
806 pub rename: bool,
807 pub resume: bool,
808 pub set_config: bool,
809 pub set_plan_mode: bool,
810}
811
812#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
818#[serde(rename_all = "kebab-case")]
819pub enum ViewerLifecycleCategory {
820 Live,
821 Starting,
822 Stopping,
823 Stopped,
824 Failed,
825}
826
827impl ViewerLifecycleCategory {
828 const fn of(state: SessionState) -> Self {
829 match state {
830 SessionState::Provisioning => Self::Starting,
831 SessionState::Running | SessionState::Disconnected | SessionState::Checkpointing => {
832 Self::Live
833 }
834 SessionState::Closing | SessionState::Destroying => Self::Stopping,
835 SessionState::Stopped => Self::Stopped,
836 SessionState::Lost | SessionState::Error | SessionState::DestroyedWithDataLoss => {
837 Self::Failed
838 }
839 }
840 }
841
842 pub const fn is_dashboard_visible(self) -> bool {
846 matches!(self, Self::Live | Self::Starting | Self::Stopping)
847 }
848}
849
850#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
851#[serde(rename_all = "kebab-case")]
852pub enum ViewerOperationKind {
853 Create,
854 Resume,
855 Stop,
856 Checkpoint,
857}
858
859#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
861#[serde(deny_unknown_fields)]
862pub struct ViewerOperationStage {
863 pub label: String,
864 pub started_at_epoch_seconds: u64,
865}
866
867#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
873#[serde(deny_unknown_fields)]
874pub struct ViewerOperation {
875 pub id: String,
876 pub session_id: String,
877 pub kind: ViewerOperationKind,
878 pub started_at_epoch_seconds: u64,
879 #[serde(default, skip_serializing_if = "Vec::is_empty")]
880 pub stages: Vec<ViewerOperationStage>,
881 #[serde(default, skip_serializing_if = "Option::is_none")]
884 pub notice: Option<String>,
885 pub cancellable: bool,
886}
887
888#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
890#[serde(rename_all = "kebab-case")]
891pub enum ViewerChatPhase {
892 #[default]
893 Idle,
894 Running,
895 Closing,
896 Closed,
897}
898
899#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
900#[serde(deny_unknown_fields)]
901pub struct ViewerConfigChoice {
902 pub value: String,
903 pub name: String,
904 #[serde(default, skip_serializing_if = "Option::is_none")]
905 pub description: Option<String>,
906}
907
908#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
914#[serde(deny_unknown_fields)]
915pub struct ViewerConfigOption {
916 pub key: String,
917 pub label: String,
918 #[serde(default, skip_serializing_if = "Option::is_none")]
919 pub current: Option<String>,
920 pub choices: Vec<ViewerConfigChoice>,
921}
922
923#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
927#[serde(tag = "action", rename_all = "kebab-case", deny_unknown_fields)]
928pub enum ControllerAction {
929 New {
930 #[serde(default)]
935 workspace_id: String,
936 profile_id: String,
937 bundle_id: String,
938 target_id: String,
939 #[serde(default)]
941 title: Option<String>,
942 #[serde(default)]
943 project_directory: Option<PathBuf>,
944 #[serde(default, skip_serializing_if = "Vec::is_empty")]
952 dirty_ack: Vec<String>,
953 },
954 Rename {
956 session_id: String,
957 title: String,
958 },
959 CancelTurn {
962 session_id: String,
963 },
964 SetConfig {
966 session_id: String,
967 key: String,
968 value: String,
969 },
970 SetPlanMode {
973 session_id: String,
974 active: bool,
975 },
976 RefreshQuota {
977 profile_id: String,
978 },
979 RefreshCapacity {
980 target_id: String,
981 },
982 Resume {
983 session_id: String,
984 workspace_id: String,
985 profile_id: String,
986 target_id: String,
987 queue: ResumeQueueDisposition,
988 },
989 Open {
990 session_id: String,
991 },
992 Prompt {
993 session_id: String,
994 text: String,
995 #[serde(default, skip_serializing_if = "Vec::is_empty")]
998 images: Vec<ViewerPromptImage>,
999 },
1000 RunShell {
1001 session_id: String,
1002 command: String,
1003 },
1004 CancelShell {
1005 session_id: String,
1006 shell_command_id: String,
1007 },
1008 Close {
1009 session_id: String,
1010 },
1011 Cancel {
1012 session_id: String,
1013 },
1014 StartReview {
1016 session_id: String,
1017 },
1018 ResolveReview {
1020 session_id: String,
1021 resolution: String,
1023 },
1024 RemoveQueuedPrompt {
1025 session_id: String,
1026 queue_id: String,
1027 },
1028 RespondElicitation {
1030 session_id: String,
1031 elicitation_id: String,
1032 response: ElicitationResponse,
1033 },
1034}
1035
1036#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1039#[serde(deny_unknown_fields)]
1040pub struct ViewerPromptImage {
1041 pub data_base64: String,
1042 pub mime_type: String,
1043 pub width: u32,
1044 pub height: u32,
1045}
1046
1047#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1048#[serde(rename_all = "kebab-case")]
1049pub enum ResumeQueueDisposition {
1050 Start,
1051 Discard,
1052}
1053
1054#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1065pub enum ActionOutcome {
1066 Accepted,
1068 Busy,
1070 SessionBusy,
1072 NotCancellable,
1074 Failed,
1076}
1077
1078impl ActionOutcome {
1079 const fn rejection(self) -> Option<ApiError> {
1081 match self {
1082 Self::Accepted => None,
1083 Self::Busy => Some(ApiError::new(
1084 StatusCode::TOO_MANY_REQUESTS,
1085 "the controller is at its concurrent action limit; retry shortly",
1086 )),
1087 Self::SessionBusy => Some(ApiError::new(
1088 StatusCode::CONFLICT,
1089 "another operation is already running for this session",
1090 )),
1091 Self::NotCancellable => Some(ApiError::new(
1092 StatusCode::CONFLICT,
1093 "the session has no cancellable operation",
1094 )),
1095 Self::Failed => Some(ApiError::new(
1096 StatusCode::INTERNAL_SERVER_ERROR,
1097 "the controller could not start this action",
1098 )),
1099 }
1100 }
1101}
1102
1103#[derive(Debug)]
1104pub struct ControllerRequest {
1105 pub action: ControllerAction,
1106 pub reply: tokio::sync::oneshot::Sender<ActionOutcome>,
1107}
1108
1109#[derive(Debug)]
1126pub struct PreflightRequest {
1127 pub bundle_id: String,
1128 pub reply: tokio::sync::oneshot::Sender<Result<PreflightNew, String>>,
1129}
1130
1131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1137#[serde(deny_unknown_fields)]
1138pub struct PreflightNew {
1139 pub dirty_repositories: Vec<String>,
1140}
1141
1142#[derive(Debug)]
1149pub enum ClientStateRequest {
1150 Read {
1151 client_id: String,
1152 session_id: String,
1153 reply: tokio::sync::oneshot::Sender<Result<ViewerClientState, String>>,
1154 },
1155 SaveDraft {
1156 client_id: String,
1157 session_id: String,
1158 draft: String,
1159 reply: tokio::sync::oneshot::Sender<Result<(), String>>,
1160 },
1161 MarkWorkspaceRead {
1162 client_id: String,
1163 workspace_id: String,
1164 reply: tokio::sync::oneshot::Sender<Result<(), String>>,
1165 },
1166 History {
1167 session_id: String,
1168 query: String,
1169 scope: String,
1170 reply: tokio::sync::oneshot::Sender<Result<ViewerPromptHistory, String>>,
1171 },
1172}
1173
1174#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1175#[serde(deny_unknown_fields)]
1176pub struct ViewerClientState {
1177 pub draft: String,
1178 pub through_event_ordinal: u64,
1179}
1180
1181#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1182#[serde(deny_unknown_fields)]
1183pub struct ViewerPromptHistory {
1184 pub entries: Vec<String>,
1185 pub truncated: bool,
1188}
1189
1190#[derive(Debug)]
1191pub struct ReadReceiptRequest {
1192 pub client_id: String,
1193 pub session_id: String,
1194 pub through: u64,
1195 pub reply: tokio::sync::oneshot::Sender<Result<(), String>>,
1196}
1197
1198#[derive(Clone)]
1199struct ServerState {
1200 snapshot_rx: watch::Receiver<ViewerSnapshot>,
1201 conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
1202 action_tx: mpsc::Sender<ControllerRequest>,
1203 receipt_tx: mpsc::Sender<ReadReceiptRequest>,
1204 preflight_tx: mpsc::Sender<PreflightRequest>,
1205 client_state_tx: mpsc::Sender<ClientStateRequest>,
1206 viewer_code: Arc<str>,
1207 login_token: Arc<str>,
1208 cookie_key: Arc<[u8]>,
1209 session_ttl: Duration,
1210 secure_cookie: bool,
1211 code_guard: Arc<Mutex<CodeGuard>>,
1212}
1213
1214#[derive(Debug, Default)]
1222struct CodeGuard {
1223 failures: u32,
1224 lockouts: u32,
1225 locked_until: Option<Instant>,
1226}
1227
1228impl CodeGuard {
1229 fn locked_at(&mut self, now: Instant) -> bool {
1230 match self.locked_until {
1231 Some(until) if now < until => true,
1232 Some(_) => {
1233 self.locked_until = None;
1236 self.failures = 0;
1237 false
1238 }
1239 None => false,
1240 }
1241 }
1242
1243 fn record_failure_at(&mut self, now: Instant) {
1244 self.failures = self.failures.saturating_add(1);
1245 if self.failures < MAX_CODE_FAILURES {
1246 return;
1247 }
1248 self.failures = 0;
1249 self.lockouts = self.lockouts.saturating_add(1);
1250 self.locked_until = Some(now + code_lockout(self.lockouts));
1251 }
1252}
1253
1254fn code_lockout(lockouts: u32) -> Duration {
1257 let multiplier = 1_u32
1258 .checked_shl(lockouts.saturating_sub(1))
1259 .unwrap_or(u32::MAX);
1260 CODE_LOCKOUT_BASE
1261 .saturating_mul(multiplier)
1262 .min(CODE_LOCKOUT_CAP)
1263}
1264
1265fn router(options: ServerOptions) -> Router {
1266 let state = ServerState {
1267 snapshot_rx: options.snapshot_rx,
1268 conversation_rx: options.conversation_rx,
1269 action_tx: options.action_tx,
1270 receipt_tx: options.receipt_tx,
1271 preflight_tx: options.preflight_tx,
1272 client_state_tx: options.client_state_tx,
1273 viewer_code: options.viewer_code.into(),
1274 login_token: options.login_token.into(),
1275 cookie_key: options.cookie_key.into(),
1276 session_ttl: options.session_ttl,
1277 secure_cookie: options.secure_cookie,
1278 code_guard: Arc::new(Mutex::new(CodeGuard::default())),
1279 };
1280 let protected = Router::new()
1281 .route("/api/snapshot", get(snapshot))
1282 .route("/api/conversations/{session_id}", get(conversation))
1283 .route(
1284 "/api/conversations/{session_id}/read",
1285 post(mark_conversation_read),
1286 )
1287 .route("/api/events", get(events))
1288 .route("/api/preflight/new", post(preflight_new))
1289 .route("/api/sessions/{session_id}/client-state", get(client_state))
1290 .route(
1291 "/api/sessions/{session_id}/draft",
1292 put(save_draft).layer(DefaultBodyLimit::max(MAX_DRAFT_BYTES)),
1293 )
1294 .route("/api/sessions/{session_id}/history", get(prompt_history))
1295 .route(
1296 "/api/workspaces/{workspace_id}/read",
1297 post(mark_workspace_read),
1298 )
1299 .route(
1300 "/api/actions",
1301 post(action).layer(DefaultBodyLimit::max(MAX_PROMPT_BODY_BYTES)),
1302 )
1303 .route_layer(axum::middleware::from_fn_with_state(
1304 state.clone(),
1305 require_session,
1306 ));
1307 Router::new()
1308 .route("/", get(viewer))
1309 .route("/login", get(viewer))
1310 .route("/viewer.css", get(viewer_css))
1311 .route("/viewer.js", get(viewer_js))
1312 .route("/markdown.js", get(markdown_js))
1313 .route("/tool-output.js", get(tool_output_js))
1314 .route("/manifest.webmanifest", get(manifest))
1315 .route("/service-worker.js", get(service_worker))
1316 .route("/icon.svg", get(icon))
1317 .route("/icon-192.png", get(icon_192))
1318 .route("/icon-512.png", get(icon_512))
1319 .route("/maskable-512.png", get(maskable_512))
1320 .route("/apple-touch-icon.png", get(apple_touch_icon))
1321 .route("/fonts/jetbrains-mono.woff2", get(mono_font))
1322 .route("/auth/session", post(create_session).delete(clear_session))
1323 .route("/auth/login", get(create_session_from_query))
1324 .merge(protected)
1325 .layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
1326 .layer(axum::middleware::from_fn(security_headers))
1327 .with_state(state)
1328}
1329
1330async fn require_session(
1331 State(state): State<ServerState>,
1332 request: Request,
1333 next: Next,
1334) -> Result<Response<Body>, ApiError> {
1335 let cookie = request
1336 .headers()
1337 .get(COOKIE)
1338 .and_then(|value| value.to_str().ok())
1339 .and_then(|header| cookie_value(header, COOKIE_NAME));
1340 if cookie.is_some_and(|value| session_cookie_valid(&state.cookie_key, value, now_unix())) {
1341 Ok(next.run(request).await)
1342 } else {
1343 Err(ApiError::unauthorized())
1344 }
1345}
1346
1347#[derive(Debug, Deserialize)]
1348#[serde(deny_unknown_fields)]
1349struct LoginRequest {
1350 code: String,
1351}
1352
1353#[derive(Debug, Deserialize)]
1354#[serde(deny_unknown_fields)]
1355struct LoginQuery {
1356 token: String,
1357}
1358
1359async fn create_session_from_query(
1360 State(state): State<ServerState>,
1361 Query(query): Query<LoginQuery>,
1362) -> Result<Response<Body>, ApiError> {
1363 if !constant_time_eq(state.login_token.as_bytes(), query.token.trim().as_bytes()) {
1364 return Err(ApiError::unauthorized());
1365 }
1366 let mut response = issue_session_cookie(&state, StatusCode::SEE_OTHER)?;
1367 response
1368 .headers_mut()
1369 .insert(LOCATION, HeaderValue::from_static("/"));
1370 response
1371 .headers_mut()
1372 .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
1373 Ok(response)
1374}
1375
1376async fn create_session(
1377 State(state): State<ServerState>,
1378 Json(request): Json<LoginRequest>,
1379) -> Result<Response<Body>, ApiError> {
1380 if code_locked(&state) {
1381 return Err(ApiError::new(
1382 StatusCode::TOO_MANY_REQUESTS,
1383 "too many incorrect codes; wait and try again",
1384 ));
1385 }
1386 if !constant_time_eq(state.viewer_code.as_bytes(), request.code.trim().as_bytes()) {
1387 record_code_failure(&state);
1388 return Err(ApiError::unauthorized());
1389 }
1390 reset_code_failures(&state);
1391 issue_session_cookie(&state, StatusCode::NO_CONTENT)
1392}
1393
1394fn issue_session_cookie(
1395 state: &ServerState,
1396 status: StatusCode,
1397) -> Result<Response<Body>, ApiError> {
1398 let ephemeral = state.session_ttl.is_zero();
1399 let validity = if ephemeral {
1400 EPHEMERAL_SESSION_TTL
1401 } else {
1402 state.session_ttl
1403 };
1404 let value = signed_cookie_value(
1405 &state.cookie_key,
1406 &generate_viewer_id().map_err(|_| {
1407 ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "cookie creation failed")
1408 })?,
1409 now_unix().saturating_add(validity.as_secs()),
1410 );
1411 let cookie = session_cookie_header(
1412 &value,
1413 (!ephemeral).then_some(validity.as_secs()),
1414 state.secure_cookie,
1415 )?;
1416 let mut response = status.into_response();
1417 response.headers_mut().insert(SET_COOKIE, cookie);
1418 Ok(response)
1419}
1420
1421async fn clear_session(State(state): State<ServerState>) -> Response<Body> {
1422 let mut response = StatusCode::NO_CONTENT.into_response();
1423 response
1424 .headers_mut()
1425 .insert(SET_COOKIE, clear_cookie_header(state.secure_cookie));
1426 response
1427}
1428
1429async fn snapshot(State(state): State<ServerState>) -> Response<Body> {
1430 let mut response = Json(state.snapshot_rx.borrow().clone()).into_response();
1431 response
1432 .headers_mut()
1433 .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
1434 response
1435}
1436
1437async fn action(
1443 State(state): State<ServerState>,
1444 Json(action): Json<ControllerAction>,
1445) -> Result<StatusCode, ApiError> {
1446 validate_action(&action, &state.snapshot_rx.borrow())?;
1447 let action = decode_prompt_images_off_task(action).await?;
1448 let (reply, outcome) = tokio::sync::oneshot::channel();
1449 state
1450 .action_tx
1451 .send(ControllerRequest { action, reply })
1452 .await
1453 .map_err(|_| ApiError::controller_unavailable())?;
1454 let outcome = outcome
1455 .await
1456 .map_err(|_| ApiError::controller_unavailable())?;
1457 match outcome.rejection() {
1458 Some(rejection) => Err(rejection),
1459 None => Ok(StatusCode::ACCEPTED),
1460 }
1461}
1462
1463#[derive(Debug, Deserialize)]
1464struct ConversationQuery {
1465 after_seq: Option<u64>,
1466}
1467
1468async fn conversation(
1469 State(state): State<ServerState>,
1470 Path(session_id): Path<String>,
1471 Query(query): Query<ConversationQuery>,
1472) -> Result<Json<BrowserTranscript>, ApiError> {
1473 validate_public_id(&session_id)?;
1474 let conversations = state.conversation_rx.borrow();
1475 let transcript = conversations
1476 .get(&session_id)
1477 .ok_or_else(|| ApiError::not_found("conversation unavailable"))?;
1478 let mut response = transcript.clone();
1479 if let Some(after) = query.after_seq {
1480 response.reset = after < response.window_start_seq;
1481 if !response.reset {
1482 response.entries.retain(|entry| entry.updated_seq > after);
1483 }
1484 }
1485 Ok(Json(response))
1486}
1487
1488#[derive(Debug, Deserialize)]
1489#[serde(deny_unknown_fields)]
1490struct ReadRequest {
1491 through: u64,
1492}
1493
1494async fn mark_conversation_read(
1495 State(state): State<ServerState>,
1496 Path(session_id): Path<String>,
1497 headers: HeaderMap,
1498 Json(request): Json<ReadRequest>,
1499) -> Result<StatusCode, ApiError> {
1500 validate_public_id(&session_id)?;
1501 require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
1502 let (reply, result) = tokio::sync::oneshot::channel();
1503 let client_id = viewer_client_id(&state, &headers).ok_or_else(ApiError::unauthorized)?;
1504 state
1505 .receipt_tx
1506 .send(ReadReceiptRequest {
1507 client_id,
1508 session_id,
1509 through: request.through,
1510 reply,
1511 })
1512 .await
1513 .map_err(|_| ApiError::controller_unavailable())?;
1514 result
1515 .await
1516 .map_err(|_| ApiError::controller_unavailable())?
1517 .map_err(|_| ApiError::new(StatusCode::CONFLICT, "read receipt failed"))?;
1518 Ok(StatusCode::NO_CONTENT)
1519}
1520
1521#[derive(Debug, Deserialize)]
1522#[serde(deny_unknown_fields)]
1523struct PreflightNewRequest {
1524 #[serde(default)]
1525 workspace_id: String,
1526 profile_id: String,
1527 bundle_id: String,
1528 target_id: String,
1529 #[serde(default)]
1530 project_directory: Option<PathBuf>,
1531}
1532
1533async fn preflight_new(
1539 State(state): State<ServerState>,
1540 Json(request): Json<PreflightNewRequest>,
1541) -> Result<Json<PreflightNew>, ApiError> {
1542 let action = ControllerAction::New {
1543 workspace_id: request.workspace_id,
1544 profile_id: request.profile_id,
1545 bundle_id: request.bundle_id.clone(),
1546 target_id: request.target_id,
1547 title: None,
1548 project_directory: request.project_directory.clone(),
1549 dirty_ack: Vec::new(),
1550 };
1551 validate_action(&action, &state.snapshot_rx.borrow())?;
1552 if request.project_directory.is_some() {
1555 return Ok(Json(PreflightNew {
1556 dirty_repositories: Vec::new(),
1557 }));
1558 }
1559 let (reply, result) = tokio::sync::oneshot::channel();
1560 state
1561 .preflight_tx
1562 .send(PreflightRequest {
1563 bundle_id: request.bundle_id,
1564 reply,
1565 })
1566 .await
1567 .map_err(|_| ApiError::controller_unavailable())?;
1568 result
1569 .await
1570 .map_err(|_| ApiError::controller_unavailable())?
1571 .map(Json)
1572 .map_err(|_| {
1573 ApiError::new(
1574 StatusCode::SERVICE_UNAVAILABLE,
1575 "the controller could not check this project",
1576 )
1577 })
1578}
1579
1580async fn ask_client_state<T>(
1582 state: &ServerState,
1583 build: impl FnOnce(tokio::sync::oneshot::Sender<Result<T, String>>) -> ClientStateRequest,
1584) -> Result<T, ApiError> {
1585 let (reply, answer) = tokio::sync::oneshot::channel();
1586 state
1587 .client_state_tx
1588 .send(build(reply))
1589 .await
1590 .map_err(|_| ApiError::controller_unavailable())?;
1591 answer
1592 .await
1593 .map_err(|_| ApiError::controller_unavailable())?
1594 .map_err(|_| {
1595 ApiError::new(
1596 StatusCode::SERVICE_UNAVAILABLE,
1597 "the controller could not reach stored viewer state",
1598 )
1599 })
1600}
1601
1602async fn client_state(
1604 State(state): State<ServerState>,
1605 Path(session_id): Path<String>,
1606 headers: HeaderMap,
1607) -> Result<Json<ViewerClientState>, ApiError> {
1608 validate_public_id(&session_id)?;
1609 require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
1610 let Some(client_id) = viewer_client_id(&state, &headers) else {
1614 return Ok(Json(ViewerClientState::default()));
1615 };
1616 ask_client_state(&state, |reply| ClientStateRequest::Read {
1617 client_id,
1618 session_id,
1619 reply,
1620 })
1621 .await
1622 .map(Json)
1623}
1624
1625#[derive(Debug, Deserialize)]
1626#[serde(deny_unknown_fields)]
1627struct DraftRequest {
1628 draft: String,
1629}
1630
1631async fn save_draft(
1632 State(state): State<ServerState>,
1633 Path(session_id): Path<String>,
1634 headers: HeaderMap,
1635 Json(request): Json<DraftRequest>,
1636) -> Result<StatusCode, ApiError> {
1637 validate_public_id(&session_id)?;
1638 require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
1639 if request.draft.len() > MAX_DRAFT_BYTES {
1640 return Err(ApiError::new(
1641 StatusCode::PAYLOAD_TOO_LARGE,
1642 "draft must be 65536 bytes or fewer",
1643 ));
1644 }
1645 let Some(client_id) = viewer_client_id(&state, &headers) else {
1646 return Err(ApiError::new(
1649 StatusCode::CONFLICT,
1650 "this viewer has no stored identity; unlock again to keep drafts",
1651 ));
1652 };
1653 ask_client_state(&state, |reply| ClientStateRequest::SaveDraft {
1654 client_id,
1655 session_id,
1656 draft: request.draft,
1657 reply,
1658 })
1659 .await?;
1660 Ok(StatusCode::NO_CONTENT)
1661}
1662
1663async fn mark_workspace_read(
1667 State(state): State<ServerState>,
1668 Path(workspace_id): Path<String>,
1669 headers: HeaderMap,
1670) -> Result<StatusCode, ApiError> {
1671 validate_public_id(&workspace_id)?;
1672 let Some(client_id) = viewer_client_id(&state, &headers) else {
1673 return Ok(StatusCode::NO_CONTENT);
1674 };
1675 ask_client_state(&state, |reply| ClientStateRequest::MarkWorkspaceRead {
1676 client_id,
1677 workspace_id,
1678 reply,
1679 })
1680 .await?;
1681 Ok(StatusCode::NO_CONTENT)
1682}
1683
1684#[derive(Debug, Deserialize)]
1685struct HistoryQuery {
1686 #[serde(default)]
1687 q: String,
1688 #[serde(default)]
1689 scope: Option<String>,
1690}
1691
1692async fn prompt_history(
1694 State(state): State<ServerState>,
1695 Path(session_id): Path<String>,
1696 Query(query): Query<HistoryQuery>,
1697) -> Result<Json<ViewerPromptHistory>, ApiError> {
1698 validate_public_id(&session_id)?;
1699 require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
1700 if query.q.chars().count() > MAX_TITLE_CHARS {
1701 return Err(ApiError::bad_request("search text is too long"));
1702 }
1703 let scope = query.scope.unwrap_or_else(|| "project".to_owned());
1704 if !matches!(scope.as_str(), "session" | "project" | "all") {
1705 return Err(ApiError::bad_request(
1706 "scope must be session, project or all",
1707 ));
1708 }
1709 ask_client_state(&state, |reply| ClientStateRequest::History {
1710 session_id,
1711 query: query.q,
1712 scope,
1713 reply,
1714 })
1715 .await
1716 .map(Json)
1717}
1718
1719async fn events(State(state): State<ServerState>) -> impl IntoResponse {
1720 let mut snapshots = state.snapshot_rx.clone();
1721 let (tx, rx) = mpsc::channel::<Result<Event, Infallible>>(8);
1722 tokio::spawn(async move {
1723 let initial = snapshots.borrow().revision;
1724 if tx
1725 .send(Ok(Event::default()
1726 .event("revision")
1727 .data(initial.to_string())))
1728 .await
1729 .is_err()
1730 {
1731 return;
1732 }
1733 while snapshots.changed().await.is_ok() {
1734 let revision = snapshots.borrow_and_update().revision;
1735 if tx
1736 .send(Ok(Event::default()
1737 .event("revision")
1738 .data(revision.to_string())))
1739 .await
1740 .is_err()
1741 {
1742 break;
1743 }
1744 }
1745 });
1746 Sse::new(ReceiverStream::new(rx)).keep_alive(KeepAlive::default())
1747}
1748
1749async fn decode_prompt_images_off_task(
1753 action: ControllerAction,
1754) -> Result<ControllerAction, ApiError> {
1755 let ControllerAction::Prompt { images, .. } = &action else {
1756 return Ok(action);
1757 };
1758 if images.is_empty() {
1759 return Ok(action);
1760 }
1761 tokio::task::spawn_blocking(move || {
1762 let ControllerAction::Prompt { images, .. } = &action else {
1763 unreachable!("only prompt actions carry images")
1764 };
1765 validate_prompt_images(images)?;
1766 Ok(action)
1767 })
1768 .await
1769 .map_err(|_| {
1770 ApiError::new(
1771 StatusCode::INTERNAL_SERVER_ERROR,
1772 "the server could not check the attached images",
1773 )
1774 })?
1775}
1776
1777fn validate_prompt_images(images: &[ViewerPromptImage]) -> Result<(), ApiError> {
1778 for image in images {
1779 if !image.mime_type.starts_with("image/") {
1780 return Err(ApiError::bad_request(
1781 "image mime type must start with image/",
1782 ));
1783 }
1784 if image.width == 0 || image.height == 0 {
1785 return Err(ApiError::bad_request(
1786 "image dimensions must be greater than zero",
1787 ));
1788 }
1789 let bytes = base64::engine::general_purpose::STANDARD
1790 .decode(&image.data_base64)
1791 .map_err(|_| ApiError::bad_request("image data must be valid base64"))?;
1792 if bytes.is_empty() {
1793 return Err(ApiError::bad_request("image data must not be empty"));
1794 }
1795 }
1796 Ok(())
1797}
1798
1799fn validate_action(action: &ControllerAction, snapshot: &ViewerSnapshot) -> Result<(), ApiError> {
1800 match action {
1801 ControllerAction::New {
1802 workspace_id,
1803 profile_id,
1804 bundle_id,
1805 target_id,
1806 title,
1807 project_directory,
1808 dirty_ack,
1809 } => {
1810 if !workspace_id.is_empty() {
1811 validate_public_id(workspace_id)?;
1812 }
1813 validate_public_id(profile_id)?;
1814 validate_public_id(bundle_id)?;
1815 validate_public_id(target_id)?;
1816 if let Some(title) = title {
1817 validate_title(title)?;
1818 }
1819 if dirty_ack.len() > MAX_DIRTY_ACKNOWLEDGEMENTS
1823 || dirty_ack
1824 .iter()
1825 .any(|repository| repository.trim().is_empty() || repository.len() > 256)
1826 {
1827 return Err(ApiError::bad_request(
1828 "dirty acknowledgement must name 0-32 repositories",
1829 ));
1830 }
1831 require_profile(snapshot, profile_id)?;
1832 require_bundle(snapshot, bundle_id)?;
1833 let target = require_target(snapshot, target_id)?;
1834 if target.requires_project_directory != project_directory.is_some() {
1835 return Err(ApiError::bad_request(
1836 "project_directory is required exactly for bare targets",
1837 ));
1838 }
1839 if let Some(directory) = project_directory
1840 && (!directory.is_absolute()
1841 || directory
1842 .components()
1843 .any(|component| component == Component::ParentDir))
1844 {
1845 return Err(ApiError::bad_request(
1846 "project_directory must be an absolute safe path",
1847 ));
1848 }
1849 }
1850 ControllerAction::Resume {
1851 session_id,
1852 workspace_id,
1853 profile_id,
1854 target_id,
1855 ..
1856 } => {
1857 validate_public_id(session_id)?;
1858 validate_public_id(workspace_id)?;
1859 validate_public_id(profile_id)?;
1860 validate_public_id(target_id)?;
1861 let session = require_session_record(snapshot, session_id)?;
1862 require_workspace(snapshot, workspace_id)?;
1863 require_profile(snapshot, profile_id)?;
1864 require_target(snapshot, target_id)?;
1865 if session
1866 .incompatible_resume_targets
1867 .iter()
1868 .any(|incompatible| incompatible == target_id)
1869 {
1870 return Err(ApiError::bad_request(
1871 "this session cannot resume on that target",
1872 ));
1873 }
1874 }
1875 ControllerAction::Open { session_id }
1876 | ControllerAction::Close { session_id }
1877 | ControllerAction::Cancel { session_id }
1878 | ControllerAction::StartReview { session_id } => {
1879 validate_public_id(session_id)?;
1880 require_session_record(snapshot, session_id)?;
1881 }
1882 ControllerAction::ResolveReview {
1883 session_id,
1884 resolution,
1885 } => {
1886 validate_public_id(session_id)?;
1887 let session = require_session_record(snapshot, session_id)?;
1888 let Some(resolution) = resolution_from_name(resolution) else {
1889 return Err(ApiError::bad_request(
1890 "a review is resolved by forward, dismiss, or cancel",
1891 ));
1892 };
1893 let Some(review) = session.turn_review.as_ref() else {
1894 return Err(ApiError::bad_request("no review is open for that session"));
1895 };
1896 let allowed = resolution == hel::hel_review::driver::Resolution::Cancelled
1900 || review.verdict.as_ref().is_some_and(|verdict| {
1901 resolution_name(&resolution)
1902 .is_some_and(|name| verdict.allowed.iter().any(|allowed| allowed == name))
1903 });
1904 if !allowed {
1905 return Err(ApiError::bad_request(
1906 "that review cannot be resolved that way yet",
1907 ));
1908 }
1909 }
1910 ControllerAction::Rename { session_id, title } => {
1911 validate_public_id(session_id)?;
1912 validate_title(title)?;
1913 let session = require_session_record(snapshot, session_id)?;
1914 if !session.capabilities.rename {
1915 return Err(ApiError::bad_request("this session cannot be renamed"));
1916 }
1917 }
1918 ControllerAction::CancelTurn { session_id } => {
1919 validate_public_id(session_id)?;
1920 let session = require_session_record(snapshot, session_id)?;
1921 if !session.capabilities.cancel_turn {
1922 return Err(ApiError::new(
1923 StatusCode::CONFLICT,
1924 "this session has no turn to cancel",
1925 ));
1926 }
1927 }
1928 ControllerAction::SetConfig {
1929 session_id,
1930 key,
1931 value,
1932 } => {
1933 validate_public_id(session_id)?;
1934 let session = require_session_record(snapshot, session_id)?;
1935 if !session.capabilities.set_config {
1936 return Err(ApiError::bad_request(
1937 "this session cannot change configuration now",
1938 ));
1939 }
1940 let option = session
1944 .config_options
1945 .iter()
1946 .find(|option| option.key == *key)
1947 .ok_or_else(|| ApiError::bad_request("this agent does not offer that setting"))?;
1948 if !option.choices.iter().any(|choice| choice.value == *value) {
1949 return Err(ApiError::bad_request(
1950 "this agent does not offer that value for that setting",
1951 ));
1952 }
1953 }
1954 ControllerAction::SetPlanMode { session_id, .. } => {
1955 validate_public_id(session_id)?;
1956 let session = require_session_record(snapshot, session_id)?;
1957 if !session.capabilities.set_plan_mode {
1958 return Err(ApiError::bad_request(
1959 "this session cannot change plan mode now",
1960 ));
1961 }
1962 }
1963 ControllerAction::RefreshQuota { profile_id } => {
1964 validate_public_id(profile_id)?;
1965 require_profile(snapshot, profile_id)?;
1966 }
1967 ControllerAction::RefreshCapacity { target_id } => {
1968 validate_public_id(target_id)?;
1969 require_target(snapshot, target_id)?;
1970 }
1971 ControllerAction::Prompt {
1972 session_id,
1973 text,
1974 images,
1975 } => {
1976 validate_public_id(session_id)?;
1977 let session = require_session_record(snapshot, session_id)?;
1978 if text.starts_with('!') {
1979 return Err(ApiError::bad_request(
1980 "leading ! is reserved for shell commands",
1981 ));
1982 }
1983 if text.chars().count() > MAX_PROMPT_CHARS {
1984 return Err(ApiError::bad_request(
1985 "prompt must contain 1-65536 characters",
1986 ));
1987 }
1988 if text.trim().is_empty() && images.is_empty() {
1989 return Err(ApiError::bad_request(
1990 "prompt must contain text or an image",
1991 ));
1992 }
1993 if !images.is_empty() && !session.prompt_images_supported {
1994 return Err(ApiError::bad_request(
1995 "this session does not support image prompts",
1996 ));
1997 }
1998 if session.turn_review.is_some() {
2003 return Err(ApiError::bad_request(
2004 crate::hel_review_host::PROMPT_HELD_MESSAGE,
2005 ));
2006 }
2007 }
2008 ControllerAction::RunShell {
2009 session_id,
2010 command,
2011 } => {
2012 validate_public_id(session_id)?;
2013 require_session_record(snapshot, session_id)?;
2014 if command.trim().is_empty() || command.chars().count() > MAX_PROMPT_CHARS {
2015 return Err(ApiError::bad_request(
2016 "shell command must contain 1-65536 characters",
2017 ));
2018 }
2019 }
2020 ControllerAction::CancelShell {
2021 session_id,
2022 shell_command_id,
2023 } => {
2024 validate_public_id(session_id)?;
2025 validate_public_id(shell_command_id)?;
2026 let session = require_session_record(snapshot, session_id)?;
2027 if !session
2028 .active_user_shells
2029 .iter()
2030 .any(|shell| shell.id == *shell_command_id)
2031 {
2032 return Err(ApiError::bad_request("unknown active shell command"));
2033 }
2034 }
2035 ControllerAction::RemoveQueuedPrompt {
2036 session_id,
2037 queue_id,
2038 } => {
2039 validate_public_id(session_id)?;
2040 validate_public_id(queue_id)?;
2041 require_session_record(snapshot, session_id)?;
2042 }
2043 ControllerAction::RespondElicitation {
2044 session_id,
2045 elicitation_id,
2046 response,
2047 } => {
2048 validate_public_id(session_id)?;
2049 validate_public_id(elicitation_id)?;
2050 let session = require_session_record(snapshot, session_id)?;
2051 let request = session
2052 .pending_elicitations
2053 .iter()
2054 .find(|request| request.id == *elicitation_id)
2055 .ok_or_else(|| ApiError::not_found("unknown elicitation"))?;
2056 if serde_json::to_vec(response).map_or(usize::MAX, |encoded| encoded.len())
2057 > MAX_ELICITATION_BYTES
2058 {
2059 return Err(ApiError::bad_request("elicitation answer is too large"));
2060 }
2061 if request.validate_response(response).is_err() {
2066 return Err(ApiError::bad_request(
2067 "the answer does not match this elicitation request",
2068 ));
2069 }
2070 }
2071 }
2072 Ok(())
2073}
2074
2075fn validate_public_id(id: &str) -> Result<(), ApiError> {
2076 validate_id("request", id).map_err(|_| ApiError::bad_request("invalid id"))
2077}
2078
2079fn validate_title(title: &str) -> Result<(), ApiError> {
2080 if title.trim().is_empty() || title.chars().count() > MAX_TITLE_CHARS {
2081 Err(ApiError::bad_request("title must contain 1-120 characters"))
2082 } else {
2083 Ok(())
2084 }
2085}
2086
2087fn require_session_record<'a>(
2088 snapshot: &'a ViewerSnapshot,
2089 id: &str,
2090) -> Result<&'a ViewerSession, ApiError> {
2091 snapshot
2092 .sessions
2093 .iter()
2094 .find(|session| session.id == id)
2095 .ok_or_else(|| ApiError::not_found("unknown session"))
2096}
2097
2098fn require_workspace(snapshot: &ViewerSnapshot, id: &str) -> Result<(), ApiError> {
2099 snapshot
2100 .workspaces
2101 .iter()
2102 .any(|workspace| workspace.id == id)
2103 .then_some(())
2104 .ok_or_else(|| ApiError::bad_request("unknown workspace"))
2105}
2106
2107fn require_profile<'a>(
2108 snapshot: &'a ViewerSnapshot,
2109 id: &str,
2110) -> Result<&'a ViewerProfile, ApiError> {
2111 snapshot
2112 .profiles
2113 .iter()
2114 .find(|profile| profile.id == id)
2115 .ok_or_else(|| ApiError::bad_request("unknown profile"))
2116}
2117
2118fn require_target<'a>(
2119 snapshot: &'a ViewerSnapshot,
2120 id: &str,
2121) -> Result<&'a ViewerTarget, ApiError> {
2122 snapshot
2123 .targets
2124 .iter()
2125 .find(|target| target.id == id)
2126 .ok_or_else(|| ApiError::bad_request("unknown target"))
2127}
2128
2129fn require_bundle(snapshot: &ViewerSnapshot, id: &str) -> Result<(), ApiError> {
2130 snapshot
2131 .bundles
2132 .iter()
2133 .any(|bundle| bundle.id == id)
2134 .then_some(())
2135 .ok_or_else(|| ApiError::bad_request("unknown bundle"))
2136}
2137
2138#[derive(Debug, Serialize)]
2139struct ErrorBody<'a> {
2140 error: &'a str,
2141}
2142
2143#[derive(Debug)]
2144struct ApiError {
2145 status: StatusCode,
2146 message: &'static str,
2147}
2148
2149impl ApiError {
2150 const fn new(status: StatusCode, message: &'static str) -> Self {
2151 Self { status, message }
2152 }
2153
2154 const fn unauthorized() -> Self {
2155 Self::new(StatusCode::UNAUTHORIZED, "unauthorized")
2156 }
2157
2158 const fn bad_request(message: &'static str) -> Self {
2159 Self::new(StatusCode::BAD_REQUEST, message)
2160 }
2161
2162 const fn not_found(message: &'static str) -> Self {
2163 Self::new(StatusCode::NOT_FOUND, message)
2164 }
2165
2166 const fn controller_unavailable() -> Self {
2167 Self::new(StatusCode::SERVICE_UNAVAILABLE, "controller unavailable")
2168 }
2169}
2170
2171impl IntoResponse for ApiError {
2172 fn into_response(self) -> Response<Body> {
2173 (
2174 self.status,
2175 Json(ErrorBody {
2176 error: self.message,
2177 }),
2178 )
2179 .into_response()
2180 }
2181}
2182
2183fn code_locked(state: &ServerState) -> bool {
2184 state
2185 .code_guard
2186 .lock()
2187 .expect("viewer code guard poisoned")
2188 .locked_at(Instant::now())
2189}
2190
2191fn record_code_failure(state: &ServerState) {
2192 state
2193 .code_guard
2194 .lock()
2195 .expect("viewer code guard poisoned")
2196 .record_failure_at(Instant::now());
2197}
2198
2199fn reset_code_failures(state: &ServerState) {
2200 *state.code_guard.lock().expect("viewer code guard poisoned") = CodeGuard::default();
2201}
2202
2203fn generate_viewer_code() -> AnyResult<String> {
2204 const RANGE: u32 = 1_000_000;
2207 const LIMIT: u32 = u32::MAX - (u32::MAX % RANGE);
2208 loop {
2209 let mut bytes = [0_u8; 4];
2210 getrandom::fill(&mut bytes)
2211 .map_err(|error| anyhow::anyhow!("generate Mjolnir viewer code: {error}"))?;
2212 let value = u32::from_le_bytes(bytes);
2213 if value < LIMIT {
2214 return Ok(format!("{:06}", value % RANGE));
2215 }
2216 }
2217}
2218
2219fn generate_login_token() -> AnyResult<String> {
2220 let mut token = [0_u8; 32];
2221 getrandom::fill(&mut token)
2222 .map_err(|error| anyhow::anyhow!("generate Mjolnir viewer login token: {error}"))?;
2223 Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(token))
2224}
2225
2226fn generate_cookie_key() -> AnyResult<[u8; COOKIE_KEY_BYTES]> {
2227 let mut key = [0_u8; COOKIE_KEY_BYTES];
2228 getrandom::fill(&mut key)
2229 .map_err(|error| anyhow::anyhow!("generate Mjolnir cookie key: {error}"))?;
2230 Ok(key)
2231}
2232
2233fn generate_viewer_id() -> AnyResult<String> {
2241 let mut id = [0_u8; 16];
2242 getrandom::fill(&mut id)
2243 .map_err(|error| anyhow::anyhow!("generate Mjolnir viewer id: {error}"))?;
2244 Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(id))
2245}
2246
2247fn signed_cookie_value(key: &[u8], viewer: &str, expiry: u64) -> String {
2248 let canonical = format!("{viewer}|{expiry}");
2251 let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts arbitrary key lengths");
2252 mac.update(canonical.as_bytes());
2253 let signature =
2254 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
2255 format!("{viewer}.{expiry}.{signature}")
2256}
2257
2258fn legacy_signed_cookie_value(key: &[u8], expiry: u64) -> String {
2264 let canonical = expiry.to_string();
2265 let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts arbitrary key lengths");
2266 mac.update(canonical.as_bytes());
2267 let signature =
2268 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
2269 format!("{canonical}.{signature}")
2270}
2271
2272fn session_cookie_valid(key: &[u8], value: &str, now: u64) -> bool {
2273 cookie_viewer(key, value, now).is_some()
2274}
2275
2276pub fn mint_desktop_session_cookie(key: &[u8]) -> AnyResult<String> {
2283 let viewer = generate_viewer_id()?;
2284 Ok(signed_cookie_value(
2285 key,
2286 &viewer,
2287 now_unix().saturating_add(EPHEMERAL_SESSION_TTL.as_secs()),
2288 ))
2289}
2290
2291fn cookie_viewer(key: &[u8], value: &str, now: u64) -> Option<Option<String>> {
2296 let parts = value.split('.').collect::<Vec<_>>();
2297 let (viewer, expiry, expected) = match parts.as_slice() {
2298 [viewer, expiry, _] => {
2299 let expiry_value = expiry.parse::<u64>().ok()?;
2300 (
2301 Some((*viewer).to_owned()),
2302 expiry_value,
2303 signed_cookie_value(key, viewer, expiry_value),
2304 )
2305 }
2306 [expiry, _] => {
2307 let expiry_value = expiry.parse::<u64>().ok()?;
2308 (
2309 None,
2310 expiry_value,
2311 legacy_signed_cookie_value(key, expiry_value),
2312 )
2313 }
2314 _ => return None,
2315 };
2316 if now >= expiry {
2317 return None;
2318 }
2319 constant_time_eq(expected.as_bytes(), value.as_bytes()).then_some(viewer)
2320}
2321
2322fn session_cookie_header(
2323 value: &str,
2324 max_age: Option<u64>,
2325 secure: bool,
2326) -> Result<HeaderValue, ApiError> {
2327 let mut header = format!("{COOKIE_NAME}={value}; Path=/; HttpOnly; SameSite=Strict");
2328 if secure {
2329 header.push_str("; Secure");
2330 }
2331 if let Some(max_age) = max_age {
2332 header.push_str(&format!("; Max-Age={max_age}"));
2333 }
2334 HeaderValue::from_str(&header)
2335 .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "cookie creation failed"))
2336}
2337
2338fn clear_cookie_header(secure: bool) -> HeaderValue {
2339 let secure = if secure { "; Secure" } else { "" };
2340 HeaderValue::from_str(&format!(
2341 "{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Strict{secure}; Max-Age=0"
2342 ))
2343 .expect("static cookie header is valid")
2344}
2345
2346fn viewer_client_id(state: &ServerState, headers: &HeaderMap) -> Option<String> {
2353 let cookie = headers
2354 .get(COOKIE)
2355 .and_then(|value| value.to_str().ok())
2356 .and_then(|header| cookie_value(header, COOKIE_NAME))?;
2357 cookie_viewer(&state.cookie_key, cookie, now_unix())
2358 .flatten()
2359 .map(|viewer| format!("phone:{viewer}"))
2360}
2361
2362fn cookie_value<'a>(header: &'a str, name: &str) -> Option<&'a str> {
2363 header
2364 .split(';')
2365 .filter_map(|part| part.trim().split_once('='))
2366 .find(|(cookie_name, _)| *cookie_name == name)
2367 .map(|(_, value)| value)
2368}
2369
2370fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
2371 if left.len() != right.len() {
2372 return false;
2373 }
2374 left.iter()
2375 .zip(right)
2376 .fold(0_u8, |difference, (left, right)| {
2377 difference | (left ^ right)
2378 })
2379 == 0
2380}
2381
2382fn now_unix() -> u64 {
2383 SystemTime::now()
2384 .duration_since(UNIX_EPOCH)
2385 .map(|elapsed| elapsed.as_secs())
2386 .unwrap_or(u64::MAX)
2387}
2388
2389const fn session_state_name(state: SessionState) -> &'static str {
2390 match state {
2391 SessionState::Provisioning => "provisioning",
2392 SessionState::Running => "running",
2393 SessionState::Disconnected => "disconnected",
2394 SessionState::Checkpointing => "checkpointing",
2395 SessionState::Closing => "closing",
2396 SessionState::Destroying => "destroying",
2397 SessionState::Stopped => "stopped",
2398 SessionState::Lost => "lost",
2399 SessionState::Error => "error",
2400 SessionState::DestroyedWithDataLoss => "destroyed-with-data-loss",
2401 }
2402}
2403
2404const fn target_kind_name(target: &TargetTemplate) -> &'static str {
2405 match target {
2406 TargetTemplate::LocalBare => "local-bare",
2407 TargetTemplate::LocalPodman { .. } => "local-podman",
2408 TargetTemplate::LocalDocker { .. } => "local-docker",
2409 TargetTemplate::AppleContainer { .. } => "apple-container",
2410 TargetTemplate::AwsEc2 { .. } => "aws-ec2",
2411 TargetTemplate::SshBare { .. } => "ssh-bare",
2412 TargetTemplate::SshPodman { .. } => "ssh-podman",
2413 }
2414}
2415
2416const VIEWER_HTML: &str = include_str!("web/viewer.html");
2421const VIEWER_CSS: &str = include_str!("web/viewer.css");
2422const VIEWER_JS: &str = include_str!("web/viewer.js");
2423const MARKDOWN_JS: &str = include_str!("web/markdown.js");
2424const TOOL_OUTPUT_JS: &str = include_str!("web/tool-output.js");
2425#[cfg(test)]
2429const TEST_DOM_JS: &str = include_str!("web/test-dom.js");
2430const SERVICE_WORKER: &str = include_str!("web/service-worker.js");
2431const MANIFEST: &str = include_str!("web/manifest.webmanifest");
2432const ICON_SVG: &str = include_str!("../src/icons/icon.svg");
2433const ICON_192: &[u8] = include_bytes!("../src/icons/icon-192.png");
2434const ICON_512: &[u8] = include_bytes!("../src/icons/icon-512.png");
2435const MASKABLE_512: &[u8] = include_bytes!("../src/icons/maskable-512.png");
2436const APPLE_TOUCH_ICON: &[u8] = include_bytes!("../src/icons/apple-touch-icon.png");
2437const MONO_FONT: &[u8] = include_bytes!("../src/fonts/jetbrains-mono.woff2");
2438
2439const CONTENT_SECURITY_POLICY: &str = "default-src 'none'; \
2447script-src 'self'; \
2448style-src 'self'; \
2449img-src 'self' data:; \
2450font-src 'self'; \
2451connect-src 'self'; \
2452manifest-src 'self'; \
2453base-uri 'none'; \
2454form-action 'none'; \
2455frame-ancestors 'none'";
2456
2457async fn viewer() -> Response<Body> {
2458 static_response("text/html; charset=utf-8", VIEWER_HTML, true)
2459}
2460
2461async fn viewer_css() -> Response<Body> {
2462 static_response("text/css; charset=utf-8", VIEWER_CSS, false)
2463}
2464
2465async fn viewer_js() -> Response<Body> {
2466 static_response("text/javascript; charset=utf-8", VIEWER_JS, false)
2467}
2468
2469async fn markdown_js() -> Response<Body> {
2470 static_response("text/javascript; charset=utf-8", MARKDOWN_JS, false)
2471}
2472
2473async fn tool_output_js() -> Response<Body> {
2474 static_response("text/javascript; charset=utf-8", TOOL_OUTPUT_JS, false)
2475}
2476
2477async fn manifest() -> Response<Body> {
2478 static_response("application/manifest+json", MANIFEST, false)
2479}
2480
2481async fn service_worker() -> Response<Body> {
2485 static_response("text/javascript; charset=utf-8", SERVICE_WORKER, true)
2486}
2487
2488async fn icon() -> Response<Body> {
2489 static_response("image/svg+xml", ICON_SVG, false)
2490}
2491
2492async fn icon_192() -> Response<Body> {
2493 binary_response("image/png", ICON_192)
2494}
2495
2496async fn icon_512() -> Response<Body> {
2497 binary_response("image/png", ICON_512)
2498}
2499
2500async fn maskable_512() -> Response<Body> {
2501 binary_response("image/png", MASKABLE_512)
2502}
2503
2504async fn apple_touch_icon() -> Response<Body> {
2505 binary_response("image/png", APPLE_TOUCH_ICON)
2506}
2507
2508async fn mono_font() -> Response<Body> {
2509 binary_response("font/woff2", MONO_FONT)
2510}
2511
2512fn static_response(
2513 content_type: &'static str,
2514 body: &'static str,
2515 no_store: bool,
2516) -> Response<Body> {
2517 finish_static(Response::new(Body::from(body)), content_type, no_store)
2518}
2519
2520fn binary_response(content_type: &'static str, body: &'static [u8]) -> Response<Body> {
2521 finish_static(Response::new(Body::from(body)), content_type, false)
2522}
2523
2524fn finish_static(
2528 mut response: Response<Body>,
2529 content_type: &'static str,
2530 no_store: bool,
2531) -> Response<Body> {
2532 let headers = response.headers_mut();
2533 headers.insert(CONTENT_TYPE, HeaderValue::from_static(content_type));
2534 headers.insert(
2535 CACHE_CONTROL,
2536 HeaderValue::from_static(if no_store { "no-store" } else { "no-cache" }),
2537 );
2538 response
2539}
2540
2541async fn security_headers(request: Request, next: Next) -> Response<Body> {
2549 let live = {
2550 let path = request.uri().path();
2551 path.starts_with("/api/") || path.starts_with("/auth/")
2552 };
2553 let mut response = next.run(request).await;
2554 let headers = response.headers_mut();
2555 headers.insert(
2556 CONTENT_SECURITY_POLICY_HEADER,
2557 HeaderValue::from_static(CONTENT_SECURITY_POLICY),
2558 );
2559 headers.insert(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
2560 headers.insert(REFERRER_POLICY, HeaderValue::from_static("no-referrer"));
2561 if live {
2562 headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
2563 }
2564 response
2565}
2566
2567#[cfg(test)]
2568mod tests {
2569 use super::*;
2570 use std::collections::BTreeMap;
2571
2572 use axum::http::Request;
2573 use http_body_util::BodyExt as _;
2574 use tower::ServiceExt as _;
2575
2576 use hel::hel_config::{
2577 CONFIG_VERSION, ContainerTemplate, HarnessKind, HarnessProfile, ProjectBundle,
2578 ProjectRepository,
2579 };
2580 use hel::hel_state::{STATE_VERSION, SessionRecord};
2581
2582 #[test]
2583 fn minted_desktop_cookie_validates_and_names_a_viewer() {
2584 let key = vec![7u8; COOKIE_KEY_BYTES];
2585 let value = mint_desktop_session_cookie(&key).unwrap();
2586 let viewer = cookie_viewer(&key, &value, now_unix());
2587 assert!(
2588 matches!(viewer, Some(Some(_))),
2589 "minted cookie must validate and carry a viewer id: {value:?}"
2590 );
2591 assert!(!session_cookie_valid(
2592 &[8u8; COOKIE_KEY_BYTES],
2593 &value,
2594 now_unix()
2595 ));
2596 }
2597
2598 fn sample_config_state() -> (HelConfig, HelState) {
2599 let config = HelConfig {
2600 version: CONFIG_VERSION,
2601 newer_config_version: None,
2602 phone: Default::default(),
2603 review: Default::default(),
2604 profiles: BTreeMap::from([(
2605 "codex-1".into(),
2606 HarnessProfile {
2607 context_window_bytes: None,
2608 kind: HarnessKind::Codex,
2609 home: "/highly/secret/codex".into(),
2610 executable: None,
2611 environment: BTreeMap::from([("GH_TOKEN".into(), "secret-token".into())]),
2612 },
2613 )]),
2614 bundles: BTreeMap::from([(
2615 "hel".into(),
2616 ProjectBundle {
2617 primary_repo: "hel".into(),
2618 repositories: vec![ProjectRepository {
2619 id: "hel".into(),
2620 github: Some("owner/hel".into()),
2621 local: Some("/private/source/hel".into()),
2622 destination: "hel".into(),
2623 git_ref: None,
2624 }],
2625 },
2626 )]),
2627 targets: BTreeMap::from([
2628 (
2629 "podman".into(),
2630 TargetTemplate::LocalPodman {
2631 container: ContainerTemplate {
2632 image: "secret.registry/image".into(),
2633 pull_policy: Default::default(),
2634 platform: None,
2635 cpus: None,
2636 memory: None,
2637 environment: BTreeMap::from([("TOKEN".into(), "secret-target".into())]),
2638 workspace_storage: Default::default(),
2639 },
2640 },
2641 ),
2642 ("raw".into(), TargetTemplate::LocalBare),
2643 ]),
2644 };
2645 let state = HelState {
2646 version: STATE_VERSION,
2647 sessions: BTreeMap::from([(
2648 "session-1".into(),
2649 SessionRecord {
2650 workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
2651 archived: false,
2652 container_cpus: None,
2653 container_memory: None,
2654 id: "session-1".into(),
2655 title: "Build Hel".into(),
2656 harness_kind: HarnessKind::Codex,
2657 last_profile: "codex-1".into(),
2658 bundle_id: "hel".into(),
2659 project_directory: None,
2660 managed_worktree: None,
2661 target_template_id: "podman".into(),
2662 resource_allocation: None,
2663 additional_mounts: vec![],
2664 state: SessionState::Running,
2665 target: None,
2666 native_session_id: Some("native-secret-id".into()),
2667 acp_session_title: Some("Build Hel".into()),
2668 session_title_override: None,
2669 created_at: "now".into(),
2670 updated_at: "now".into(),
2671 viewed_through_event_ordinal: 0,
2672 draft_input: String::new(),
2673 last_error: Some("secret-token at /highly/secret/codex".into()),
2674 last_checkpoint_error: None,
2675 checkpoint: None,
2676 },
2677 )]),
2678 mount_history: BTreeMap::new(),
2679 container_sizes: BTreeMap::new(),
2680 };
2681 (config, state)
2682 }
2683
2684 type TestServer = (
2685 Router,
2686 mpsc::Receiver<ControllerRequest>,
2687 mpsc::Receiver<ReadReceiptRequest>,
2688 mpsc::Receiver<PreflightRequest>,
2689 mpsc::Receiver<ClientStateRequest>,
2690 );
2691
2692 fn app() -> TestServer {
2693 app_with_conversations(BTreeMap::new())
2694 }
2695
2696 fn app_with_conversations(conversations: BTreeMap<String, BrowserTranscript>) -> TestServer {
2697 app_with(conversations, |_| {})
2698 }
2699
2700 fn app_with_snapshot(adjust: impl FnOnce(&mut ViewerSnapshot)) -> TestServer {
2701 app_with(BTreeMap::new(), adjust)
2702 }
2703
2704 fn app_with(
2705 conversations: BTreeMap<String, BrowserTranscript>,
2706 adjust: impl FnOnce(&mut ViewerSnapshot),
2707 ) -> TestServer {
2708 let (config, state) = sample_config_state();
2709 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
2710 adjust(&mut snapshot);
2711 let (_snapshot_tx, snapshot_rx) = watch::channel(snapshot);
2712 let (_conversation_tx, conversation_rx) = watch::channel(conversations);
2713 let (action_tx, action_rx) = mpsc::channel(8);
2714 let (receipt_tx, receipt_rx) = mpsc::channel(8);
2715 let (preflight_tx, preflight_rx) = mpsc::channel(8);
2716 let (client_state_tx, client_state_rx) = mpsc::channel(8);
2717 let options = test_options(
2718 snapshot_rx,
2719 conversation_rx,
2720 action_tx,
2721 receipt_tx,
2722 preflight_tx,
2723 client_state_tx,
2724 )
2725 .with_test_credentials("123456", b"01234567890123456789012345678901");
2726 (
2727 router(options),
2728 action_rx,
2729 receipt_rx,
2730 preflight_rx,
2731 client_state_rx,
2732 )
2733 }
2734
2735 fn test_options(
2736 snapshot_rx: watch::Receiver<ViewerSnapshot>,
2737 conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
2738 action_tx: mpsc::Sender<ControllerRequest>,
2739 receipt_tx: mpsc::Sender<ReadReceiptRequest>,
2740 preflight_tx: mpsc::Sender<PreflightRequest>,
2741 client_state_tx: mpsc::Sender<ClientStateRequest>,
2742 ) -> ServerOptions {
2743 ServerOptions::new(
2744 "127.0.0.1:0".parse().unwrap(),
2745 snapshot_rx,
2746 conversation_rx,
2747 action_tx,
2748 receipt_tx,
2749 preflight_tx,
2750 client_state_tx,
2751 )
2752 .unwrap()
2753 }
2754
2755 fn detached_options() -> ServerOptions {
2756 let (config, state) = sample_config_state();
2757 let (_snapshot_tx, snapshot_rx) =
2758 watch::channel(ViewerSnapshot::from_config_state(&config, &state, 1));
2759 let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
2760 let (action_tx, _action_rx) = mpsc::channel(1);
2761 let (receipt_tx, _receipt_rx) = mpsc::channel(1);
2762 let (preflight_tx, _preflight_rx) = mpsc::channel(1);
2763 let (client_state_tx, _client_state_rx) = mpsc::channel(1);
2764 test_options(
2765 snapshot_rx,
2766 conversation_rx,
2767 action_tx,
2768 receipt_tx,
2769 preflight_tx,
2770 client_state_tx,
2771 )
2772 }
2773
2774 fn cookie() -> String {
2780 format!(
2781 "{COOKIE_NAME}={}",
2782 signed_cookie_value(
2783 b"01234567890123456789012345678901",
2784 "test-viewer",
2785 now_unix().saturating_add(3600)
2786 )
2787 )
2788 }
2789
2790 async fn login_cookie(app: &Router) -> String {
2791 let response = app
2792 .clone()
2793 .oneshot(
2794 Request::post("/auth/session")
2795 .header(CONTENT_TYPE, "application/json")
2796 .body(Body::from(r#"{"code":"123456"}"#))
2797 .unwrap(),
2798 )
2799 .await
2800 .unwrap();
2801 assert_eq!(response.status(), StatusCode::NO_CONTENT);
2802 response
2803 .headers()
2804 .get(SET_COOKIE)
2805 .unwrap()
2806 .to_str()
2807 .unwrap()
2808 .split(';')
2809 .next()
2810 .unwrap()
2811 .to_string()
2812 }
2813
2814 #[tokio::test]
2815 async fn api_requires_a_valid_signed_cookie() {
2816 let (app, _, _, _, _) = app();
2817 let unauthorized = app
2818 .clone()
2819 .oneshot(Request::get("/api/snapshot").body(Body::empty()).unwrap())
2820 .await
2821 .unwrap();
2822 assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
2823
2824 let cookie = login_cookie(&app).await;
2825 let authorized = app
2826 .oneshot(
2827 Request::get("/api/snapshot")
2828 .header(COOKIE, cookie)
2829 .body(Body::empty())
2830 .unwrap(),
2831 )
2832 .await
2833 .unwrap();
2834 assert_eq!(authorized.status(), StatusCode::OK);
2835 }
2836
2837 #[tokio::test]
2838 async fn qr_login_exchanges_the_secret_for_a_cookie_and_redirects_cleanly() {
2839 let (app, _, _, _, _) = app();
2840 let rejected = app
2841 .clone()
2842 .oneshot(
2843 Request::get("/auth/login?token=wrong")
2844 .body(Body::empty())
2845 .unwrap(),
2846 )
2847 .await
2848 .unwrap();
2849 assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED);
2850
2851 let accepted = app
2852 .oneshot(
2853 Request::get("/auth/login?token=test-login-token")
2854 .body(Body::empty())
2855 .unwrap(),
2856 )
2857 .await
2858 .unwrap();
2859 assert_eq!(accepted.status(), StatusCode::SEE_OTHER);
2860 assert_eq!(accepted.headers().get(LOCATION).unwrap(), "/");
2861 assert_eq!(accepted.headers().get(CACHE_CONTROL).unwrap(), "no-store");
2862 assert!(accepted.headers().contains_key(SET_COOKIE));
2863 }
2864
2865 #[test]
2866 fn signed_cookie_rejects_expiry_and_tampering() {
2867 let key = b"01234567890123456789012345678901";
2868 let cookie = signed_cookie_value(key, "test-viewer", 200);
2869 assert!(session_cookie_valid(key, &cookie, 100));
2870 assert!(!session_cookie_valid(key, &cookie, 200));
2871 assert!(!session_cookie_valid(key, &format!("{cookie}x"), 100));
2872 assert!(!session_cookie_valid(b"another-key", &cookie, 100));
2873 }
2874
2875 #[test]
2876 fn generated_code_and_cookie_attributes_are_phone_safe() {
2877 let code = generate_viewer_code().unwrap();
2878 assert_eq!(code.len(), 6);
2879 assert!(code.bytes().all(|byte| byte.is_ascii_digit()));
2880 let header = session_cookie_header("signed", Some(60), true)
2881 .unwrap()
2882 .to_str()
2883 .unwrap()
2884 .to_string();
2885 assert!(header.contains("HttpOnly"));
2886 assert!(header.contains("SameSite=Strict"));
2887 assert!(header.contains("Secure"));
2888 assert!(header.contains("Max-Age=60"));
2889 }
2890
2891 #[test]
2892 fn public_snapshot_omits_homes_environment_locators_and_raw_errors() {
2893 let (config, state) = sample_config_state();
2894 let json =
2895 serde_json::to_string(&ViewerSnapshot::from_config_state(&config, &state, 9)).unwrap();
2896 assert!(!json.contains("/highly/secret"));
2897 assert!(!json.contains("secret-token"));
2898 assert!(!json.contains("secret-target"));
2899 assert!(!json.contains("secret.registry"));
2900 assert!(!json.contains("native-secret-id"));
2901 assert!(json.contains("\"has_error\":true"));
2902 }
2903
2904 #[test]
2905 fn public_snapshot_exposes_only_review_status_configuration() {
2906 let (mut config, state) = sample_config_state();
2907 config.review = hel::hel_config::ReviewConfig {
2908 enabled: true,
2909 tier: hel::hel_review::lanes::ReviewTier::Extended,
2910 profile: Some("reviewer-1".into()),
2911 model: Some("private-review-model".into()),
2912 effort: Some("private-review-effort".into()),
2913 };
2914
2915 let value =
2916 serde_json::to_value(ViewerSnapshot::from_config_state(&config, &state, 9)).unwrap();
2917
2918 assert_eq!(
2919 value.get("review_config"),
2920 Some(&serde_json::json!({
2921 "enabled": true,
2922 "tier": "extended",
2923 "profile": "reviewer-1",
2924 }))
2925 );
2926 let json = value.to_string();
2927 assert!(!json.contains("private-review-model"));
2928 assert!(!json.contains("private-review-effort"));
2929 }
2930
2931 fn sample_elicitation() -> ElicitationRequest {
2932 ElicitationRequest::from_acp_params(
2933 "elicitation-1",
2934 serde_json::json!({
2935 "sessionId": "session-1",
2936 "mode": "form",
2937 "message": "Which CI architecture should the workflow use?",
2938 "requestedSchema": {
2939 "type": "object",
2940 "required": ["question_0"],
2941 "properties": {
2942 "question_0": {
2943 "type": "string",
2944 "title": "CI architecture",
2945 "oneOf": [
2946 {"const": "reusable", "title": "Reusable workflow"},
2947 {"const": "matrix", "title": "Matrix job"}
2948 ]
2949 },
2950 "question_0_custom": {
2951 "type": "string",
2952 "title": "Other",
2953 "_meta": {"_askUserQuestionCustomAnswer": {
2954 "questionId": "question_0",
2955 "isCustomAnswer": true
2956 }}
2957 }
2958 }
2959 }
2960 }),
2961 )
2962 .expect("sample elicitation parses")
2963 }
2964
2965 fn accept(pairs: &[(&str, &str)]) -> ElicitationResponse {
2966 ElicitationResponse::Accept {
2967 content: pairs
2968 .iter()
2969 .map(|(id, value)| {
2970 (
2971 (*id).to_owned(),
2972 hel::hel_elicitation::ElicitationValue::String((*value).to_owned()),
2973 )
2974 })
2975 .collect(),
2976 }
2977 }
2978
2979 fn pending_elicitation_snapshot(snapshot: &mut ViewerSnapshot) {
2980 snapshot.sessions[0].pending_elicitations = vec![sample_elicitation()];
2981 }
2982
2983 #[tokio::test]
2984 async fn elicitation_answer_is_typed_and_forwarded() {
2985 let (app, mut actions, _, _, _) = app_with_snapshot(pending_elicitation_snapshot);
2986 let cookie = login_cookie(&app).await;
2987 let response = tokio::spawn(
2988 app.oneshot(
2989 Request::post("/api/actions")
2990 .header(COOKIE, cookie)
2991 .header(CONTENT_TYPE, "application/json")
2992 .body(Body::from(
2993 r#"{"action":"respond-elicitation","session_id":"session-1","elicitation_id":"elicitation-1","response":{"action":"accept","content":{"question_0":"reusable"}}}"#,
2994 ))
2995 .unwrap(),
2996 ),
2997 );
2998 let action = actions.recv().await.unwrap();
2999 assert_eq!(
3000 action.action,
3001 ControllerAction::RespondElicitation {
3002 session_id: "session-1".into(),
3003 elicitation_id: "elicitation-1".into(),
3004 response: accept(&[("question_0", "reusable")]),
3005 }
3006 );
3007 action.reply.send(ActionOutcome::Accepted).unwrap();
3008 assert_eq!(
3009 response.await.unwrap().unwrap().status(),
3010 StatusCode::ACCEPTED
3011 );
3012 }
3013
3014 #[tokio::test]
3015 async fn elicitation_answer_for_an_unknown_request_is_refused_without_reaching_the_controller()
3016 {
3017 let (app, mut actions, _, _, _) = app_with_snapshot(pending_elicitation_snapshot);
3018 let cookie = login_cookie(&app).await;
3019 let response = app
3020 .oneshot(
3021 Request::post("/api/actions")
3022 .header(COOKIE, cookie)
3023 .header(CONTENT_TYPE, "application/json")
3024 .body(Body::from(
3025 r#"{"action":"respond-elicitation","session_id":"session-1","elicitation_id":"elicitation-9","response":{"action":"cancel"}}"#,
3026 ))
3027 .unwrap(),
3028 )
3029 .await
3030 .unwrap();
3031 assert_eq!(response.status(), StatusCode::NOT_FOUND);
3032 assert!(actions.try_recv().is_err());
3033 }
3034
3035 #[test]
3036 fn elicitation_answers_are_checked_against_the_request_the_agent_asked() {
3037 let (config, state) = sample_config_state();
3038 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
3039 pending_elicitation_snapshot(&mut snapshot);
3040 let respond = |response: ElicitationResponse| ControllerAction::RespondElicitation {
3041 session_id: "session-1".into(),
3042 elicitation_id: "elicitation-1".into(),
3043 response,
3044 };
3045
3046 assert!(validate_action(&respond(accept(&[("question_0", "matrix")])), &snapshot).is_ok());
3047 assert!(validate_action(&respond(ElicitationResponse::Decline), &snapshot).is_ok());
3050 assert!(validate_action(&respond(accept(&[("question_0", "cron")])), &snapshot).is_err());
3053 assert!(validate_action(&respond(accept(&[("smuggled", "yes")])), &snapshot).is_err());
3054 assert!(validate_action(&respond(accept(&[])), &snapshot).is_err());
3055 assert!(
3058 validate_action(
3059 &respond(accept(&[("question_0_custom", "a monorepo pipeline")])),
3060 &snapshot,
3061 )
3062 .is_ok()
3063 );
3064 }
3065
3066 #[test]
3067 fn oversized_elicitation_answers_are_refused() {
3068 let (config, state) = sample_config_state();
3069 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
3070 pending_elicitation_snapshot(&mut snapshot);
3071 let long = "x".repeat(MAX_ELICITATION_BYTES);
3072 assert!(
3073 validate_action(
3074 &ControllerAction::RespondElicitation {
3075 session_id: "session-1".into(),
3076 elicitation_id: "elicitation-1".into(),
3077 response: accept(&[("question_0_custom", long.as_str())]),
3078 },
3079 &snapshot,
3080 )
3081 .is_err()
3082 );
3083 }
3084
3085 fn viewer_source(from: &str, to: &str) -> &'static str {
3093 let start = VIEWER_JS
3094 .find(from)
3095 .unwrap_or_else(|| panic!("src/web/viewer.js no longer contains {from:?}"));
3096 let end = VIEWER_JS[start..]
3097 .find(to)
3098 .map(|offset| start + offset)
3099 .unwrap_or_else(|| {
3100 panic!("src/web/viewer.js no longer contains {to:?} after {from:?}")
3101 });
3102 &VIEWER_JS[start..end]
3103 }
3104
3105 fn run_web_check(name: &str, check: &str) {
3112 let directory = tempfile::tempdir().expect("temporary directory for a web check");
3113 for (file, source) in [
3114 ("test-dom.js", TEST_DOM_JS),
3115 ("markdown.js", MARKDOWN_JS),
3116 ("tool-output.js", TOOL_OUTPUT_JS),
3117 ] {
3118 std::fs::write(directory.path().join(file), source).expect("write a web module");
3119 }
3120 let path = directory.path().join(format!("{name}.mjs"));
3121 std::fs::write(&path, check).expect("write the web check");
3122 let output = std::process::Command::new("node")
3123 .arg(&path)
3124 .output()
3125 .expect("Node.js is required to exercise the web viewer");
3126 assert!(
3127 output.status.success(),
3128 "{name} failed:\nstdout:\n{}\nstderr:\n{}",
3129 String::from_utf8_lossy(&output.stdout),
3130 String::from_utf8_lossy(&output.stderr),
3131 );
3132 }
3133
3134 fn run_viewer_script(name: &str, script: &str) {
3138 run_web_check(name, script);
3139 }
3140
3141 #[test]
3142 fn embedded_viewer_lists_histories_from_every_workspace() {
3143 let source = viewer_source("function renderResumable()", "function resumableCard");
3144 let setup = r#"
3145const snapshot = {
3146 sessions: [
3147 { id: "history-a", workspace_id: "workspace-a", capabilities: { resume: true } },
3148 { id: "history-b", workspace_id: "workspace-b", capabilities: { resume: true } },
3149 { id: "running-b", workspace_id: "workspace-b", capabilities: { resume: false } },
3150 ],
3151};
3152const resumable = {
3153 children: [],
3154 replaceChildren(...children) { this.children = children; },
3155};
3156function resumableCard(session) { return session.id; }
3157function el(_tag, _className, text) { return text; }
3158"#;
3159 let checks = r#"
3160renderResumable();
3161if (JSON.stringify(resumable.children) !== JSON.stringify(["history-a", "history-b"])) {
3162 throw new Error(`global histories were filtered: ${JSON.stringify(resumable.children)}`);
3163}
3164"#;
3165 run_viewer_script(
3166 "global-resume-history",
3167 &format!("{setup}\n{source}\n{checks}"),
3168 );
3169 }
3170
3171 #[test]
3172 fn embedded_viewer_sends_the_selected_resume_workspace() {
3173 let source = viewer_source("async function runSessionAction", "sessions.onclick =");
3174 let setup = r#"
3175const pendingActions = new Set();
3176const snapshot = { sessions: [] };
3177let sent = null;
3178function selectedWorkspaceId() { return "workspace-b"; }
3179function navigate() {}
3180function renderRoute() {}
3181async function refresh() {}
3182async function request(path, options) {
3183 sent = { path, body: JSON.parse(options.body) };
3184}
3185"#;
3186 let checks = r#"
3187const errorNode = { textContent: "" };
3188await runSessionAction(
3189 { action: "resume", id: "history-a", profile: "codex-1", target: "podman" },
3190 errorNode,
3191 { queue: "start" },
3192);
3193if (sent.path !== "/api/actions" || sent.body.workspace_id !== "workspace-b") {
3194 throw new Error(`resume did not carry its destination: ${JSON.stringify(sent)}`);
3195}
3196"#;
3197 run_viewer_script(
3198 "resume-workspace-destination",
3199 &format!("{setup}\n{source}\n{checks}"),
3200 );
3201 }
3202
3203 #[test]
3208 fn the_project_key_groups_without_naming_a_path() {
3209 let (config, mut state) = sample_config_state();
3210 let first = state.sessions["session-1"].clone();
3211 let mut second = first.clone();
3212 second.id = "session-2".into();
3213 state.sessions.insert(second.id.clone(), second);
3214 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
3215
3216 let keys = snapshot
3217 .sessions
3218 .iter()
3219 .map(|session| session.project_key.as_str())
3220 .collect::<std::collections::BTreeSet<_>>();
3221 assert_eq!(keys.len(), 1, "two sessions in one project did not group");
3222 let key = keys.into_iter().next().expect("one key");
3223 assert!(!key.is_empty(), "the project key is empty");
3224 assert!(
3225 !key.contains('/') && !key.contains("hel"),
3226 "the project key leaks its identity: {key}"
3227 );
3228 assert_eq!(
3229 snapshot.sessions[0].project_label, "hel",
3230 "the project label should be a name a person recognises"
3231 );
3232 }
3233
3234 #[test]
3237 fn lifecycle_categories_decide_what_the_dashboard_shows() {
3238 use ViewerLifecycleCategory::{Failed, Live, Starting, Stopped, Stopping};
3239
3240 for (state, expected, on_dashboard) in [
3241 (SessionState::Provisioning, Starting, true),
3242 (SessionState::Running, Live, true),
3243 (SessionState::Disconnected, Live, true),
3244 (SessionState::Checkpointing, Live, true),
3245 (SessionState::Closing, Stopping, true),
3246 (SessionState::Destroying, Stopping, true),
3247 (SessionState::Stopped, Stopped, false),
3248 (SessionState::Lost, Failed, false),
3249 (SessionState::Error, Failed, false),
3250 (SessionState::DestroyedWithDataLoss, Failed, false),
3251 ] {
3252 let category = ViewerLifecycleCategory::of(state);
3253 assert_eq!(category, expected, "{state:?}");
3254 assert_eq!(
3255 category.is_dashboard_visible(),
3256 on_dashboard,
3257 "{state:?} belongs on the dashboard? "
3258 );
3259 }
3260 }
3261
3262 #[test]
3266 fn compatible_resume_targets_are_the_complement_of_the_incompatible_ones() {
3267 let (config, state) = sample_config_state();
3268 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
3269 let session = &snapshot.sessions[0];
3270 let all = config.targets.keys().cloned().collect::<Vec<_>>();
3271
3272 for target in &all {
3273 assert_ne!(
3274 session.compatible_resume_targets.contains(target),
3275 session.incompatible_resume_targets.contains(target),
3276 "target {target} is in both lists or neither"
3277 );
3278 }
3279 assert_eq!(
3280 session.compatible_resume_targets.len() + session.incompatible_resume_targets.len(),
3281 all.len(),
3282 "the two lists do not cover every target"
3283 );
3284 }
3285
3286 #[tokio::test]
3290 async fn actions_are_refused_when_their_capability_is_false() {
3291 for (body, capability) in [
3292 (
3293 r#"{"action":"cancel-turn","session_id":"session-1"}"#,
3294 "cancel_turn",
3295 ),
3296 (
3297 r#"{"action":"set-plan-mode","session_id":"session-1","active":true}"#,
3298 "set_plan_mode",
3299 ),
3300 (
3301 r#"{"action":"set-config","session_id":"session-1","key":"model","value":"x"}"#,
3302 "set_config",
3303 ),
3304 ] {
3305 let (app, mut actions, _, _, _) = app();
3306 let response = post_action(app, cookie(), body.to_owned()).await;
3307 assert!(
3308 response.status().is_client_error(),
3309 "{capability} was accepted while false: {}",
3310 response.status()
3311 );
3312 assert!(
3313 actions.try_recv().is_err(),
3314 "{capability} reached the controller while false"
3315 );
3316 }
3317 }
3318
3319 #[tokio::test]
3322 async fn a_config_key_the_harness_never_advertised_is_refused() {
3323 let capable = |snapshot: &mut ViewerSnapshot| {
3324 snapshot.sessions[0].capabilities.set_config = true;
3325 snapshot.sessions[0].config_options = vec![ViewerConfigOption {
3326 key: "model".into(),
3327 label: "model".into(),
3328 current: None,
3329 choices: vec![ViewerConfigChoice {
3330 value: "sonnet".into(),
3331 name: "Sonnet".into(),
3332 description: None,
3333 }],
3334 }];
3335 };
3336
3337 for (body, why) in [
3338 (
3339 r#"{"action":"set-config","session_id":"session-1","key":"effort","value":"high"}"#,
3340 "an unadvertised key",
3341 ),
3342 (
3343 r#"{"action":"set-config","session_id":"session-1","key":"model","value":"gpt-9"}"#,
3344 "an unoffered value",
3345 ),
3346 ] {
3347 let (app, mut actions, _, _, _) = app_with_snapshot(capable);
3348 let response = post_action(app, cookie(), body.to_owned()).await;
3349 assert_eq!(
3350 response.status(),
3351 StatusCode::BAD_REQUEST,
3352 "{why} was accepted"
3353 );
3354 assert!(actions.try_recv().is_err(), "{why} reached the controller");
3355 }
3356
3357 let (app, mut actions, _, _, _) = app_with_snapshot(capable);
3359 let response = tokio::spawn(post_action(
3360 app,
3361 cookie(),
3362 r#"{"action":"set-config","session_id":"session-1","key":"model","value":"sonnet"}"#
3363 .to_owned(),
3364 ));
3365 let action = actions
3366 .recv()
3367 .await
3368 .expect("the action reached the controller");
3369 assert!(
3370 matches!(
3371 action.action,
3372 ControllerAction::SetConfig { ref key, ref value, .. }
3373 if key == "model" && value == "sonnet"
3374 ),
3375 "the advertised value was not forwarded unchanged"
3376 );
3377 action.reply.send(ActionOutcome::Accepted).unwrap();
3378 assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
3379 }
3380
3381 #[tokio::test]
3384 async fn a_dirty_acknowledgement_is_bounded_and_names_repositories() {
3385 let oversized = (0..40)
3386 .map(|index| format!(r#""repo-{index}""#))
3387 .collect::<Vec<_>>()
3388 .join(",");
3389 for (ack, why) in [
3390 (oversized.as_str(), "an unbounded acknowledgement"),
3391 (r#""""#, "an empty repository name"),
3392 ] {
3393 let (app, mut actions, _, _, _) = app();
3394 let body = format!(
3395 r#"{{"action":"new","workspace_id":"default","profile_id":"codex-1","bundle_id":"hel","target_id":"podman","dirty_ack":[{ack}]}}"#
3396 );
3397 let response = post_action(app, cookie(), body).await;
3398 assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{why}");
3399 assert!(actions.try_recv().is_err(), "{why} reached the controller");
3400 }
3401 }
3402
3403 #[tokio::test]
3406 async fn a_new_session_without_a_title_is_accepted() {
3407 let (app, mut actions, _, _, _) = app();
3408 let response = tokio::spawn(post_action(
3409 app,
3410 cookie(),
3411 r#"{"action":"new","workspace_id":"default","profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#
3412 .to_owned(),
3413 ));
3414 let action = actions
3417 .recv()
3418 .await
3419 .expect("the action reached the controller");
3420 assert!(
3421 matches!(
3422 action.action,
3423 ControllerAction::New { title: None, ref workspace_id, .. }
3424 if workspace_id == "default"
3425 ),
3426 "the workspace or the absent title did not survive the boundary"
3427 );
3428 action.reply.send(ActionOutcome::Accepted).unwrap();
3429 assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
3430 }
3431
3432 #[test]
3436 fn a_cookie_names_one_viewer_and_two_cookies_never_collide() {
3437 let key = b"01234567890123456789012345678901";
3438 let expiry = now_unix().saturating_add(3600);
3439 let first = signed_cookie_value(key, "viewer-a", expiry);
3440 let second = signed_cookie_value(key, "viewer-b", expiry);
3441 assert_ne!(
3442 first, second,
3443 "two viewers unlocking in the same second share a cookie"
3444 );
3445 assert_eq!(
3446 cookie_viewer(key, &first, now_unix()),
3447 Some(Some("viewer-a".to_owned()))
3448 );
3449 assert_eq!(
3450 cookie_viewer(key, &second, now_unix()),
3451 Some(Some("viewer-b".to_owned()))
3452 );
3453 }
3454
3455 #[test]
3459 fn a_legacy_cookie_still_authenticates_and_stores_nothing() {
3460 let key = b"01234567890123456789012345678901";
3461 let expiry = now_unix().saturating_add(3600);
3462 let legacy = legacy_signed_cookie_value(key, expiry);
3463 assert_eq!(cookie_viewer(key, &legacy, now_unix()), Some(None));
3464 assert!(session_cookie_valid(key, &legacy, now_unix()));
3465 assert!(
3466 !session_cookie_valid(key, &legacy, expiry),
3467 "an expired legacy cookie still authenticated"
3468 );
3469 }
3470
3471 #[test]
3473 fn a_tampered_cookie_is_refused() {
3474 let key = b"01234567890123456789012345678901";
3475 let expiry = now_unix().saturating_add(3600);
3476 let honest = signed_cookie_value(key, "viewer-a", expiry);
3477 let swapped = honest.replacen("viewer-a", "viewer-b", 1);
3478 assert_eq!(cookie_viewer(key, &swapped, now_unix()), None);
3479 assert_eq!(cookie_viewer(key, "nonsense", now_unix()), None);
3480 assert_eq!(cookie_viewer(key, &format!("{expiry}."), now_unix()), None);
3481 }
3482
3483 #[tokio::test]
3486 async fn an_oversized_draft_is_refused_with_a_stable_code() {
3487 let (app, _, _, _, mut stored) = app();
3488 let draft = "x".repeat(64 * 1024 + 1);
3489 let response = app
3490 .oneshot(
3491 Request::put("/api/sessions/session-1/draft")
3492 .header(COOKIE, cookie())
3493 .header(CONTENT_TYPE, "application/json")
3494 .body(Body::from(
3495 serde_json::json!({ "draft": draft }).to_string(),
3496 ))
3497 .unwrap(),
3498 )
3499 .await
3500 .unwrap();
3501 assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
3502 assert!(stored.try_recv().is_err(), "an oversized draft was stored");
3503 }
3504
3505 #[tokio::test]
3508 async fn a_legacy_viewer_reads_empty_state_and_cannot_store_a_draft() {
3509 let key = b"01234567890123456789012345678901";
3510 let legacy = format!(
3511 "{COOKIE_NAME}={}",
3512 legacy_signed_cookie_value(key, now_unix().saturating_add(3600))
3513 );
3514
3515 let (reader, _, _, _, mut stored) = app();
3516 let response = reader
3517 .oneshot(
3518 Request::get("/api/sessions/session-1/client-state")
3519 .header(COOKIE, legacy.clone())
3520 .body(Body::empty())
3521 .unwrap(),
3522 )
3523 .await
3524 .unwrap();
3525 assert_eq!(response.status(), StatusCode::OK);
3526 let body = response.into_body().collect().await.unwrap().to_bytes();
3527 let state: ViewerClientState = serde_json::from_slice(&body).unwrap();
3528 assert_eq!(state, ViewerClientState::default());
3529 assert!(
3530 stored.try_recv().is_err(),
3531 "a legacy viewer read stored state"
3532 );
3533
3534 let (writer, _, _, _, mut stored) = app();
3535 let response = writer
3536 .oneshot(
3537 Request::put("/api/sessions/session-1/draft")
3538 .header(COOKIE, legacy)
3539 .header(CONTENT_TYPE, "application/json")
3540 .body(Body::from(r#"{"draft":"text"}"#))
3541 .unwrap(),
3542 )
3543 .await
3544 .unwrap();
3545 assert_eq!(response.status(), StatusCode::CONFLICT);
3546 assert!(stored.try_recv().is_err(), "a legacy viewer stored a draft");
3547 }
3548
3549 #[tokio::test]
3551 async fn prompt_history_refuses_an_unknown_scope() {
3552 let (app, _, _, _, mut stored) = app();
3553 let response = app
3554 .oneshot(
3555 Request::get("/api/sessions/session-1/history?q=ship&scope=everything")
3556 .header(COOKIE, cookie())
3557 .body(Body::empty())
3558 .unwrap(),
3559 )
3560 .await
3561 .unwrap();
3562 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
3563 assert!(
3564 stored.try_recv().is_err(),
3565 "the search reached the controller"
3566 );
3567 }
3568
3569 #[tokio::test]
3573 async fn a_preflight_validates_before_it_reaches_the_controller() {
3574 for (body, why) in [
3575 (
3576 r#"{"profile_id":"nope","bundle_id":"hel","target_id":"podman"}"#,
3577 "an unknown profile",
3578 ),
3579 (
3580 r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw"}"#,
3581 "a bare target with no directory",
3582 ),
3583 ] {
3584 let (app, _, _, mut preflights, _) = app();
3585 let response = app
3586 .oneshot(
3587 Request::post("/api/preflight/new")
3588 .header(COOKIE, cookie())
3589 .header(CONTENT_TYPE, "application/json")
3590 .body(Body::from(body))
3591 .unwrap(),
3592 )
3593 .await
3594 .unwrap();
3595 assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{why}");
3596 assert!(
3597 preflights.try_recv().is_err(),
3598 "{why} reached the controller"
3599 );
3600 }
3601 }
3602
3603 #[tokio::test]
3606 async fn a_bare_preflight_answers_without_the_controller() {
3607 let (app, _, _, mut preflights, _) = app();
3608 let response = app
3609 .oneshot(
3610 Request::post("/api/preflight/new")
3611 .header(COOKIE, cookie())
3612 .header(CONTENT_TYPE, "application/json")
3613 .body(Body::from(
3614 r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw","project_directory":"/work/project"}"#,
3615 ))
3616 .unwrap(),
3617 )
3618 .await
3619 .unwrap();
3620 assert_eq!(response.status(), StatusCode::OK);
3621 assert!(preflights.try_recv().is_err(), "the controller was asked");
3622 let body = response.into_body().collect().await.unwrap().to_bytes();
3623 let answer: PreflightNew = serde_json::from_slice(&body).unwrap();
3624 assert!(answer.dirty_repositories.is_empty());
3625 }
3626
3627 #[tokio::test]
3630 async fn a_bundle_preflight_reports_the_repositories_by_leaf_name() {
3631 let (app, _, _, mut preflights, _) = app();
3632 let response = tokio::spawn(
3633 app.oneshot(
3634 Request::post("/api/preflight/new")
3635 .header(COOKIE, cookie())
3636 .header(CONTENT_TYPE, "application/json")
3637 .body(Body::from(
3638 r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#,
3639 ))
3640 .unwrap(),
3641 ),
3642 );
3643 let request = preflights.recv().await.expect("the controller was asked");
3644 assert_eq!(request.bundle_id, "hel");
3645 request
3646 .reply
3647 .send(Ok(PreflightNew {
3648 dirty_repositories: vec!["hel".into()],
3649 }))
3650 .unwrap();
3651 let response = response.await.unwrap().unwrap();
3652 assert_eq!(response.status(), StatusCode::OK);
3653 let body = response.into_body().collect().await.unwrap().to_bytes();
3654 let answer: PreflightNew = serde_json::from_slice(&body).unwrap();
3655 assert_eq!(answer.dirty_repositories, vec!["hel".to_owned()]);
3656 assert!(
3657 !String::from_utf8_lossy(&body).contains('/'),
3658 "the preflight published a path: {}",
3659 String::from_utf8_lossy(&body)
3660 );
3661 }
3662
3663 #[test]
3668 fn the_markdown_renderer_builds_structure_and_refuses_injection() {
3669 run_web_check(
3670 "markdown",
3671 r#"import { installDocument, elements, only, check, checkEqual } from './test-dom.js';
3672installDocument();
3673const { renderMarkdown, renderDiffSummary, safeHref } = await import('./markdown.js');
3674
3675const render = source => {
3676 const host = document.createElement('section');
3677 host.append(renderMarkdown(source));
3678 return host;
3679};
3680
3681// Headings
3682checkEqual(only(render('# Title'), 'h1').textContent, 'Title', 'h1');
3683checkEqual(only(render('### Deep'), 'h3').textContent, 'Deep', 'h3');
3684
3685// Nested lists
3686const nested = render('- one\n - inner\n- two');
3687check(elements(nested, 'ul').length === 2, 'nested list produced ' + elements(nested, 'ul').length + ' lists');
3688check(elements(elements(nested, 'ul')[0], 'li').length >= 2, 'outer list lost items');
3689
3690// Ordered lists
3691checkEqual(elements(render('1. a\n2. b'), 'ol').length, 1, 'ordered list');
3692
3693// Fenced code stays unparsed
3694const fenced = render('```rust\nlet x = *y*;\n```');
3695checkEqual(only(fenced, 'code').textContent, 'let x = *y*;', 'fenced code');
3696check(elements(fenced, 'span').some(s => s.className === 'tok-kw'), 'fenced rust untinted');
3697checkEqual(elements(fenced, 'em').length, 0, 'fence emphasised its contents');
3698checkEqual(only(fenced, 'pre').dataset.lang, 'rust', 'fence language');
3699
3700// Inline code beats emphasis
3701checkEqual(only(render('`*not em*`'), 'code').textContent, '*not em*', 'inline code');
3702checkEqual(elements(render('`*not em*`'), 'em').length, 0, 'inline code emphasised');
3703
3704// Emphasis
3705checkEqual(only(render('**bold**'), 'strong').textContent, 'bold', 'strong');
3706checkEqual(only(render('*it*'), 'em').textContent, 'it', 'em');
3707checkEqual(only(render('~~gone~~'), 'del').textContent, 'gone', 'del');
3708
3709// Tables
3710const table = render('| a | b |\n| --- | ---: |\n| 1 | 2 |');
3711checkEqual(elements(table, 'table').length, 1, 'table');
3712checkEqual(elements(table, 'th').length, 2, 'table header cells');
3713checkEqual(elements(table, 'td').length, 2, 'table body cells');
3714checkEqual(elements(table, 'th')[1].className, 'align-right', 'table alignment class');
3715checkEqual(only(table, 'div').className, 'scroll-x', 'table scroll wrapper');
3716
3717// Blockquote and rule
3718checkEqual(elements(render('> quoted'), 'blockquote').length, 1, 'blockquote');
3719checkEqual(elements(render('---'), 'hr').length, 1, 'rule');
3720
3721// XSS: markup is text, never elements
3722const injected = render('<img src=x onerror=alert(1)>');
3723checkEqual(elements(injected, 'img').length, 0, 'raw HTML became an element');
3724check(injected.textContent.includes('<img src=x onerror=alert(1)>'), 'raw HTML lost its text');
3725
3726// XSS: refused link schemes
3727for (const target of ['javascript:alert(1)', 'JaVaScRiPt:alert(1)', 'java\tscript:alert(1)', 'data:text/html,<script>', 'vbscript:x']) {
3728 const out = render(`[click](${target})`);
3729 checkEqual(elements(out, 'a').length, 0, `link scheme ${JSON.stringify(target)} was allowed`);
3730 check(out.textContent.includes('click'), `link scheme ${JSON.stringify(target)} lost its label`);
3731}
3732
3733// Accepted schemes keep their href and carry safe rel/target
3734for (const target of ['https://example.com', 'http://example.com/a', 'mailto:someone@example.com']) {
3735 const anchor = only(render(`[click](${target})`), 'a');
3736 checkEqual(anchor.getAttribute('href'), target, 'href');
3737 checkEqual(anchor.getAttribute('rel'), 'noreferrer noopener', 'rel');
3738 checkEqual(anchor.getAttribute('target'), '_blank', 'target');
3739}
3740
3741// safeHref directly
3742checkEqual(safeHref('javascript:alert(1)'), null, 'safeHref allowed javascript:');
3743checkEqual(safeHref(' https://x.test '), 'https://x.test', 'safeHref cleaned value');
3744
3745// Inline markup inside a link label
3746checkEqual(only(render('[**bold link**](https://x.test)'), 'strong').textContent, 'bold link', 'link label markup');
3747
3748// An unclosed delimiter is literal, not markup
3749checkEqual(render('a * b').textContent, 'a * b', 'unclosed emphasis');
3750checkEqual(elements(render('a * b'), 'em').length, 0, 'unclosed emphasis made an element');
3751
3752// Diff summaries: the real format from format_diffstat, two spaces and U+2212
3753const diff = renderDiffSummary(['src/main.rs +12 ā3', 'unparseable line']);
3754const items = elements(diff, 'li');
3755checkEqual(items.length, 2, 'diffstat rows');
3756checkEqual(elements(items[0], 'span')[0].textContent, 'src/main.rs', 'diffstat path');
3757checkEqual(elements(items[0], 'span')[1].textContent, '+12', 'diffstat additions');
3758checkEqual(elements(items[0], 'span')[2].textContent, 'ā3', 'diffstat deletions');
3759checkEqual(elements(items[1], 'span').length, 1, 'unparseable diffstat produced counts');
3760checkEqual(elements(items[1], 'span')[0].textContent, 'unparseable line', 'unparseable diffstat lost its text');
3761
3762console.log('all markdown checks passed');
3763"#,
3764 );
3765 }
3766
3767 #[test]
3772 fn tool_output_is_tinted_folded_and_never_read_as_markdown() {
3773 run_web_check(
3774 "tool-output",
3775 r#"import { installDocument, elements, only, check, checkEqual, openFold } from './test-dom.js';
3776installDocument();
3777const { renderToolOutput, codeBlock, detectLang, appendCommandTokens, isPathLike } = await import(
3778 './tool-output.js'
3779);
3780
3781const classes = root => elements(root, 'span').map(s => s.className);
3782
3783// A shell command is told apart into program, subcommand, flag and path.
3784const line = document.createElement('pre');
3785appendCommandTokens(line, 'cargo test --workspace src/lib.rs');
3786const seen = classes(line);
3787check(seen.includes('cmd-program'), 'no program: ' + seen);
3788check(seen.includes('cmd-subcommand'), 'no subcommand: ' + seen);
3789check(seen.includes('cmd-flag'), 'no flag: ' + seen);
3790check(seen.includes('cmd-path'), 'no path: ' + seen);
3791checkEqual(line.textContent, 'cargo test --workspace src/lib.rs', 'command text changed');
3792
3793// An operator starts the program count again, so both programs are found.
3794const piped = document.createElement('pre');
3795appendCommandTokens(piped, 'git status && cargo build');
3796checkEqual(classes(piped).filter(c => c === 'cmd-program').length, 2, 'pipeline reset');
3797
3798// Prose with a slash is not a path; a real path is.
3799check(!isPathLike('and/or'), '"and/or" read as a path');
3800check(isPathLike('src/lib/thing.rs'), 'a real path did not');
3801check(isPathLike('./x'), 'a relative path did not');
3802check(isPathLike('Cargo.toml'), 'a file with an extension did not');
3803
3804// JSON is pretty-printed and tinted, keys apart from values.
3805const json = renderToolOutput('{"name":"hel","count":3,"ok":true}');
3806const jsonClasses = classes(json);
3807check(jsonClasses.includes('tok-key'), 'no JSON key: ' + jsonClasses);
3808check(jsonClasses.includes('tok-str'), 'no JSON string: ' + jsonClasses);
3809check(jsonClasses.includes('tok-num'), 'no JSON number: ' + jsonClasses);
3810check(jsonClasses.includes('tok-kw'), 'no JSON keyword: ' + jsonClasses);
3811check(json.textContent.includes('"name"'), 'JSON lost its content');
3812
3813// Rust is tinted; an unknown language is not.
3814const rust = codeBlock('pub fn main() {\n let x = 1;\n}', 'rust');
3815check(classes(rust).includes('tok-kw'), 'rust keywords untinted');
3816checkEqual(only(rust, 'pre').dataset.lang, 'rust', 'rust data-lang');
3817const plain = codeBlock('nothing in particular here', 'brainfuck');
3818checkEqual(classes(plain).length, 0, 'unknown language was tinted');
3819
3820// Sniffing is conservative: a log stays plain, real code does not.
3821checkEqual(detectLang('12:03 INFO started\n12:04 INFO done\n12:05 INFO stopped'), '', 'a log was sniffed');
3822checkEqual(
3823 detectLang('fn a() {}\nfn b() {}\nlet mut x = 1;\nuse std::fmt;\nimpl Foo {}\nlet y = x.unwrap();'),
3824 'rust',
3825 'rust was not sniffed',
3826);
3827checkEqual(detectLang('--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+new'), 'diff', 'diff was not sniffed');
3828
3829// A long dump is one closed fold that has built nothing yet.
3830const long = Array.from({ length: 400 }, (_, i) => `line ${i}`).join('\n');
3831const folded = renderToolOutput(long);
3832checkEqual(folded.nodeName, 'DETAILS', 'a 400-line dump was not folded');
3833checkEqual(elements(folded, 'pre').length, 0, 'a closed fold built its content anyway');
3834check(only(folded, 'summary').textContent.includes('400 lines'), 'fold summary: ' + only(folded, 'summary').textContent);
3835openFold(folded);
3836checkEqual(elements(folded, 'pre').length, 1, 'an opened fold built nothing');
3837check(elements(folded, 'pre')[0].textContent.includes('line 399'), 'the fold lost its content');
3838
3839// Opening twice builds once.
3840openFold(folded);
3841checkEqual(elements(folded, 'pre').length, 1, 'reopening rebuilt the content');
3842
3843// A short dump is not folded.
3844checkEqual(renderToolOutput('one\ntwo').nodeName, 'PRE', 'a short dump was folded');
3845
3846// Tool output is never parsed as Markdown, so an underscore is an underscore.
3847const literal = renderToolOutput('a _b_ c <img src=x>');
3848checkEqual(elements(literal, 'em').length, 0, 'tool output was emphasised');
3849checkEqual(elements(literal, 'img').length, 0, 'tool output produced an element');
3850check(literal.textContent.includes('<img src=x>'), 'tool output lost its text');
3851
3852console.log('all tool-output checks passed');
3853"#,
3854 );
3855 }
3856
3857 #[test]
3864 fn no_web_module_builds_markup_from_a_string() {
3865 const SINKS: [&str; 5] = [
3866 "innerHTML",
3867 "outerHTML",
3868 "insertAdjacentHTML",
3869 "document.write",
3870 "new Function",
3871 ];
3872 const ALLOWED: [(&str, &str); 0] = [];
3875 for (name, source) in [
3876 ("viewer.js", VIEWER_JS),
3877 ("markdown.js", MARKDOWN_JS),
3878 ("tool-output.js", TOOL_OUTPUT_JS),
3879 ] {
3880 for (number, line) in source.lines().enumerate() {
3881 let trimmed = line.trim();
3882 if trimmed.starts_with("//") || trimmed.starts_with("///") {
3883 continue;
3884 }
3885 for sink in SINKS {
3886 if !trimmed.contains(sink) {
3887 continue;
3888 }
3889 assert!(
3890 ALLOWED
3891 .iter()
3892 .any(|(file, allowed)| *file == name && trimmed == *allowed),
3893 "{name}:{} builds markup from a string: {trimmed}",
3894 number + 1
3895 );
3896 }
3897 }
3898 }
3899 }
3900
3901 #[test]
3905 fn embedded_viewer_keeps_elicitation_answers_across_snapshot_polls() {
3906 let source = viewer_source(
3907 "const elicitationCards = new Map()",
3908 "async function submitElicitation",
3909 );
3910 let dom = r#"
3911let replaceCalls = 0;
3912class Option {
3913 constructor(label, value) {
3914 this.label = label;
3915 this.value = value;
3916 this.selected = false;
3917 }
3918}
3919function makeEl(tag) {
3920 return {
3921 tagName: tag.toUpperCase(),
3922 children: [],
3923 options: [],
3924 selectedOptions: [],
3925 className: "",
3926 textContent: "",
3927 disabled: false,
3928 required: false,
3929 value: "",
3930 appendChild(child) {
3931 this.children.push(child);
3932 if (this.tagName === "SELECT") this.options.push(child);
3933 return child;
3934 },
3935 append(...kids) {
3936 this.children.push(...kids);
3937 },
3938 replaceChildren(...kids) {
3939 replaceCalls += 1;
3940 this.children = kids;
3941 },
3942 addEventListener() {},
3943 setCustomValidity() {},
3944 reportValidity() {
3945 return true;
3946 },
3947 };
3948}
3949const created = [];
3950const document = {
3951 createElement(tag) {
3952 const el = makeEl(tag);
3953 created.push(el);
3954 return el;
3955 },
3956};
3957const elicitations = makeEl("div");
3958async function submitElicitation() {}
3959"#;
3960 let checks = r#"
3961const request = {
3962 id: "elicitation-1",
3963 message: "Which CI architecture?",
3964 title: "CI",
3965 fields: [
3966 {
3967 id: "question_0",
3968 title: "CI architecture",
3969 required: false,
3970 kind: "single_select",
3971 options: [{ value: "reusable", title: "Reusable" }, { value: "matrix", title: "Matrix" }],
3972 },
3973 { id: "question_0_custom", title: "Other", required: false, kind: "text" },
3974 ],
3975};
3976const session = { id: "session-1", pending_elicitations: [request] };
3977renderElicitations(session);
3978const card = elicitations.children[0];
3979const select = created.find((el) => el.tagName === "SELECT");
3980const text = created.find((el) => el.tagName === "INPUT");
3981select.value = "reusable";
3982text.value = "keep me";
3983const attachments = replaceCalls;
3984renderElicitations(session);
3985if (elicitations.children[0] !== card) {
3986 throw new Error("a snapshot rebuilt the pending card");
3987}
3988if (select.value !== "reusable" || text.value !== "keep me") {
3989 throw new Error("a snapshot wiped the half-filled answer");
3990}
3991if (replaceCalls !== attachments) {
3992 throw new Error("a snapshot re-attached an unchanged card and dropped focus");
3993}
3994sentElicitations.add(elicitationKey("session-1", request.id));
3995renderElicitations(session);
3996if (elicitations.children[0] !== card) {
3997 throw new Error("a sent answer rebuilt the card");
3998}
3999if (!select.disabled || !text.disabled) {
4000 throw new Error("a sent answer left the controls live");
4001}
4002if (select.value !== "reusable") {
4003 throw new Error("a sent answer wiped the reply");
4004}
4005renderElicitations({ id: "session-1", pending_elicitations: [] });
4006if (elicitations.children.length !== 0 || elicitationCards.size !== 0) {
4007 throw new Error("an answered request stayed rendered");
4008}
4009if (sentElicitations.size !== 0) {
4010 throw new Error("a resolved request kept its sent marker");
4011}
4012"#;
4013 run_viewer_script(
4014 "elicitation-rendering",
4015 &format!("{dom}\n{source}\n{checks}"),
4016 );
4017 }
4018
4019 fn sample_image(pixels: usize) -> ViewerPromptImage {
4020 ViewerPromptImage {
4021 data_base64: base64::engine::general_purpose::STANDARD.encode(vec![7_u8; pixels]),
4022 mime_type: "image/png".into(),
4023 width: 32,
4024 height: 24,
4025 }
4026 }
4027
4028 fn image_capable(snapshot: &mut ViewerSnapshot) {
4029 snapshot.sessions[0].prompt_images_supported = true;
4030 }
4031
4032 async fn post_action(app: Router, cookie: String, body: String) -> Response<Body> {
4033 app.oneshot(
4034 Request::post("/api/actions")
4035 .header(COOKIE, cookie)
4036 .header(CONTENT_TYPE, "application/json")
4037 .body(Body::from(body))
4038 .unwrap(),
4039 )
4040 .await
4041 .unwrap()
4042 }
4043
4044 #[tokio::test]
4045 async fn image_prompt_reaches_the_controller_with_its_images() {
4046 let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
4047 let cookie = login_cookie(&app).await;
4048 let image = sample_image(8);
4049 let body = serde_json::to_string(&ControllerAction::Prompt {
4050 session_id: "session-1".into(),
4051 text: String::new(),
4052 images: vec![image.clone(), image.clone()],
4053 })
4054 .unwrap();
4055 let response = tokio::spawn(post_action(app, cookie, body));
4056 let action = actions.recv().await.unwrap();
4057 assert_eq!(
4058 action.action,
4059 ControllerAction::Prompt {
4060 session_id: "session-1".into(),
4061 text: String::new(),
4062 images: vec![image.clone(), image],
4063 }
4064 );
4065 action.reply.send(ActionOutcome::Accepted).unwrap();
4066 assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
4067 }
4068
4069 #[tokio::test]
4073 async fn multi_image_prompts_are_accepted_over_the_general_body_limit() {
4074 let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
4075 let cookie = login_cookie(&app).await;
4076 let image = sample_image(MAX_BODY_BYTES / 2);
4077 let body = serde_json::to_string(&ControllerAction::Prompt {
4078 session_id: "session-1".into(),
4079 text: "look at these".into(),
4080 images: vec![image.clone(), image],
4081 })
4082 .unwrap();
4083 assert!(body.len() > MAX_BODY_BYTES);
4084 assert!(body.len() < MAX_PROMPT_BODY_BYTES);
4085 let response = tokio::spawn(post_action(app, cookie, body));
4086 let action = actions.recv().await.unwrap();
4087 action.reply.send(ActionOutcome::Accepted).unwrap();
4088 assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
4089 }
4090
4091 #[tokio::test]
4092 async fn a_body_over_the_prompt_limit_is_still_refused() {
4093 let (app, _actions, _, _, _) = app_with_snapshot(image_capable);
4094 let cookie = login_cookie(&app).await;
4095 let image = sample_image(MAX_PROMPT_BODY_BYTES);
4096 let body = serde_json::to_string(&ControllerAction::Prompt {
4097 session_id: "session-1".into(),
4098 text: String::new(),
4099 images: vec![image],
4100 })
4101 .unwrap();
4102 assert!(body.len() > MAX_PROMPT_BODY_BYTES);
4103 let response = post_action(app, cookie, body).await;
4104 assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
4105 }
4106
4107 #[tokio::test]
4108 async fn malformed_image_payloads_never_reach_the_controller() {
4109 let cases = [
4110 ("aW1hZ2U=", "text/plain", 32, 24),
4111 ("aW1hZ2U=", "image/png", 0, 24),
4112 ("not base64!", "image/png", 32, 24),
4113 ("", "image/png", 32, 24),
4114 ];
4115 for (data, mime, width, height) in cases {
4116 let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
4117 let cookie = login_cookie(&app).await;
4118 let body = serde_json::to_string(&ControllerAction::Prompt {
4119 session_id: "session-1".into(),
4120 text: String::new(),
4121 images: vec![ViewerPromptImage {
4122 data_base64: data.into(),
4123 mime_type: mime.into(),
4124 width,
4125 height,
4126 }],
4127 })
4128 .unwrap();
4129 let response = post_action(app, cookie, body).await;
4130 assert_eq!(
4131 response.status(),
4132 StatusCode::BAD_REQUEST,
4133 "expected {data:?}/{mime} {width}x{height} to be refused"
4134 );
4135 assert!(actions.try_recv().is_err());
4136 }
4137 }
4138
4139 #[test]
4140 fn image_prompts_need_text_or_an_image_and_an_agent_that_takes_them() {
4141 let (config, state) = sample_config_state();
4142 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4143 let prompt = |text: &str, images: Vec<ViewerPromptImage>| ControllerAction::Prompt {
4144 session_id: "session-1".into(),
4145 text: text.into(),
4146 images,
4147 };
4148
4149 assert!(validate_action(&prompt("ship it", Vec::new()), &snapshot).is_ok());
4151 assert!(validate_action(&prompt("", vec![sample_image(8)]), &snapshot).is_err());
4152
4153 image_capable(&mut snapshot);
4154 assert!(validate_action(&prompt("", vec![sample_image(8)]), &snapshot).is_ok());
4156 assert!(validate_action(&prompt(" ", Vec::new()), &snapshot).is_err());
4157 assert!(validate_action(&prompt("", Vec::new()), &snapshot).is_err());
4158 assert!(validate_action(&prompt("!ls", vec![sample_image(8)]), &snapshot).is_err());
4160 }
4161
4162 #[test]
4165 fn embedded_viewer_reads_multiline_composer_text_out_of_its_dom() {
4166 let source = viewer_source("function composerText()", "function setComposerText(");
4167 let harness = r##"
4168const Node = { TEXT_NODE: 3 };
4169function textNode(value) {
4170 return { nodeType: 3, nodeValue: value, nodeName: "#text", childNodes: [], dataset: {} };
4171}
4172function element(name, children = [], dataset = {}) {
4173 const node = { nodeType: 1, nodeName: name, dataset, childNodes: children };
4174 children.forEach((child, index) => {
4175 child.nextSibling = children[index + 1] || null;
4176 });
4177 return node;
4178}
4179let promptText = null;
4180function read(children) {
4181 promptText = element("DIV", children);
4182 return composerText();
4183}
4184"##;
4185 let checks = r#"
4186const plain = read([textNode("ship it")]);
4187if (plain !== "ship it") throw new Error(`plain text became ${JSON.stringify(plain)}`);
4188
4189const broken = read([textNode("first"), element("BR"), textNode("second")]);
4190if (broken !== "first\nsecond") throw new Error(`line break became ${JSON.stringify(broken)}`);
4191
4192// The trailing break a browser leaves behind to keep the caret on a new line
4193// is scaffolding, not a line the user typed.
4194const filler = read([
4195 textNode("first"),
4196 element("BR"),
4197 element("BR", [], { composerFiller: "true" }),
4198]);
4199if (filler !== "first\n") throw new Error(`filler break became ${JSON.stringify(filler)}`);
4200
4201const blocks = read([
4202 textNode("first"),
4203 element("DIV", [textNode("second")]),
4204 element("DIV", [textNode("third")]),
4205]);
4206if (blocks !== "first\nsecond\nthird") throw new Error(`blocks became ${JSON.stringify(blocks)}`);
4207
4208const carriage = read([textNode("first\r\nsecond")]);
4209if (carriage !== "first\nsecond") throw new Error(`CRLF became ${JSON.stringify(carriage)}`);
4210"#;
4211 run_viewer_script("composer-reader", &format!("{harness}\n{source}\n{checks}"));
4212 }
4213
4214 #[tokio::test]
4218 async fn viewer_declares_the_icon_route_instead_of_requesting_a_missing_favicon() {
4219 let (app, _, _, _, _) = app();
4220 let page = fetch_text(app.clone(), "/").await;
4221 assert!(page.contains(r#"rel="icon""#), "the page declares no icon");
4222 assert!(page.contains("/icon.svg"), "the page names no icon route");
4223 let icon = app
4224 .oneshot(Request::get("/icon.svg").body(Body::empty()).unwrap())
4225 .await
4226 .unwrap();
4227 assert_eq!(icon.status(), StatusCode::OK);
4228 assert_eq!(
4229 icon.headers().get(CONTENT_TYPE).unwrap(),
4230 "image/svg+xml",
4231 "the icon route does not serve an SVG"
4232 );
4233 }
4234
4235 #[tokio::test]
4236 async fn valid_action_is_typed_and_forwarded() {
4237 let (app, mut actions, _, _, _) = app();
4238 let cookie = login_cookie(&app).await;
4239 let response = tokio::spawn(
4240 app.oneshot(
4241 Request::post("/api/actions")
4242 .header(COOKIE, cookie)
4243 .header(CONTENT_TYPE, "application/json")
4244 .body(Body::from(
4245 r#"{"action":"prompt","session_id":"session-1","text":"ship it"}"#,
4246 ))
4247 .unwrap(),
4248 ),
4249 );
4250 let action = actions.recv().await.unwrap();
4251 assert_eq!(
4252 action.action,
4253 ControllerAction::Prompt {
4254 session_id: "session-1".into(),
4255 text: "ship it".into(),
4256 images: Vec::new(),
4257 }
4258 );
4259 action.reply.send(ActionOutcome::Accepted).unwrap();
4260 let response = response.await.unwrap().unwrap();
4261 assert_eq!(response.status(), StatusCode::ACCEPTED);
4262 }
4263
4264 #[tokio::test]
4265 async fn shell_action_is_typed_and_forwarded() {
4266 let (app, mut actions, _, _, _) = app();
4267 let cookie = login_cookie(&app).await;
4268 let response = tokio::spawn(
4269 app.oneshot(
4270 Request::post("/api/actions")
4271 .header(COOKIE, cookie)
4272 .header(CONTENT_TYPE, "application/json")
4273 .body(Body::from(
4274 r#"{"action":"run-shell","session_id":"session-1","command":"cargo test"}"#,
4275 ))
4276 .unwrap(),
4277 ),
4278 );
4279 let action = actions.recv().await.unwrap();
4280 assert_eq!(
4281 action.action,
4282 ControllerAction::RunShell {
4283 session_id: "session-1".into(),
4284 command: "cargo test".into(),
4285 }
4286 );
4287 action.reply.send(ActionOutcome::Accepted).unwrap();
4288 assert_eq!(
4289 response.await.unwrap().unwrap().status(),
4290 StatusCode::ACCEPTED
4291 );
4292 }
4293
4294 #[test]
4295 fn shell_action_validation_reserves_bang_prompts_and_checks_cancellation_ids() {
4296 let (config, state) = sample_config_state();
4297 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4298 assert!(
4299 validate_action(
4300 &ControllerAction::Prompt {
4301 session_id: "session-1".into(),
4302 text: "!cargo test".into(),
4303 images: Vec::new(),
4304 },
4305 &snapshot,
4306 )
4307 .is_err()
4308 );
4309 assert!(
4310 validate_action(
4311 &ControllerAction::RunShell {
4312 session_id: "session-1".into(),
4313 command: "cargo test".into(),
4314 },
4315 &snapshot,
4316 )
4317 .is_ok()
4318 );
4319 assert!(
4320 validate_action(
4321 &ControllerAction::CancelShell {
4322 session_id: "session-1".into(),
4323 shell_command_id: "shell-1".into(),
4324 },
4325 &snapshot,
4326 )
4327 .is_err()
4328 );
4329
4330 snapshot.sessions[0]
4331 .active_user_shells
4332 .push(ViewerUserShell {
4333 id: "shell-1".into(),
4334 command: "cargo test".into(),
4335 started_at_ms: Some(10),
4336 });
4337 assert!(
4338 validate_action(
4339 &ControllerAction::CancelShell {
4340 session_id: "session-1".into(),
4341 shell_command_id: "shell-1".into(),
4342 },
4343 &snapshot,
4344 )
4345 .is_ok()
4346 );
4347 }
4348
4349 #[tokio::test]
4350 async fn bare_new_action_forwards_an_explicit_safe_project_directory() {
4351 let (app, mut actions, _, _, _) = app();
4352 let cookie = login_cookie(&app).await;
4353 let response = tokio::spawn(
4354 app.oneshot(
4355 Request::post("/api/actions")
4356 .header(COOKIE, cookie)
4357 .header(CONTENT_TYPE, "application/json")
4358 .body(Body::from(
4359 r#"{"action":"new","profile_id":"codex-1","bundle_id":"hel","target_id":"raw","title":"Raw work","project_directory":"/work/project"}"#,
4360 ))
4361 .unwrap(),
4362 ),
4363 );
4364 let action = actions.recv().await.unwrap();
4365 assert_eq!(
4366 action.action,
4367 ControllerAction::New {
4368 workspace_id: String::new(),
4369 profile_id: "codex-1".into(),
4370 bundle_id: "hel".into(),
4371 target_id: "raw".into(),
4372 title: Some("Raw work".into()),
4373 project_directory: Some(PathBuf::from("/work/project")),
4374 dirty_ack: Vec::new(),
4375 }
4376 );
4377 action.reply.send(ActionOutcome::Accepted).unwrap();
4378 assert_eq!(
4379 response.await.unwrap().unwrap().status(),
4380 StatusCode::ACCEPTED
4381 );
4382 }
4383
4384 #[test]
4385 fn new_action_requires_project_directory_exactly_for_bare_targets() {
4386 let (config, state) = sample_config_state();
4387 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4388 let action = |target_id: &str, project_directory: Option<PathBuf>| ControllerAction::New {
4389 workspace_id: String::new(),
4390 profile_id: "codex-1".into(),
4391 bundle_id: "hel".into(),
4392 target_id: target_id.into(),
4393 title: Some("New work".into()),
4394 project_directory,
4395 dirty_ack: Vec::new(),
4396 };
4397
4398 assert!(validate_action(&action("podman", None), &snapshot).is_ok());
4399 assert_eq!(
4400 validate_action(&action("podman", Some("/work".into())), &snapshot)
4401 .unwrap_err()
4402 .status,
4403 StatusCode::BAD_REQUEST
4404 );
4405 assert_eq!(
4406 validate_action(&action("raw", None), &snapshot)
4407 .unwrap_err()
4408 .status,
4409 StatusCode::BAD_REQUEST
4410 );
4411 assert_eq!(
4412 validate_action(&action("raw", Some("relative".into())), &snapshot)
4413 .unwrap_err()
4414 .status,
4415 StatusCode::BAD_REQUEST
4416 );
4417 assert_eq!(
4418 validate_action(&action("raw", Some("/work/../secret".into())), &snapshot)
4419 .unwrap_err()
4420 .status,
4421 StatusCode::BAD_REQUEST
4422 );
4423 assert!(validate_action(&action("raw", Some("/work/project".into())), &snapshot).is_ok());
4424 }
4425
4426 #[tokio::test]
4427 async fn cancel_action_is_typed_and_forwarded() {
4428 let (app, mut actions, _, _, _) = app();
4429 let cookie = login_cookie(&app).await;
4430 let response = tokio::spawn(
4431 app.oneshot(
4432 Request::post("/api/actions")
4433 .header(COOKIE, cookie)
4434 .header(CONTENT_TYPE, "application/json")
4435 .body(Body::from(
4436 r#"{"action":"cancel","session_id":"session-1"}"#,
4437 ))
4438 .unwrap(),
4439 ),
4440 );
4441 let action = actions.recv().await.unwrap();
4442 assert_eq!(
4443 action.action,
4444 ControllerAction::Cancel {
4445 session_id: "session-1".into(),
4446 }
4447 );
4448 action.reply.send(ActionOutcome::Accepted).unwrap();
4449 assert_eq!(
4450 response.await.unwrap().unwrap().status(),
4451 StatusCode::ACCEPTED
4452 );
4453 }
4454
4455 #[tokio::test]
4456 async fn action_validation_accepts_cross_harness_resume_and_rejects_unknown() {
4457 let (mut config, state) = sample_config_state();
4458 config.profiles.insert(
4459 "claude-1".into(),
4460 HarnessProfile {
4461 context_window_bytes: None,
4462 kind: HarnessKind::Claude,
4463 home: "/secret/claude".into(),
4464 executable: None,
4465 environment: BTreeMap::new(),
4466 },
4467 );
4468 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4469 snapshot.workspaces.push(ViewerWorkspace {
4470 id: "workspace-1".into(),
4471 name: "One".into(),
4472 });
4473 validate_action(
4474 &ControllerAction::Resume {
4475 session_id: "session-1".into(),
4476 workspace_id: "workspace-1".into(),
4477 profile_id: "claude-1".into(),
4478 target_id: "podman".into(),
4479 queue: ResumeQueueDisposition::Start,
4480 },
4481 &snapshot,
4482 )
4483 .unwrap();
4484
4485 let error = validate_action(
4486 &ControllerAction::Resume {
4487 session_id: "session-1".into(),
4488 workspace_id: "missing".into(),
4489 profile_id: "claude-1".into(),
4490 target_id: "podman".into(),
4491 queue: ResumeQueueDisposition::Start,
4492 },
4493 &snapshot,
4494 )
4495 .unwrap_err();
4496 assert_eq!(error.status, StatusCode::BAD_REQUEST);
4497
4498 let error = validate_action(
4499 &ControllerAction::Close {
4500 session_id: "not-managed".into(),
4501 },
4502 &snapshot,
4503 )
4504 .unwrap_err();
4505 assert_eq!(error.status, StatusCode::NOT_FOUND);
4506 }
4507
4508 #[test]
4511 fn a_running_review_projects_to_the_phone() {
4512 use crate::hel_review_host::{RuntimeReviewView, VerdictKind, VerdictView};
4513 use hel::hel_review::driver::{Resolution, RoleState, RoleStatus, TurnReviewPhase};
4514
4515 let review = RuntimeReviewView {
4516 session_id: "session-1".into(),
4517 tier: hel::hel_review::lanes::ReviewTier::Extended,
4518 phase: TurnReviewPhase::Verdict(hel::hel_review::verdict::ReviewVerdict::Findings {
4519 synthesis: "[P1] src/lib.rs:1 -- unbounded retry".into(),
4520 evidence: Default::default(),
4521 }),
4522 roles: vec![
4523 RoleStatus {
4524 role: "supervisor".into(),
4525 label: "Supervisor".into(),
4526 state: RoleState::Clean,
4527 },
4528 RoleStatus {
4529 role: "tests".into(),
4530 label: "Tests".into(),
4531 state: RoleState::Findings,
4532 },
4533 ],
4534 status: "Enter to act".into(),
4535 verdict: Some(VerdictView {
4536 kind: VerdictKind::Findings,
4537 text: "[P1] src/lib.rs:1 -- unbounded retry".into(),
4538 allowed: vec![
4539 Resolution::Forwarded,
4540 Resolution::Dismissed,
4541 Resolution::Cancelled,
4542 ],
4543 }),
4544 };
4545
4546 let projected = ViewerTurnReview::from_runtime(&review);
4547
4548 assert_eq!(projected.tier, "extended");
4549 assert_eq!(
4550 projected
4551 .roles
4552 .iter()
4553 .map(|role| (role.label.as_str(), role.state.as_str()))
4554 .collect::<Vec<_>>(),
4555 vec![("Supervisor", "clean"), ("Tests", "findings")]
4556 );
4557 let verdict = projected.verdict.expect("a findings verdict travels");
4558 assert_eq!(verdict.kind, "findings");
4559 assert!(verdict.text.contains("unbounded retry"));
4560 assert_eq!(verdict.allowed, vec!["forward", "dismiss", "cancel"]);
4561 }
4562
4563 #[test]
4567 fn resolving_a_review_is_gated_on_what_the_daemon_published() {
4568 let (config, state) = sample_config_state();
4569 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4570
4571 let resolve = |resolution: &str| ControllerAction::ResolveReview {
4572 session_id: "session-1".into(),
4573 resolution: resolution.into(),
4574 };
4575
4576 let error = validate_action(&resolve("cancel"), &snapshot).unwrap_err();
4578 assert_eq!(error.status, StatusCode::BAD_REQUEST);
4579
4580 snapshot.sessions[0].turn_review = Some(ViewerTurnReview {
4581 tier: "quick".into(),
4582 status: "the reviewer is reading the changeā¦".into(),
4583 roles: Vec::new(),
4584 verdict: None,
4585 });
4586 validate_action(&resolve("cancel"), &snapshot).unwrap();
4588 assert_eq!(
4589 validate_action(&resolve("forward"), &snapshot)
4590 .unwrap_err()
4591 .status,
4592 StatusCode::BAD_REQUEST
4593 );
4594
4595 snapshot.sessions[0].turn_review = Some(ViewerTurnReview {
4597 tier: "quick".into(),
4598 status: "the review failed".into(),
4599 roles: Vec::new(),
4600 verdict: Some(ViewerReviewVerdict {
4601 kind: "failed".into(),
4602 text: "bifrost exited with 1".into(),
4603 allowed: vec!["dismiss".into(), "cancel".into()],
4604 }),
4605 });
4606 validate_action(&resolve("dismiss"), &snapshot).unwrap();
4607 assert_eq!(
4608 validate_action(&resolve("forward"), &snapshot)
4609 .unwrap_err()
4610 .status,
4611 StatusCode::BAD_REQUEST
4612 );
4613 assert_eq!(
4615 validate_action(&resolve("approve"), &snapshot)
4616 .unwrap_err()
4617 .status,
4618 StatusCode::BAD_REQUEST
4619 );
4620
4621 validate_action(
4623 &ControllerAction::StartReview {
4624 session_id: "session-1".into(),
4625 },
4626 &snapshot,
4627 )
4628 .unwrap();
4629 assert_eq!(
4630 validate_action(
4631 &ControllerAction::StartReview {
4632 session_id: "not-managed".into(),
4633 },
4634 &snapshot,
4635 )
4636 .unwrap_err()
4637 .status,
4638 StatusCode::NOT_FOUND
4639 );
4640 }
4641
4642 #[test]
4643 fn resume_action_refuses_a_target_the_session_cannot_use() {
4644 let (mut config, state) = sample_config_state();
4645 config.bundles.get_mut("hel").unwrap().repositories[0].local = None;
4648 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4649 snapshot.workspaces.push(ViewerWorkspace {
4650 id: "workspace-1".into(),
4651 name: "One".into(),
4652 });
4653 assert_eq!(
4654 snapshot.sessions[0].incompatible_resume_targets,
4655 vec!["raw".to_owned()]
4656 );
4657
4658 let error = validate_action(
4659 &ControllerAction::Resume {
4660 session_id: "session-1".into(),
4661 workspace_id: "workspace-1".into(),
4662 profile_id: "codex-1".into(),
4663 target_id: "raw".into(),
4664 queue: ResumeQueueDisposition::Start,
4665 },
4666 &snapshot,
4667 )
4668 .unwrap_err();
4669
4670 assert_eq!(error.status, StatusCode::BAD_REQUEST);
4671 }
4672
4673 #[tokio::test]
4674 async fn snapshot_endpoint_returns_only_public_projection() {
4675 let (app, _, _, _, _) = app();
4676 let cookie = login_cookie(&app).await;
4677 let response = app
4678 .oneshot(
4679 Request::get("/api/snapshot")
4680 .header(COOKIE, cookie)
4681 .body(Body::empty())
4682 .unwrap(),
4683 )
4684 .await
4685 .unwrap();
4686 let body = response.into_body().collect().await.unwrap().to_bytes();
4687 let body = String::from_utf8(body.to_vec()).unwrap();
4688 assert!(body.contains("session-1"));
4689 assert!(!body.contains("secret-token"));
4690 assert!(!body.contains("native-secret-id"));
4691 assert!(!body.contains("/private/source/hel"));
4692
4693 let snapshot: serde_json::Value = serde_json::from_str(&body).unwrap();
4694 let repository = &snapshot["bundles"][0]["repositories"][0];
4695 assert_eq!(repository["id"], "hel");
4696 assert_eq!(repository["github"], "owner/hel");
4697 assert_eq!(repository["destination"], "hel");
4698 assert!(repository.get("local").is_none());
4699 }
4700
4701 #[tokio::test]
4702 async fn conversation_endpoint_returns_authenticated_bounded_deltas() {
4703 let transcript = BrowserTranscript {
4704 latest_seq: 8,
4705 window_start_seq: 3,
4706 reset: false,
4707 entries: vec![
4708 BrowserTranscriptEntry {
4709 id: 3,
4710 updated_seq: 3,
4711 role: "user",
4712 label: "You".into(),
4713 recorded_at_ms: None,
4714 lines: vec!["begin".into()],
4715 glyph: "\u{276f}",
4716 tone: "user",
4717 tool_status: None,
4718 diffstats: Vec::new(),
4719 },
4720 BrowserTranscriptEntry {
4721 id: 7,
4722 updated_seq: 8,
4723 role: "agent",
4724 label: "Agent".into(),
4725 recorded_at_ms: None,
4726 lines: vec!["live".into()],
4727 glyph: "\u{25cf}",
4728 tone: "agent",
4729 tool_status: None,
4730 diffstats: Vec::new(),
4731 },
4732 ],
4733 };
4734 let (app, _, _, _, _) =
4735 app_with_conversations(BTreeMap::from([("session-1".into(), transcript)]));
4736 let cookie = login_cookie(&app).await;
4737 let response = app
4738 .oneshot(
4739 Request::get("/api/conversations/session-1?after_seq=3")
4740 .header(COOKIE, cookie)
4741 .body(Body::empty())
4742 .unwrap(),
4743 )
4744 .await
4745 .unwrap();
4746 assert_eq!(response.status(), StatusCode::OK);
4747 let body = response.into_body().collect().await.unwrap().to_bytes();
4748 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
4749 assert_eq!(body["latest_seq"], 8);
4750 assert_eq!(body["reset"], false);
4751 assert_eq!(body["entries"].as_array().unwrap().len(), 1);
4752 assert_eq!(body["entries"][0]["lines"][0], "live");
4753 }
4754
4755 #[tokio::test]
4756 async fn conversation_read_receipt_never_contends_with_a_running_action() {
4757 let (app, mut actions, mut receipts, _, _) = app();
4758 let cookie = login_cookie(&app).await;
4759 let prompt = tokio::spawn(
4763 app.clone().oneshot(
4764 Request::post("/api/actions")
4765 .header(COOKIE, cookie.clone())
4766 .header(CONTENT_TYPE, "application/json")
4767 .body(Body::from(
4768 r#"{"action":"prompt","session_id":"session-1","text":"ship it"}"#,
4769 ))
4770 .unwrap(),
4771 ),
4772 );
4773 let action = actions.recv().await.unwrap();
4774
4775 let response = tokio::spawn(
4776 app.oneshot(
4777 Request::post("/api/conversations/session-1/read")
4778 .header(COOKIE, cookie)
4779 .header(CONTENT_TYPE, "application/json")
4780 .body(Body::from(r#"{"through":42}"#))
4781 .unwrap(),
4782 ),
4783 );
4784 let receipt = receipts.recv().await.unwrap();
4785 assert_eq!(receipt.session_id, "session-1");
4786 assert_eq!(receipt.through, 42);
4787 receipt.reply.send(Ok(())).unwrap();
4788 assert_eq!(
4789 response.await.unwrap().unwrap().status(),
4790 StatusCode::NO_CONTENT
4791 );
4792 assert!(
4793 actions.try_recv().is_err(),
4794 "a read receipt must not queue a controller action"
4795 );
4796
4797 action.reply.send(ActionOutcome::Accepted).unwrap();
4798 assert_eq!(
4799 prompt.await.unwrap().unwrap().status(),
4800 StatusCode::ACCEPTED
4801 );
4802 }
4803
4804 #[tokio::test]
4805 async fn each_rejected_action_keeps_its_own_status_and_guidance() {
4806 for (outcome, status, guidance) in [
4807 (
4808 ActionOutcome::Busy,
4809 StatusCode::TOO_MANY_REQUESTS,
4810 "concurrent action limit",
4811 ),
4812 (
4813 ActionOutcome::SessionBusy,
4814 StatusCode::CONFLICT,
4815 "another operation is already running",
4816 ),
4817 (
4818 ActionOutcome::NotCancellable,
4819 StatusCode::CONFLICT,
4820 "no cancellable operation",
4821 ),
4822 (
4823 ActionOutcome::Failed,
4824 StatusCode::INTERNAL_SERVER_ERROR,
4825 "could not start this action",
4826 ),
4827 ] {
4828 let (app, mut actions, _, _, _) = app();
4829 let cookie = login_cookie(&app).await;
4830 let response = tokio::spawn(
4831 app.oneshot(
4832 Request::post("/api/actions")
4833 .header(COOKIE, cookie)
4834 .header(CONTENT_TYPE, "application/json")
4835 .body(Body::from(r#"{"action":"close","session_id":"session-1"}"#))
4836 .unwrap(),
4837 ),
4838 );
4839 let request = actions.recv().await.unwrap();
4840 request.reply.send(outcome).unwrap();
4841
4842 let response = response.await.unwrap().unwrap();
4843 assert_eq!(response.status(), status, "{outcome:?}");
4844 let body = response.into_body().collect().await.unwrap().to_bytes();
4845 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
4846 let error = body["error"].as_str().unwrap();
4847 assert!(error.contains(guidance), "{outcome:?} answered {error:?}");
4848 }
4849 }
4850
4851 #[tokio::test]
4852 async fn the_viewer_shows_a_session_whose_action_failed_after_it_was_accepted() {
4853 let (app, _, _, _, _) = app();
4857 let script = fetch_text(app, "/viewer.js").await;
4858 assert!(script.contains("has_error"), "viewer ignores has_error");
4859 }
4860
4861 #[tokio::test]
4864 async fn every_response_carries_the_security_headers() {
4865 for path in [
4866 "/",
4867 "/viewer.js",
4868 "/viewer.css",
4869 "/manifest.webmanifest",
4870 "/api/snapshot",
4871 ] {
4872 let (app, _, _, _, _) = app();
4873 let response = app
4874 .oneshot(Request::get(path).body(Body::empty()).unwrap())
4875 .await
4876 .unwrap();
4877 let headers = response.headers();
4878 let policy = headers
4879 .get(CONTENT_SECURITY_POLICY_HEADER)
4880 .unwrap_or_else(|| panic!("{path} carries no content-security policy"))
4881 .to_str()
4882 .unwrap();
4883 assert!(
4884 policy.starts_with("default-src 'none';"),
4885 "{path} does not refuse unlisted sources: {policy}"
4886 );
4887 assert!(
4888 policy.contains("script-src 'self'") && !policy.contains("unsafe-inline"),
4889 "{path} permits inline script: {policy}"
4890 );
4891 assert!(
4892 policy.contains("frame-ancestors 'none'"),
4893 "{path} can be framed: {policy}"
4894 );
4895 assert_eq!(
4896 headers.get(X_CONTENT_TYPE_OPTIONS).unwrap(),
4897 "nosniff",
4898 "{path} permits content sniffing"
4899 );
4900 assert_eq!(
4901 headers.get(REFERRER_POLICY).unwrap(),
4902 "no-referrer",
4903 "{path} leaks a referrer"
4904 );
4905 }
4906 }
4907
4908 #[tokio::test]
4912 async fn the_page_carries_no_inline_script_or_style() {
4913 let (app, _, _, _, _) = app();
4914 let page = fetch_text(app, "/").await;
4915 assert!(
4916 !page.contains("<script>") && !page.contains("<style>"),
4917 "the page inlines script or style, which the policy blocks"
4918 );
4919 assert!(
4920 page.contains(r#"src="/viewer.js""#) && page.contains(r#"href="/viewer.css""#),
4921 "the page does not load its script and style as separate assets"
4922 );
4923 }
4924
4925 #[tokio::test]
4928 async fn live_state_and_the_service_worker_are_never_stored() {
4929 for path in ["/", "/service-worker.js", "/api/snapshot"] {
4930 let (app, _, _, _, _) = app();
4931 let response = app
4932 .oneshot(Request::get(path).body(Body::empty()).unwrap())
4933 .await
4934 .unwrap();
4935 assert_eq!(
4936 response.headers().get(CACHE_CONTROL).unwrap(),
4937 "no-store",
4938 "{path} may be stored"
4939 );
4940 }
4941 }
4942
4943 #[test]
4946 fn the_service_worker_declines_to_handle_live_state() {
4947 assert!(
4948 SERVICE_WORKER.contains("url.pathname.startsWith('/api/')"),
4949 "the service worker does not exclude the API"
4950 );
4951 assert!(
4952 SERVICE_WORKER.contains("url.pathname.startsWith('/auth/')"),
4953 "the service worker does not exclude authentication"
4954 );
4955 assert!(
4956 SERVICE_WORKER.contains("caches.delete"),
4957 "the service worker never deletes a superseded cache"
4958 );
4959 }
4960
4961 #[tokio::test]
4964 async fn the_installable_assets_are_served() {
4965 for (path, content_type) in [
4966 ("/icon-192.png", "image/png"),
4967 ("/icon-512.png", "image/png"),
4968 ("/maskable-512.png", "image/png"),
4969 ("/apple-touch-icon.png", "image/png"),
4970 ("/fonts/jetbrains-mono.woff2", "font/woff2"),
4971 ] {
4972 let (app, _, _, _, _) = app();
4973 let response = app
4974 .oneshot(Request::get(path).body(Body::empty()).unwrap())
4975 .await
4976 .unwrap();
4977 assert_eq!(response.status(), StatusCode::OK, "{path} is not served");
4978 assert_eq!(
4979 response.headers().get(CONTENT_TYPE).unwrap(),
4980 content_type,
4981 "{path} is served as the wrong type"
4982 );
4983 }
4984 }
4985
4986 async fn fetch_text(app: Router, path: &str) -> String {
4990 let response = app
4991 .oneshot(Request::get(path).body(Body::empty()).unwrap())
4992 .await
4993 .unwrap();
4994 assert_eq!(response.status(), StatusCode::OK, "{path} is not served");
4995 let body = response.into_body().collect().await.unwrap().to_bytes();
4996 String::from_utf8(body.to_vec()).expect("assets are UTF-8")
4997 }
4998
4999 #[tokio::test]
5000 async fn repeated_wrong_codes_lock_the_login_endpoint() {
5001 let (app, _, _, _, _) = app();
5002 let attempt = |code: &'static str| {
5003 let app = app.clone();
5004 async move {
5005 app.oneshot(
5006 Request::post("/auth/session")
5007 .header(CONTENT_TYPE, "application/json")
5008 .body(Body::from(format!(r#"{{"code":"{code}"}}"#)))
5009 .unwrap(),
5010 )
5011 .await
5012 .unwrap()
5013 .status()
5014 }
5015 };
5016 for _ in 0..MAX_CODE_FAILURES {
5017 assert_eq!(attempt("000000").await, StatusCode::UNAUTHORIZED);
5018 }
5019 assert_eq!(attempt("000000").await, StatusCode::TOO_MANY_REQUESTS);
5020 assert_eq!(attempt("123456").await, StatusCode::TOO_MANY_REQUESTS);
5023 }
5024
5025 #[test]
5026 fn viewer_code_lockouts_lengthen_instead_of_resetting_after_every_wait() {
5027 let serve_one_lockout = |guard: &mut CodeGuard, now: Instant| {
5028 for _ in 0..MAX_CODE_FAILURES {
5029 assert!(!guard.locked_at(now));
5030 guard.record_failure_at(now);
5031 }
5032 assert!(guard.locked_at(now));
5033 guard.locked_until.expect("the guard is locked") - now
5034 };
5035
5036 let start = Instant::now();
5037 let mut guard = CodeGuard::default();
5038 let first = serve_one_lockout(&mut guard, start);
5039 assert_eq!(first, CODE_LOCKOUT_BASE);
5040
5041 let second_round = start + first;
5045 let second = serve_one_lockout(&mut guard, second_round);
5046 assert_eq!(second, CODE_LOCKOUT_BASE * 2);
5047 let third = serve_one_lockout(&mut guard, second_round + second);
5048 assert_eq!(third, CODE_LOCKOUT_BASE * 4);
5049 assert_eq!(code_lockout(u32::MAX), CODE_LOCKOUT_CAP);
5050
5051 let mut recovered = CodeGuard::default();
5054 assert_eq!(serve_one_lockout(&mut recovered, start), CODE_LOCKOUT_BASE);
5055 }
5056
5057 #[test]
5058 fn persisted_cookie_key_survives_a_restart_and_stays_owner_only() {
5059 let directory = tempfile::tempdir().unwrap();
5060 let path = directory.path().join("phone-cookie-key");
5061
5062 let first = load_or_create_cookie_key(&path).unwrap();
5063 assert!(first.len() >= COOKIE_KEY_BYTES);
5064 assert_eq!(std::fs::read(&path).unwrap(), first);
5065 #[cfg(unix)]
5066 {
5067 use std::os::unix::fs::PermissionsExt as _;
5068 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
5069 assert_eq!(mode & 0o777, 0o600);
5070 }
5071
5072 let mut restarted = detached_options();
5075 restarted
5076 .set_cookie_key(load_or_create_cookie_key(&path).unwrap())
5077 .unwrap();
5078 let mut original = detached_options();
5079 original.set_cookie_key(first.clone()).unwrap();
5080 let cookie = signed_cookie_value(&original.cookie_key, "test-viewer", 200);
5081 assert!(session_cookie_valid(&restarted.cookie_key, &cookie, 100));
5082 assert!(!session_cookie_valid(
5083 &detached_options().cookie_key,
5084 &cookie,
5085 100
5086 ));
5087
5088 std::fs::remove_file(&path).unwrap();
5090 let rotated = load_or_create_cookie_key(&path).unwrap();
5091 assert_ne!(rotated, first);
5092 assert!(!session_cookie_valid(&rotated, &cookie, 100));
5093 }
5094
5095 #[test]
5096 fn corrupt_cookie_key_is_regenerated_instead_of_blocking_startup() {
5097 let directory = tempfile::tempdir().unwrap();
5098 let path = directory.path().join("phone-cookie-key");
5099 std::fs::write(&path, b"short").unwrap();
5100
5101 let key = load_or_create_cookie_key(&path).unwrap();
5102
5103 assert!(key.len() >= COOKIE_KEY_BYTES);
5104 assert_eq!(std::fs::read(&path).unwrap(), key);
5105 assert_eq!(load_or_create_cookie_key(&path).unwrap(), key);
5106 }
5107}