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