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 guardian_review_model: None,
3965 kind: HarnessKind::Codex,
3966 home: "/highly/secret/codex".into(),
3967 environment: BTreeMap::from([("GH_TOKEN".into(), "secret-token".into())]),
3968 },
3969 )]),
3970 bundles: BTreeMap::from([(
3971 "hel".into(),
3972 ProjectBundle {
3973 primary_repo: "hel".into(),
3974 repositories: vec![ProjectRepository {
3975 id: "hel".into(),
3976 github: Some("owner/hel".into()),
3977 local: Some("/private/source/hel".into()),
3978 destination: "hel".into(),
3979 git_ref: None,
3980 }],
3981 },
3982 )]),
3983 targets: BTreeMap::from([
3984 (
3985 "podman".into(),
3986 TargetTemplate::LocalPodman {
3987 container: ContainerTemplate {
3988 image: "secret.registry/image".into(),
3989 pull_policy: Default::default(),
3990 platform: None,
3991 cpus: None,
3992 memory: None,
3993 environment: BTreeMap::from([("TOKEN".into(), "secret-target".into())]),
3994 workspace_storage: Default::default(),
3995 },
3996 },
3997 ),
3998 ("raw".into(), TargetTemplate::LocalBare),
3999 ]),
4000 };
4001 let state = AppState {
4002 subagents: Default::default(),
4003 version: STATE_VERSION,
4004 sessions: BTreeMap::from([(
4005 "session-1".into(),
4006 SessionRecord {
4007 mjolnir_subagents: None,
4008 create_managed_worktree: None,
4009 workspace_id: mj_core::workspace::DEFAULT_WORKSPACE_ID.to_owned(),
4010 archived: false,
4011 container_cpus: None,
4012 container_memory: None,
4013 id: "session-1".into(),
4014 title: "Build Hel".into(),
4015 harness_kind: HarnessKind::Codex,
4016 last_profile: "codex-1".into(),
4017 bundle_id: "hel".into(),
4018 project_directory: None,
4019 managed_worktree: None,
4020 target_template_id: "podman".into(),
4021 resource_allocation: None,
4022 additional_mounts: vec![],
4023 state: SessionState::Running,
4024 target: None,
4025 native_session_id: Some("native-secret-id".into()),
4026 acp_session_title: Some("Build Hel".into()),
4027 session_title_override: None,
4028 created_at: "now".into(),
4029 updated_at: "now".into(),
4030 viewed_through_event_ordinal: 0,
4031 draft_input: String::new(),
4032 last_error: Some("secret-token at /highly/secret/codex".into()),
4033 last_checkpoint_error: None,
4034 checkpoint: None,
4035 },
4036 )]),
4037 mount_history: BTreeMap::new(),
4038 container_sizes: BTreeMap::new(),
4039 };
4040 (config, state)
4041 }
4042
4043 type TestServer = (
4044 Router,
4045 mpsc::Receiver<ControllerRequest>,
4046 mpsc::Receiver<ReadReceiptRequest>,
4047 mpsc::Receiver<PreflightRequest>,
4048 mpsc::Receiver<ClientStateRequest>,
4049 );
4050
4051 fn app() -> TestServer {
4052 app_with_conversations(BTreeMap::new())
4053 }
4054
4055 fn app_with_move_receiver() -> (Router, mpsc::Receiver<MovePreparationRequest>) {
4056 let (config, state) = sample_config_state();
4057 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4058 snapshot.sessions[0].capabilities.move_session = true;
4059 let (_snapshot_tx, snapshot_rx) = watch::channel(snapshot);
4060 let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
4061 let (action_tx, _action_rx) = mpsc::channel(8);
4062 let (bundle_tx, _bundle_rx) = mpsc::channel(8);
4063 let (receipt_tx, _receipt_rx) = mpsc::channel(8);
4064 let (preflight_tx, _preflight_rx) = mpsc::channel(8);
4065 let (move_preparation_tx, move_preparation_rx) = mpsc::channel(8);
4066 let (client_state_tx, _client_state_rx) = mpsc::channel(8);
4067 let options = test_options(
4068 snapshot_rx,
4069 conversation_rx,
4070 action_tx,
4071 bundle_tx,
4072 receipt_tx,
4073 preflight_tx,
4074 move_preparation_tx,
4075 client_state_tx,
4076 )
4077 .with_test_credentials("123456", b"01234567890123456789012345678901");
4078 (router(options), move_preparation_rx)
4079 }
4080
4081 fn app_with_conversations(conversations: BTreeMap<String, BrowserTranscript>) -> TestServer {
4082 app_with(conversations, |_| {})
4083 }
4084
4085 fn app_with_snapshot(adjust: impl FnOnce(&mut ViewerSnapshot)) -> TestServer {
4086 app_with(BTreeMap::new(), adjust)
4087 }
4088
4089 fn app_with(
4090 conversations: BTreeMap<String, BrowserTranscript>,
4091 adjust: impl FnOnce(&mut ViewerSnapshot),
4092 ) -> TestServer {
4093 let (config, state) = sample_config_state();
4094 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4095 adjust(&mut snapshot);
4096 let (_snapshot_tx, snapshot_rx) = watch::channel(snapshot);
4097 let (_conversation_tx, conversation_rx) = watch::channel(conversations);
4098 let (action_tx, action_rx) = mpsc::channel(8);
4099 let (bundle_tx, _bundle_rx) = mpsc::channel(8);
4100 let (receipt_tx, receipt_rx) = mpsc::channel(8);
4101 let (preflight_tx, preflight_rx) = mpsc::channel(8);
4102 let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
4103 let (client_state_tx, client_state_rx) = mpsc::channel(8);
4104 let options = test_options(
4105 snapshot_rx,
4106 conversation_rx,
4107 action_tx,
4108 bundle_tx,
4109 receipt_tx,
4110 preflight_tx,
4111 move_preparation_tx,
4112 client_state_tx,
4113 )
4114 .with_test_credentials("123456", b"01234567890123456789012345678901");
4115 (
4116 router(options),
4117 action_rx,
4118 receipt_rx,
4119 preflight_rx,
4120 client_state_rx,
4121 )
4122 }
4123
4124 fn app_with_bundle_receiver() -> (Router, mpsc::Receiver<BundleRequest>) {
4125 let (config, state) = sample_config_state();
4126 let (_snapshot_tx, snapshot_rx) =
4127 watch::channel(ViewerSnapshot::from_config_state(&config, &state, 1));
4128 let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
4129 let (action_tx, _action_rx) = mpsc::channel(8);
4130 let (bundle_tx, bundle_rx) = mpsc::channel(8);
4131 let (receipt_tx, _receipt_rx) = mpsc::channel(8);
4132 let (preflight_tx, _preflight_rx) = mpsc::channel(8);
4133 let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
4134 let (client_state_tx, _client_state_rx) = mpsc::channel(8);
4135 let options = test_options(
4136 snapshot_rx,
4137 conversation_rx,
4138 action_tx,
4139 bundle_tx,
4140 receipt_tx,
4141 preflight_tx,
4142 move_preparation_tx,
4143 client_state_tx,
4144 )
4145 .with_test_credentials("123456", b"01234567890123456789012345678901");
4146 (router(options), bundle_rx)
4147 }
4148
4149 #[allow(clippy::too_many_arguments)]
4152 fn test_options(
4153 snapshot_rx: watch::Receiver<ViewerSnapshot>,
4154 conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
4155 action_tx: mpsc::Sender<ControllerRequest>,
4156 bundle_tx: mpsc::Sender<BundleRequest>,
4157 receipt_tx: mpsc::Sender<ReadReceiptRequest>,
4158 preflight_tx: mpsc::Sender<PreflightRequest>,
4159 move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
4160 client_state_tx: mpsc::Sender<ClientStateRequest>,
4161 ) -> ServerOptions {
4162 test_options_with_dictation(
4163 snapshot_rx,
4164 conversation_rx,
4165 action_tx,
4166 bundle_tx,
4167 receipt_tx,
4168 preflight_tx,
4169 move_preparation_tx,
4170 client_state_tx,
4171 )
4172 .0
4173 }
4174
4175 #[allow(clippy::too_many_arguments)]
4176 fn test_options_with_dictation(
4177 snapshot_rx: watch::Receiver<ViewerSnapshot>,
4178 conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
4179 action_tx: mpsc::Sender<ControllerRequest>,
4180 bundle_tx: mpsc::Sender<BundleRequest>,
4181 receipt_tx: mpsc::Sender<ReadReceiptRequest>,
4182 preflight_tx: mpsc::Sender<PreflightRequest>,
4183 move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
4184 client_state_tx: mpsc::Sender<ClientStateRequest>,
4185 ) -> (ServerOptions, mpsc::Receiver<DictationRequest>) {
4186 let (dictation_tx, dictation_rx) = mpsc::channel(8);
4187 let options = ServerOptions::new(
4188 "127.0.0.1:0".parse().unwrap(),
4189 snapshot_rx,
4190 conversation_rx,
4191 ServerRequests {
4192 action_tx,
4193 bundle_tx,
4194 receipt_tx,
4195 preflight_tx,
4196 move_preparation_tx,
4197 client_state_tx,
4198 dictation_tx,
4199 },
4200 )
4201 .unwrap();
4202 (options, dictation_rx)
4203 }
4204
4205 fn app_with_dictation_receiver() -> (Router, mpsc::Receiver<DictationRequest>) {
4206 let (config, state) = sample_config_state();
4207 let (_snapshot_tx, snapshot_rx) =
4208 watch::channel(ViewerSnapshot::from_config_state(&config, &state, 1));
4209 let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
4210 let (action_tx, _action_rx) = mpsc::channel(8);
4211 let (bundle_tx, _bundle_rx) = mpsc::channel(8);
4212 let (receipt_tx, _receipt_rx) = mpsc::channel(8);
4213 let (preflight_tx, _preflight_rx) = mpsc::channel(8);
4214 let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
4215 let (client_state_tx, _client_state_rx) = mpsc::channel(8);
4216 let (options, dictation_rx) = test_options_with_dictation(
4217 snapshot_rx,
4218 conversation_rx,
4219 action_tx,
4220 bundle_tx,
4221 receipt_tx,
4222 preflight_tx,
4223 move_preparation_tx,
4224 client_state_tx,
4225 );
4226 (
4227 router(options.with_test_credentials("123456", b"01234567890123456789012345678901")),
4228 dictation_rx,
4229 )
4230 }
4231
4232 fn detached_options() -> ServerOptions {
4233 let (config, state) = sample_config_state();
4234 let (_snapshot_tx, snapshot_rx) =
4235 watch::channel(ViewerSnapshot::from_config_state(&config, &state, 1));
4236 let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
4237 let (action_tx, _action_rx) = mpsc::channel(1);
4238 let (bundle_tx, _bundle_rx) = mpsc::channel(1);
4239 let (receipt_tx, _receipt_rx) = mpsc::channel(1);
4240 let (preflight_tx, _preflight_rx) = mpsc::channel(1);
4241 let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(1);
4242 let (client_state_tx, _client_state_rx) = mpsc::channel(1);
4243 test_options(
4244 snapshot_rx,
4245 conversation_rx,
4246 action_tx,
4247 bundle_tx,
4248 receipt_tx,
4249 preflight_tx,
4250 move_preparation_tx,
4251 client_state_tx,
4252 )
4253 }
4254
4255 fn cookie() -> String {
4261 format!(
4262 "{COOKIE_NAME}={}",
4263 signed_cookie_value(
4264 b"01234567890123456789012345678901",
4265 "test-viewer",
4266 now_unix().saturating_add(3600)
4267 )
4268 )
4269 }
4270
4271 fn valid_wav() -> Bytes {
4272 let samples = vec![0_u8; 320];
4273 let mut wav = Vec::with_capacity(44 + samples.len());
4274 wav.extend_from_slice(b"RIFF");
4275 wav.extend_from_slice(&(36_u32 + samples.len() as u32).to_le_bytes());
4276 wav.extend_from_slice(b"WAVEfmt ");
4277 wav.extend_from_slice(&16_u32.to_le_bytes());
4278 wav.extend_from_slice(&1_u16.to_le_bytes());
4279 wav.extend_from_slice(&1_u16.to_le_bytes());
4280 wav.extend_from_slice(&16_000_u32.to_le_bytes());
4281 wav.extend_from_slice(&32_000_u32.to_le_bytes());
4282 wav.extend_from_slice(&2_u16.to_le_bytes());
4283 wav.extend_from_slice(&16_u16.to_le_bytes());
4284 wav.extend_from_slice(b"data");
4285 wav.extend_from_slice(&(samples.len() as u32).to_le_bytes());
4286 wav.extend_from_slice(&samples);
4287 Bytes::from(wav)
4288 }
4289
4290 async fn login_cookie(app: &Router) -> String {
4291 let response = app
4292 .clone()
4293 .oneshot(
4294 Request::post("/auth/session")
4295 .header(CONTENT_TYPE, "application/json")
4296 .body(Body::from(r#"{"code":"123456"}"#))
4297 .unwrap(),
4298 )
4299 .await
4300 .unwrap();
4301 assert_eq!(response.status(), StatusCode::NO_CONTENT);
4302 response
4303 .headers()
4304 .get(SET_COOKIE)
4305 .unwrap()
4306 .to_str()
4307 .unwrap()
4308 .split(';')
4309 .next()
4310 .unwrap()
4311 .to_string()
4312 }
4313
4314 #[tokio::test]
4315 async fn dictation_availability_requires_auth_and_forwards_typed_request() {
4316 let (app, mut requests) = app_with_dictation_receiver();
4317 let unauthorized = app
4318 .clone()
4319 .oneshot(
4320 Request::get("/api/sessions/session-1/dictation")
4321 .body(Body::empty())
4322 .unwrap(),
4323 )
4324 .await
4325 .unwrap();
4326 assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
4327 assert!(requests.try_recv().is_err());
4328
4329 let cookie = login_cookie(&app).await;
4330 let pending = tokio::spawn({
4331 let app = app.clone();
4332 async move {
4333 app.oneshot(
4334 Request::get("/api/sessions/session-1/dictation")
4335 .header(COOKIE, cookie)
4336 .body(Body::empty())
4337 .unwrap(),
4338 )
4339 .await
4340 .unwrap()
4341 }
4342 });
4343 let request = requests.recv().await.unwrap();
4344 assert_eq!(request.session_id, "session-1");
4345 assert!(matches!(
4346 request.operation,
4347 DictationOperation::Availability
4348 ));
4349 request
4350 .reply
4351 .send(Ok(DictationResponse::Availability {
4352 available: true,
4353 reason: None,
4354 }))
4355 .unwrap();
4356 let response = pending.await.unwrap();
4357 assert_eq!(response.status(), StatusCode::OK);
4358 let body = response.into_body().collect().await.unwrap().to_bytes();
4359 assert_eq!(&body[..], br#"{"available":true}"#);
4360 }
4361
4362 #[tokio::test]
4363 async fn dictation_rejects_bad_wav_before_controller_dispatch() {
4364 let (app, mut requests) = app_with_dictation_receiver();
4365 let cookie = login_cookie(&app).await;
4366 let response = app
4367 .oneshot(
4368 Request::post("/api/sessions/session-1/dictation")
4369 .header(COOKIE, cookie)
4370 .header(CONTENT_TYPE, "audio/wav")
4371 .body(Body::from("not wav"))
4372 .unwrap(),
4373 )
4374 .await
4375 .unwrap();
4376 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4377 assert!(requests.try_recv().is_err());
4378 }
4379
4380 #[tokio::test]
4381 async fn dictation_rejects_a_third_upload_before_reading_its_body() {
4382 let (app, mut requests) = app_with_dictation_receiver();
4383 let cookie = login_cookie(&app).await;
4384 let request = || {
4385 Request::post("/api/sessions/session-1/dictation")
4386 .header(COOKIE, cookie.clone())
4387 .header(CONTENT_TYPE, "audio/wav")
4388 .body(Body::from(valid_wav()))
4389 .unwrap()
4390 };
4391 let first = tokio::spawn({
4392 let app = app.clone();
4393 let request = request();
4394 async move { app.oneshot(request).await.unwrap() }
4395 });
4396 let second = tokio::spawn({
4397 let app = app.clone();
4398 let request = request();
4399 async move { app.oneshot(request).await.unwrap() }
4400 });
4401 let first_request = requests.recv().await.unwrap();
4402 let second_request = requests.recv().await.unwrap();
4403 let third = app
4404 .oneshot(
4405 Request::post("/api/sessions/session-1/dictation")
4406 .header(COOKIE, cookie)
4407 .header(CONTENT_TYPE, "audio/wav")
4408 .body(Body::from_stream(futures::stream::poll_fn(
4409 |_| -> std::task::Poll<Option<Result<Bytes, std::io::Error>>> {
4410 panic!("overloaded dictation polled its body")
4411 },
4412 )))
4413 .unwrap(),
4414 )
4415 .await
4416 .unwrap();
4417 assert_eq!(third.status(), StatusCode::TOO_MANY_REQUESTS);
4418 first_request
4419 .reply
4420 .send(Ok(DictationResponse::Transcript {
4421 text: "first".into(),
4422 }))
4423 .unwrap();
4424 second_request
4425 .reply
4426 .send(Ok(DictationResponse::Transcript {
4427 text: "second".into(),
4428 }))
4429 .unwrap();
4430 assert_eq!(first.await.unwrap().status(), StatusCode::OK);
4431 assert_eq!(second.await.unwrap().status(), StatusCode::OK);
4432 }
4433
4434 #[tokio::test]
4435 async fn dictation_upload_rejects_unauthorized_missing_and_oversized_requests() {
4436 let (app, mut requests) = app_with_dictation_receiver();
4437 let response = app
4438 .clone()
4439 .oneshot(
4440 Request::post("/api/sessions/session-1/dictation")
4441 .body(Body::from(valid_wav()))
4442 .unwrap(),
4443 )
4444 .await
4445 .unwrap();
4446 assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
4447 let cookie = login_cookie(&app).await;
4448 let response = app
4449 .clone()
4450 .oneshot(
4451 Request::post("/api/sessions/missing/dictation")
4452 .header(COOKIE, &cookie)
4453 .body(Body::from(valid_wav()))
4454 .unwrap(),
4455 )
4456 .await
4457 .unwrap();
4458 assert_eq!(response.status(), StatusCode::NOT_FOUND);
4459 let response = app
4461 .oneshot(
4462 Request::post("/api/sessions/session-1/dictation")
4463 .header(COOKIE, cookie)
4464 .body(Body::from(vec![0_u8; MAX_AUDIO_BYTES + 1]))
4465 .unwrap(),
4466 )
4467 .await
4468 .unwrap();
4469 assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
4470 assert!(requests.try_recv().is_err());
4471 }
4472
4473 fn app_with_background_stop_receiver(
4474 can_stop: bool,
4475 ) -> (Router, mpsc::Receiver<BackgroundTaskStopRequest>) {
4476 let (config, state) = sample_config_state();
4477 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4478 snapshot.sessions[0].background_tasks = vec![ViewerBackgroundTask {
4479 id: "terminal:background-1".into(),
4480 command: "cargo test".into(),
4481 started_at_ms: 1_000,
4482 can_stop,
4483 }];
4484 let (_snapshot_tx, snapshot_rx) = watch::channel(snapshot);
4485 let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
4486 let (action_tx, _action_rx) = mpsc::channel(8);
4487 let (bundle_tx, _bundle_rx) = mpsc::channel(8);
4488 let (receipt_tx, _receipt_rx) = mpsc::channel(8);
4489 let (preflight_tx, _preflight_rx) = mpsc::channel(8);
4490 let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
4491 let (client_state_tx, _client_state_rx) = mpsc::channel(8);
4492 let (stop_tx, stop_rx) = mpsc::channel(8);
4493 let mut options = test_options(
4494 snapshot_rx,
4495 conversation_rx,
4496 action_tx,
4497 bundle_tx,
4498 receipt_tx,
4499 preflight_tx,
4500 move_preparation_tx,
4501 client_state_tx,
4502 )
4503 .with_test_credentials("123456", b"01234567890123456789012345678901");
4504 options.set_background_task_stop_tx(stop_tx);
4505 (router(options), stop_rx)
4506 }
4507
4508 #[tokio::test]
4509 async fn background_task_stop_validates_the_snapshot_and_waits_for_acknowledgement() {
4510 let (app, mut requests) = app_with_background_stop_receiver(true);
4511 let cookie = login_cookie(&app).await;
4512 let response = tokio::spawn({
4513 let app = app.clone();
4514 let cookie = cookie.clone();
4515 async move {
4516 app.oneshot(
4517 Request::post("/api/sessions/session-1/background-tasks/stop")
4518 .header(COOKIE, cookie)
4519 .header(CONTENT_TYPE, "application/json")
4520 .body(Body::from(
4521 r#"{"background_task_id":"terminal:background-1"}"#,
4522 ))
4523 .unwrap(),
4524 )
4525 .await
4526 .unwrap()
4527 }
4528 });
4529 let request = requests.recv().await.unwrap();
4530 assert_eq!(request.session_id, "session-1");
4531 assert_eq!(request.background_task_id, "terminal:background-1");
4532 request.reply.send(Ok(())).unwrap();
4533 assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
4534
4535 let (app, mut requests) = app_with_background_stop_receiver(false);
4536 let cookie = login_cookie(&app).await;
4537 let response = app
4538 .oneshot(
4539 Request::post("/api/sessions/session-1/background-tasks/stop")
4540 .header(COOKIE, cookie)
4541 .header(CONTENT_TYPE, "application/json")
4542 .body(Body::from(
4543 r#"{"background_task_id":"terminal:background-1"}"#,
4544 ))
4545 .unwrap(),
4546 )
4547 .await
4548 .unwrap();
4549 assert_eq!(response.status(), StatusCode::CONFLICT);
4550 assert!(requests.try_recv().is_err());
4551 }
4552
4553 #[tokio::test]
4554 async fn background_task_stop_reports_provider_failure_without_leaking_details() {
4555 let (app, mut requests) = app_with_background_stop_receiver(true);
4556 let cookie = login_cookie(&app).await;
4557 let response = tokio::spawn({
4558 let app = app.clone();
4559 async move {
4560 app.oneshot(
4561 Request::post("/api/sessions/session-1/background-tasks/stop")
4562 .header(COOKIE, cookie)
4563 .header(CONTENT_TYPE, "application/json")
4564 .body(Body::from(
4565 r#"{"background_task_id":"terminal:background-1"}"#,
4566 ))
4567 .unwrap(),
4568 )
4569 .await
4570 .unwrap()
4571 }
4572 });
4573 let request = requests.recv().await.unwrap();
4574 request
4575 .reply
4576 .send(Err(BackgroundTaskStopFailure::Provider))
4577 .unwrap();
4578 let response = response.await.unwrap();
4579 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
4580 let body = response.into_body().collect().await.unwrap().to_bytes();
4581 assert_eq!(
4582 &body[..],
4583 br#"{"error":"the provider could not stop this background task"}"#
4584 );
4585 }
4586
4587 #[tokio::test]
4588 async fn dictation_provider_failure_is_actionable_and_does_not_expose_details() {
4589 let (app, mut requests) = app_with_dictation_receiver();
4590 let cookie = login_cookie(&app).await;
4591 let pending = tokio::spawn(async move {
4592 app.oneshot(
4593 Request::post("/api/sessions/session-1/dictation")
4594 .header(COOKIE, cookie)
4595 .body(Body::from(valid_wav()))
4596 .unwrap(),
4597 )
4598 .await
4599 .unwrap()
4600 });
4601 let request = requests.recv().await.unwrap();
4602 request
4603 .reply
4604 .send(Err(DictationError::Provider(
4605 "private provider details".into(),
4606 )))
4607 .unwrap();
4608 let response = pending.await.unwrap();
4609 assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
4610 let bytes = response.into_body().collect().await.unwrap().to_bytes();
4611 let body = String::from_utf8(bytes.to_vec()).unwrap();
4612 assert!(body.contains("transcription"));
4613 assert!(!body.contains("private provider details"));
4614 }
4615
4616 #[tokio::test]
4617 async fn dropped_dictation_handler_cancels_controller_request() {
4618 let (app, mut requests) = app_with_dictation_receiver();
4619 let cookie = login_cookie(&app).await;
4620 let pending = tokio::spawn({
4621 let app = app.clone();
4622 async move {
4623 app.oneshot(
4624 Request::post("/api/sessions/session-1/dictation")
4625 .header(COOKIE, cookie)
4626 .body(Body::from(valid_wav()))
4627 .unwrap(),
4628 )
4629 .await
4630 .unwrap()
4631 }
4632 });
4633 let request = requests.recv().await.unwrap();
4634 let cancel = request.cancel.clone();
4635 pending.abort();
4636 let _ = pending.await;
4637 assert!(cancel.is_cancelled());
4638 drop(request);
4639 }
4640
4641 #[tokio::test]
4642 async fn bundle_endpoint_authenticates_and_forwards_the_source() {
4643 let (app, mut bundles) = app_with_bundle_receiver();
4644 let unauthorized = app
4645 .clone()
4646 .oneshot(
4647 Request::post("/api/bundles")
4648 .header(CONTENT_TYPE, "application/json")
4649 .body(Body::from(r#"{"source":"example/app"}"#))
4650 .unwrap(),
4651 )
4652 .await
4653 .unwrap();
4654 assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
4655 assert!(bundles.try_recv().is_err());
4656
4657 let cookie = login_cookie(&app).await;
4658 let response = tokio::spawn({
4659 let app = app.clone();
4660 let cookie = cookie.clone();
4661 async move {
4662 app.oneshot(
4663 Request::post("/api/bundles")
4664 .header(CONTENT_TYPE, "application/json")
4665 .header(COOKIE, cookie)
4666 .body(Body::from(r#"{"source":"example/app"}"#))
4667 .unwrap(),
4668 )
4669 .await
4670 .unwrap()
4671 }
4672 });
4673 let request = bundles.recv().await.expect("bundle request forwarded");
4674 assert_eq!(request.source, "example/app");
4675 request.reply.send(Ok("app".into())).unwrap();
4676 let response = response.await.unwrap();
4677 assert_eq!(response.status(), StatusCode::OK);
4678 let body = response.into_body().collect().await.unwrap().to_bytes();
4679 assert_eq!(body.as_ref(), br#"{"bundle_id":"app"}"#);
4680 }
4681
4682 #[tokio::test]
4683 async fn bundle_endpoint_rejects_empty_and_oversized_sources_before_dispatch() {
4684 for source in [String::new(), "x".repeat(MAX_BUNDLE_SOURCE_CHARS + 1)] {
4685 let (app, mut bundles) = app_with_bundle_receiver();
4686 let cookie = login_cookie(&app).await;
4687 let response = app
4688 .oneshot(
4689 Request::post("/api/bundles")
4690 .header(CONTENT_TYPE, "application/json")
4691 .header(COOKIE, cookie)
4692 .body(Body::from(
4693 serde_json::to_string(&serde_json::json!({"source": source})).unwrap(),
4694 ))
4695 .unwrap(),
4696 )
4697 .await
4698 .unwrap();
4699 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4700 assert!(bundles.try_recv().is_err());
4701 }
4702 }
4703
4704 #[tokio::test]
4705 async fn bundle_endpoint_reports_invalid_source_as_a_client_error() {
4706 let (app, mut bundles) = app_with_bundle_receiver();
4707 let cookie = login_cookie(&app).await;
4708 let response = tokio::spawn({
4709 let app = app.clone();
4710 async move {
4711 app.oneshot(
4712 Request::post("/api/bundles")
4713 .header(CONTENT_TYPE, "application/json")
4714 .header(COOKIE, cookie)
4715 .body(Body::from(r#"{"source":"not a source"}"#))
4716 .unwrap(),
4717 )
4718 .await
4719 .unwrap()
4720 }
4721 });
4722 let request = bundles.recv().await.expect("bundle request forwarded");
4723 request
4724 .reply
4725 .send(Err(BundleFailure::InvalidSource))
4726 .unwrap();
4727 let response = response.await.unwrap();
4728 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
4729 let body = response.into_body().collect().await.unwrap().to_bytes();
4730 assert!(String::from_utf8_lossy(&body).contains("GitHub owner/repository"));
4731 }
4732
4733 #[tokio::test]
4734 async fn api_requires_a_valid_signed_cookie() {
4735 let (app, _, _, _, _) = app();
4736 let unauthorized = app
4737 .clone()
4738 .oneshot(Request::get("/api/snapshot").body(Body::empty()).unwrap())
4739 .await
4740 .unwrap();
4741 assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
4742
4743 let cookie = login_cookie(&app).await;
4744 let authorized = app
4745 .oneshot(
4746 Request::get("/api/snapshot")
4747 .header(COOKIE, cookie)
4748 .body(Body::empty())
4749 .unwrap(),
4750 )
4751 .await
4752 .unwrap();
4753 assert_eq!(authorized.status(), StatusCode::OK);
4754 }
4755
4756 #[tokio::test]
4757 async fn qr_login_exchanges_the_secret_for_a_cookie_and_redirects_cleanly() {
4758 let (app, _, _, _, _) = app();
4759 let rejected = app
4760 .clone()
4761 .oneshot(
4762 Request::get("/auth/login?token=wrong")
4763 .body(Body::empty())
4764 .unwrap(),
4765 )
4766 .await
4767 .unwrap();
4768 assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED);
4769
4770 let accepted = app
4771 .oneshot(
4772 Request::get("/auth/login?token=test-login-token")
4773 .body(Body::empty())
4774 .unwrap(),
4775 )
4776 .await
4777 .unwrap();
4778 assert_eq!(accepted.status(), StatusCode::SEE_OTHER);
4779 assert_eq!(accepted.headers().get(LOCATION).unwrap(), "/");
4780 assert_eq!(accepted.headers().get(CACHE_CONTROL).unwrap(), "no-store");
4781 assert!(accepted.headers().contains_key(SET_COOKIE));
4782 }
4783
4784 #[test]
4785 fn signed_cookie_rejects_expiry_and_tampering() {
4786 let key = b"01234567890123456789012345678901";
4787 let cookie = signed_cookie_value(key, "test-viewer", 200);
4788 assert!(session_cookie_valid(key, &cookie, 100));
4789 assert!(!session_cookie_valid(key, &cookie, 200));
4790 assert!(!session_cookie_valid(key, &format!("{cookie}x"), 100));
4791 assert!(!session_cookie_valid(b"another-key", &cookie, 100));
4792 }
4793
4794 #[test]
4795 fn generated_code_and_cookie_attributes_are_phone_safe() {
4796 let code = generate_viewer_code().unwrap();
4797 assert_eq!(code.len(), 6);
4798 assert!(code.bytes().all(|byte| byte.is_ascii_digit()));
4799 let header = session_cookie_header("signed", Some(60), true)
4800 .unwrap()
4801 .to_str()
4802 .unwrap()
4803 .to_string();
4804 assert!(header.contains("HttpOnly"));
4805 assert!(header.contains("SameSite=Strict"));
4806 assert!(header.contains("Secure"));
4807 assert!(header.contains("Max-Age=60"));
4808 }
4809
4810 #[test]
4811 fn public_snapshot_omits_homes_environment_locators_and_raw_errors() {
4812 let (config, state) = sample_config_state();
4813 let json =
4814 serde_json::to_string(&ViewerSnapshot::from_config_state(&config, &state, 9)).unwrap();
4815 assert!(!json.contains("/highly/secret"));
4816 assert!(!json.contains("secret-token"));
4817 assert!(!json.contains("secret-target"));
4818 assert!(!json.contains("secret.registry"));
4819 assert!(!json.contains("native-secret-id"));
4820 assert!(json.contains("\"has_error\":true"));
4821 }
4822
4823 #[test]
4824 fn public_snapshot_keeps_running_sessions_but_omits_disabled_profiles() {
4825 let (mut config, state) = sample_config_state();
4826 config.profiles.get_mut("codex-1").unwrap().enabled = false;
4827
4828 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 9);
4829
4830 assert!(snapshot.profiles.is_empty());
4831 assert_eq!(snapshot.sessions.len(), 1);
4832 assert_eq!(snapshot.sessions[0].profile_id, "codex-1");
4833 }
4834
4835 #[test]
4836 fn target_snapshot_uses_each_raw_host_project_history_and_leaves_managed_empty() {
4837 let (mut config, mut state) = sample_config_state();
4838 config
4839 .targets
4840 .insert("raw-local".into(), TargetTemplate::LocalBare);
4841 config.targets.insert(
4842 "raw-builder".into(),
4843 TargetTemplate::SshBare {
4844 ssh: SshConnection {
4845 host: "builder-a".into(),
4846 user: None,
4847 identity_file: None,
4848 extra_args: Vec::new(),
4849 },
4850 permissions: PermissionMode::Guardian,
4851 workspace_prefix: "workspaces".into(),
4852 },
4853 );
4854 state.remember_project_directory("local", Path::new("/work/local"));
4855 state.remember_project_directory("builder-a", Path::new("/srv/builder"));
4856 state.remember_project_directory("other-host", Path::new("/not-published"));
4857
4858 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
4859 let target = |id: &str| {
4860 snapshot
4861 .targets
4862 .iter()
4863 .find(|target| target.id == id)
4864 .unwrap()
4865 };
4866 assert_eq!(
4867 target("raw-local").recent_project_directories,
4868 vec!["/work/local"]
4869 );
4870 assert_eq!(
4871 target("raw-builder").recent_project_directories,
4872 vec!["/srv/builder"]
4873 );
4874 assert!(target("podman").recent_project_directories.is_empty());
4875 }
4876
4877 #[test]
4878 fn public_snapshot_exposes_only_review_status_configuration() {
4879 let (mut config, state) = sample_config_state();
4880 config.review = mj_core::config::ReviewConfig {
4881 enabled: true,
4882 tier: mj_core::review::lanes::ReviewTier::Extended,
4883 profile: Some("reviewer-1".into()),
4884 model: Some("private-review-model".into()),
4885 effort: Some("private-review-effort".into()),
4886 };
4887
4888 let value =
4889 serde_json::to_value(ViewerSnapshot::from_config_state(&config, &state, 9)).unwrap();
4890
4891 assert_eq!(
4892 value.get("review_config"),
4893 Some(&serde_json::json!({
4894 "enabled": true,
4895 "tier": "extended",
4896 "profile": "reviewer-1",
4897 }))
4898 );
4899 let json = value.to_string();
4900 assert!(!json.contains("private-review-model"));
4901 assert!(!json.contains("private-review-effort"));
4902 }
4903
4904 fn sample_elicitation() -> ElicitationRequest {
4905 ElicitationRequest::from_acp_params(
4906 "elicitation-1",
4907 serde_json::json!({
4908 "sessionId": "session-1",
4909 "mode": "form",
4910 "message": "Which CI architecture should the workflow use?",
4911 "requestedSchema": {
4912 "type": "object",
4913 "required": ["question_0"],
4914 "properties": {
4915 "question_0": {
4916 "type": "string",
4917 "title": "CI architecture",
4918 "oneOf": [
4919 {"const": "reusable", "title": "Reusable workflow"},
4920 {"const": "matrix", "title": "Matrix job"}
4921 ]
4922 },
4923 "question_0_custom": {
4924 "type": "string",
4925 "title": "Other",
4926 "_meta": {"_askUserQuestionCustomAnswer": {
4927 "questionId": "question_0",
4928 "isCustomAnswer": true
4929 }}
4930 }
4931 }
4932 }
4933 }),
4934 )
4935 .expect("sample elicitation parses")
4936 }
4937
4938 fn accept(pairs: &[(&str, &str)]) -> ElicitationResponse {
4939 ElicitationResponse::Accept {
4940 content: pairs
4941 .iter()
4942 .map(|(id, value)| {
4943 (
4944 (*id).to_owned(),
4945 mj_core::elicitation::ElicitationValue::String((*value).to_owned()),
4946 )
4947 })
4948 .collect(),
4949 }
4950 }
4951
4952 fn pending_elicitation_snapshot(snapshot: &mut ViewerSnapshot) {
4953 snapshot.sessions[0].pending_elicitations = vec![sample_elicitation()];
4954 }
4955
4956 #[tokio::test]
4957 async fn elicitation_answer_is_typed_and_forwarded() {
4958 let (app, mut actions, _, _, _) = app_with_snapshot(pending_elicitation_snapshot);
4959 let cookie = login_cookie(&app).await;
4960 let response = tokio::spawn(
4961 app.oneshot(
4962 Request::post("/api/actions")
4963 .header(COOKIE, cookie)
4964 .header(CONTENT_TYPE, "application/json")
4965 .body(Body::from(
4966 r#"{"action":"respond-elicitation","session_id":"session-1","elicitation_id":"elicitation-1","response":{"action":"accept","content":{"question_0":"reusable"}}}"#,
4967 ))
4968 .unwrap(),
4969 ),
4970 );
4971 let action = actions.recv().await.unwrap();
4972 assert_eq!(
4973 action.action,
4974 ControllerAction::RespondElicitation {
4975 session_id: "session-1".into(),
4976 elicitation_id: "elicitation-1".into(),
4977 response: accept(&[("question_0", "reusable")]),
4978 }
4979 );
4980 action.reply.send(ActionOutcome::accepted()).unwrap();
4981 assert_eq!(
4982 response.await.unwrap().unwrap().status(),
4983 StatusCode::ACCEPTED
4984 );
4985 }
4986
4987 #[tokio::test]
4988 async fn elicitation_answer_for_an_unknown_request_is_refused_without_reaching_the_controller()
4989 {
4990 let (app, mut actions, _, _, _) = app_with_snapshot(pending_elicitation_snapshot);
4991 let cookie = login_cookie(&app).await;
4992 let response = app
4993 .oneshot(
4994 Request::post("/api/actions")
4995 .header(COOKIE, cookie)
4996 .header(CONTENT_TYPE, "application/json")
4997 .body(Body::from(
4998 r#"{"action":"respond-elicitation","session_id":"session-1","elicitation_id":"elicitation-9","response":{"action":"cancel"}}"#,
4999 ))
5000 .unwrap(),
5001 )
5002 .await
5003 .unwrap();
5004 assert_eq!(response.status(), StatusCode::NOT_FOUND);
5005 assert!(actions.try_recv().is_err());
5006 }
5007
5008 #[test]
5009 fn elicitation_answers_are_checked_against_the_request_the_agent_asked() {
5010 let (config, state) = sample_config_state();
5011 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
5012 pending_elicitation_snapshot(&mut snapshot);
5013 let respond = |response: ElicitationResponse| ControllerAction::RespondElicitation {
5014 session_id: "session-1".into(),
5015 elicitation_id: "elicitation-1".into(),
5016 response,
5017 };
5018
5019 assert!(validate_action(&respond(accept(&[("question_0", "matrix")])), &snapshot).is_ok());
5020 assert!(validate_action(&respond(ElicitationResponse::Decline), &snapshot).is_ok());
5023 assert!(validate_action(&respond(accept(&[("question_0", "cron")])), &snapshot).is_err());
5026 assert!(validate_action(&respond(accept(&[("smuggled", "yes")])), &snapshot).is_err());
5027 assert!(validate_action(&respond(accept(&[])), &snapshot).is_err());
5028 assert!(
5031 validate_action(
5032 &respond(accept(&[("question_0_custom", "a monorepo pipeline")])),
5033 &snapshot,
5034 )
5035 .is_ok()
5036 );
5037 }
5038
5039 #[test]
5040 fn oversized_elicitation_answers_are_refused() {
5041 let (config, state) = sample_config_state();
5042 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
5043 pending_elicitation_snapshot(&mut snapshot);
5044 let long = "x".repeat(MAX_ELICITATION_BYTES);
5045 assert!(
5046 validate_action(
5047 &ControllerAction::RespondElicitation {
5048 session_id: "session-1".into(),
5049 elicitation_id: "elicitation-1".into(),
5050 response: accept(&[("question_0_custom", long.as_str())]),
5051 },
5052 &snapshot,
5053 )
5054 .is_err()
5055 );
5056 }
5057
5058 fn viewer_source(from: &str, to: &str) -> &'static str {
5066 let start = VIEWER_JS
5067 .find(from)
5068 .unwrap_or_else(|| panic!("src/web/viewer.js no longer contains {from:?}"));
5069 let end = VIEWER_JS[start..]
5070 .find(to)
5071 .map(|offset| start + offset)
5072 .unwrap_or_else(|| {
5073 panic!("src/web/viewer.js no longer contains {to:?} after {from:?}")
5074 });
5075 &VIEWER_JS[start..end]
5076 }
5077
5078 fn run_web_check(name: &str, check: &str) {
5085 let directory = tempfile::tempdir().expect("temporary directory for a web check");
5086 for (file, source) in [
5087 ("test-dom.js", TEST_DOM_JS),
5088 ("markdown.js", MARKDOWN_JS),
5089 ("tool-output.js", TOOL_OUTPUT_JS),
5090 ] {
5091 std::fs::write(directory.path().join(file), source).expect("write a web module");
5092 }
5093 let path = directory.path().join(format!("{name}.mjs"));
5094 std::fs::write(&path, check).expect("write the web check");
5095 let output = std::process::Command::new("node")
5096 .arg(&path)
5097 .output()
5098 .expect("Node.js is required to exercise the web viewer");
5099 assert!(
5100 output.status.success(),
5101 "{name} failed:\nstdout:\n{}\nstderr:\n{}",
5102 String::from_utf8_lossy(&output.stdout),
5103 String::from_utf8_lossy(&output.stderr),
5104 );
5105 }
5106
5107 fn run_viewer_script(name: &str, script: &str) {
5111 run_web_check(name, script);
5112 }
5113
5114 #[test]
5115 fn web_configuration_repair_action_explains_missing_entries_without_a_request() {
5116 let source = viewer_source("async function runSessionAction(", "sessions.onclick");
5117 let setup = r#"
5118const pendingActions = new Set();
5119const snapshot = { sessions: [{ id: 'broken', configuration_issue: 'Restore bundle project in config.toml' }] };
5120const errorNode = { textContent: '' };
5121"#;
5122 let checks = r#"
5123await runSessionAction({ action: 'repair-config', id: 'broken' }, errorNode);
5124if (!errorNode.textContent.includes('Restore bundle project')) throw Error('repair guidance missing');
5125snapshot.sessions[0].configuration_issue = null;
5126await runSessionAction({ action: 'repair-config', id: 'broken' }, errorNode);
5127if (!errorNode.textContent.includes('repaired')) throw Error('stale configuration diagnostic');
5128"#;
5129 run_viewer_script(
5130 "configuration-repair",
5131 &format!("{setup}\n{source}\n{checks}"),
5132 );
5133 }
5134
5135 #[test]
5136 fn viewer_reports_configuration_drift_without_exposing_private_configuration() {
5137 let (mut config, state) = sample_config_state();
5138 let bundle = config.bundles.remove("hel").unwrap();
5139 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
5140 assert!(snapshot.sessions[0].has_error);
5141 assert!(
5142 snapshot.sessions[0]
5143 .configuration_issue
5144 .as_deref()
5145 .unwrap()
5146 .contains("missing bundle")
5147 );
5148 let json = serde_json::to_string(&snapshot).unwrap();
5149 assert!(!json.contains("secret-token"));
5150 assert!(!json.contains("/highly/secret"));
5151 config.bundles.insert("hel".into(), bundle);
5152 let repaired = ViewerSnapshot::from_config_state(&config, &state, 2);
5153 assert!(repaired.sessions[0].configuration_issue.is_none());
5154 }
5155
5156 #[test]
5157 fn web_preflight_applies_resolved_path_and_ignores_cancelled_reply() {
5158 let source = viewer_source(
5159 "async function preflightNew()",
5160 "async function advanceNew()",
5161 );
5162 let setup = r#"
5163let newDraft = { targetId: 'remote', profileId: 'codex', bundleId: 'bundle', projectDirectory: '~/project', projectDirectories: {} };
5164let pendingNewPreflight = null, pendingNewPreflightController = null;
5165function targetIsBare() { return true; }
5166function renderNewForm() {}
5167function selectedWorkspaceId() { return 'workspace'; }
5168let resolveRequest;
5169function request() { return new Promise(resolve => { resolveRequest = resolve; }); }
5170"#;
5171 let checks = r#"
5172let pending = preflightNew();
5173resolveRequest({ project_directory: '/remote/project' });
5174if (!await pending || newDraft.projectDirectory !== '/remote/project' || newDraft.projectDirectories.remote !== '/remote/project') throw Error('resolved path was not applied');
5175newDraft.projectDirectory = '~/newer';
5176pending = preflightNew();
5177pendingNewPreflightController.abort();
5178resolveRequest({ project_directory: '/remote/stale' });
5179if (await pending || newDraft.projectDirectory !== '~/newer') throw Error('cancelled reply replaced draft');
5180"#;
5181 run_viewer_script("path-preflight", &format!("{setup}\n{source}\n{checks}"));
5182 }
5183
5184 #[test]
5185 fn embedded_viewer_displays_capacity_retry_deadlines() {
5186 let source = viewer_source(
5187 "function sessionActivityLabel(",
5188 "function updateSessionActivity(",
5189 );
5190 let setup = "function isTransitioningSession() { return false; }";
5191 let checks = r#"
5192const session = { lifecycle: 'live', capacity_retry: { attempt: 2, retry_at_ms: 120000 } };
5193if (sessionActivityLabel(session, 60000) !== 'Model at capacity · retrying in 1m00s') throw Error('missing retry countdown');
5194if (sessionActivityLabel(session, 121000) !== 'Model at capacity · retrying in 0m00s') throw Error('negative retry countdown');
5195"#;
5196 run_viewer_script("capacity-retry", &format!("{setup}\n{source}\n{checks}"));
5197 }
5198
5199 #[test]
5200 fn embedded_viewer_lists_current_workspace_histories_and_retained_move_recovery() {
5201 let source = viewer_source("function isResumeSession(", "const resumeDrafts =");
5202 let setup = r#"
5203const snapshot = {
5204 sessions: [
5205 { id: "history-a", workspace_id: "workspace-a", capabilities: { resume: true } },
5206 { id: "history-b", workspace_id: "workspace-b", capabilities: { resume: true } },
5207 { id: "running-a", workspace_id: "workspace-a", lifecycle: "live", has_error: true, capabilities: { resume: false, open: false } },
5208 { id: "move-a", workspace_id: "workspace-a", capabilities: { resume: false }, move_recovery: { checkpoint_retained: true, phase: "failed" } },
5209 { id: "moving-a", workspace_id: "workspace-a", capabilities: { resume: false }, move_recovery: { checkpoint_retained: true, phase: "starting_queue" } },
5210 ],
5211};
5212function selectedWorkspaceId() { return "workspace-a"; }
5213function sessionActivityMs() { return 0; }
5214function epochMs() { return null; }
5215"#;
5216 let checks = r#"
5217const ids = workspace => resumeSessions(workspace).map(session => session.id).sort();
5218if (JSON.stringify(ids("workspace-a")) !== JSON.stringify(["history-a", "move-a"])) {
5219 throw new Error(`workspace A histories or recoveries were wrong: ${JSON.stringify(ids("workspace-a"))}`);
5220}
5221if (JSON.stringify(ids("workspace-b")) !== JSON.stringify(["history-b"])) {
5222 throw new Error(`workspace B histories were wrong: ${JSON.stringify(ids("workspace-b"))}`);
5223}
5224if (ids("missing-workspace").length !== 0) throw new Error("unknown workspace exposed sessions");
5225"#;
5226 run_viewer_script(
5227 "workspace-resume-history",
5228 &format!("{setup}\n{source}\n{checks}"),
5229 );
5230 }
5231
5232 #[test]
5233 fn embedded_viewer_sends_the_selected_resume_workspace() {
5234 let source = viewer_source("async function runSessionAction", "sessions.onclick =");
5235 let setup = r#"
5236const pendingActions = new Set();
5237const snapshot = { sessions: [] };
5238let sent = null;
5239function selectedWorkspaceId() { return "workspace-b"; }
5240function navigate() {}
5241function renderRoute() {}
5242async function refresh() {}
5243async function request(path, options) {
5244 sent = { path, body: JSON.parse(options.body) };
5245}
5246"#;
5247 let checks = r#"
5248const errorNode = { textContent: "" };
5249await runSessionAction(
5250 { action: "resume", id: "history-a", profile: "codex-1", target: "podman" },
5251 errorNode,
5252 { queue: "start" },
5253);
5254if (sent.path !== "/api/actions" || sent.body.workspace_id !== "workspace-b") {
5255 throw new Error(`resume did not carry its destination: ${JSON.stringify(sent)}`);
5256}
5257"#;
5258 run_viewer_script(
5259 "resume-workspace-destination",
5260 &format!("{setup}\n{source}\n{checks}"),
5261 );
5262 }
5263
5264 #[test]
5265 fn embedded_viewer_warns_before_stopping_an_active_session() {
5266 let source = viewer_source("async function runSessionAction", "sessions.onclick =");
5267 let setup = r#"
5268const pendingActions = new Set();
5269const snapshot = {
5270 sessions: [
5271 { id: "active", chat_phase: "running" },
5272 { id: "idle", chat_phase: "idle" },
5273 ],
5274};
5275const questions = [];
5276function confirm(question) { questions.push(question); return false; }
5277function navigate() {}
5278"#;
5279 let checks = r#"
5280const errorNode = { textContent: "" };
5281await runSessionAction({ action: "close", id: "active" }, errorNode);
5282await runSessionAction({ action: "close", id: "idle" }, errorNode);
5283if (!questions[0].startsWith("Stop active session?\n\n")) {
5284 throw new Error(`active close warning was ${JSON.stringify(questions[0])}`);
5285}
5286if (!questions[0].includes("current turn will be interrupted")) {
5287 throw new Error(`active close omitted interruption: ${JSON.stringify(questions[0])}`);
5288}
5289if (!questions[1].startsWith("Stop session?\n\n")) {
5290 throw new Error(`idle close warning was ${JSON.stringify(questions[1])}`);
5291}
5292"#;
5293 run_viewer_script(
5294 "active-session-stop-confirmation",
5295 &format!("{setup}\n{source}\n{checks}"),
5296 );
5297 }
5298
5299 #[test]
5304 fn the_project_key_groups_without_naming_a_path() {
5305 let (config, mut state) = sample_config_state();
5306 let first = state.sessions["session-1"].clone();
5307 let mut second = first.clone();
5308 second.id = "session-2".into();
5309 state.sessions.insert(second.id.clone(), second);
5310 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
5311
5312 let keys = snapshot
5313 .sessions
5314 .iter()
5315 .map(|session| session.project_key.as_str())
5316 .collect::<std::collections::BTreeSet<_>>();
5317 assert_eq!(keys.len(), 1, "two sessions in one project did not group");
5318 let key = keys.into_iter().next().expect("one key");
5319 assert!(!key.is_empty(), "the project key is empty");
5320 assert!(
5321 !key.contains('/') && !key.contains("hel"),
5322 "the project key leaks its identity: {key}"
5323 );
5324 assert_eq!(
5325 snapshot.sessions[0].project_label, "hel",
5326 "the project label should be a name a person recognises"
5327 );
5328 }
5329
5330 #[test]
5331 fn web_project_keys_follow_the_complete_repository_set() {
5332 let (mut config, mut state) = sample_config_state();
5333 let shared_bundle = config.bundles["hel"].clone();
5334 config.bundles.insert("other".into(), shared_bundle);
5335
5336 let mut other = state.sessions["session-1"].clone();
5337 other.id = "session-2".into();
5338 other.bundle_id = "other".into();
5339 state.sessions.insert(other.id.clone(), other);
5340
5341 assert_eq!(
5342 config.bundles["hel"].primary_repo,
5343 config.bundles["other"].primary_repo
5344 );
5345 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
5346 let first = snapshot
5347 .sessions
5348 .iter()
5349 .find(|session| session.id == "session-1")
5350 .expect("first session");
5351 let second = snapshot
5352 .sessions
5353 .iter()
5354 .find(|session| session.id == "session-2")
5355 .expect("second session");
5356
5357 assert_eq!(first.project_label, "hel");
5358 assert_eq!(second.project_label, "hel");
5359 assert_eq!(first.project_key, second.project_key);
5360
5361 let secondary = ProjectRepository {
5362 id: "secondary".into(),
5363 github: Some("owner/secondary".into()),
5364 local: None,
5365 destination: "secondary".into(),
5366 git_ref: None,
5367 };
5368 config
5369 .bundles
5370 .get_mut("other")
5371 .unwrap()
5372 .repositories
5373 .push(secondary.clone());
5374 let project_keys = |config: &Config| {
5375 ViewerSnapshot::from_config_state(config, &state, 1)
5376 .sessions
5377 .into_iter()
5378 .map(|session| session.project_key)
5379 .collect::<Vec<_>>()
5380 };
5381 let keys = project_keys(&config);
5382 assert_ne!(
5383 keys[0], keys[1],
5384 "an added repository must change the bundle identity"
5385 );
5386
5387 let first_bundle = config.bundles.get_mut("hel").unwrap();
5388 first_bundle.repositories.insert(0, secondary);
5389 first_bundle.primary_repo = "secondary".into();
5390 let keys = project_keys(&config);
5391 assert_eq!(
5392 keys[0], keys[1],
5393 "the same repository set must group together despite order or primary choice"
5394 );
5395 }
5396
5397 #[test]
5398 fn viewer_session_applies_a_resolved_source_without_publishing_it() {
5399 let (config, state) = sample_config_state();
5400 let mut viewer = ViewerSnapshot::from_config_state(&config, &state, 1)
5401 .sessions
5402 .into_iter()
5403 .next()
5404 .expect("session");
5405 let source = ProjectSourceIdentity::git_remote("git@github.com:BrokkAi/bifrost-dev.git")
5406 .expect("GitHub source");
5407
5408 viewer.set_project_source(&source);
5409
5410 assert_eq!(viewer.project_label, "bifrost-dev");
5411 assert_eq!(viewer.project_key, project_key(&source.key));
5412 let json = serde_json::to_string(&viewer).expect("serialize viewer session");
5413 assert!(!json.contains("BrokkAi"));
5414 assert!(!json.contains("github.com"));
5415 }
5416
5417 #[test]
5420 fn lifecycle_categories_decide_what_the_dashboard_shows() {
5421 use ViewerLifecycleCategory::{Failed, Live, Starting, Stopped, Stopping};
5422
5423 for (state, expected, on_dashboard) in [
5424 (SessionState::Provisioning, Starting, true),
5425 (SessionState::Running, Live, true),
5426 (SessionState::Disconnected, Live, true),
5427 (SessionState::Checkpointing, Live, true),
5428 (SessionState::Closing, Stopping, true),
5429 (SessionState::Destroying, Stopping, true),
5430 (SessionState::Stopped, Stopped, false),
5431 (SessionState::Lost, Failed, false),
5432 (SessionState::Error, Failed, false),
5433 (SessionState::DestroyedWithDataLoss, Failed, false),
5434 ] {
5435 let category = ViewerLifecycleCategory::of(state);
5436 assert_eq!(category, expected, "{state:?}");
5437 assert_eq!(
5438 category.is_dashboard_visible(),
5439 on_dashboard,
5440 "{state:?} belongs on the dashboard? "
5441 );
5442 }
5443 }
5444
5445 #[test]
5449 fn compatible_resume_targets_are_the_complement_of_the_incompatible_ones() {
5450 let (config, state) = sample_config_state();
5451 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
5452 let session = &snapshot.sessions[0];
5453 let all = config.targets.keys().cloned().collect::<Vec<_>>();
5454
5455 for target in &all {
5456 assert_ne!(
5457 session.compatible_resume_targets.contains(target),
5458 session.incompatible_resume_targets.contains(target),
5459 "target {target} is in both lists or neither"
5460 );
5461 }
5462 assert_eq!(
5463 session.compatible_resume_targets.len() + session.incompatible_resume_targets.len(),
5464 all.len(),
5465 "the two lists do not cover every target"
5466 );
5467 }
5468
5469 #[tokio::test]
5473 async fn actions_are_refused_when_their_capability_is_false() {
5474 for (body, capability) in [
5475 (
5476 r#"{"action":"cancel-turn","session_id":"session-1"}"#,
5477 "cancel_turn",
5478 ),
5479 (
5480 r#"{"action":"set-plan-mode","session_id":"session-1","active":true}"#,
5481 "set_plan_mode",
5482 ),
5483 (
5484 r#"{"action":"set-config","session_id":"session-1","key":"model","value":"x"}"#,
5485 "set_config",
5486 ),
5487 ] {
5488 let (app, mut actions, _, _, _) = app();
5489 let response = post_action(app, cookie(), body.to_owned()).await;
5490 assert!(
5491 response.status().is_client_error(),
5492 "{capability} was accepted while false: {}",
5493 response.status()
5494 );
5495 assert!(
5496 actions.try_recv().is_err(),
5497 "{capability} reached the controller while false"
5498 );
5499 }
5500 }
5501
5502 #[tokio::test]
5505 async fn a_config_key_the_harness_never_advertised_is_refused() {
5506 let capable = |snapshot: &mut ViewerSnapshot| {
5507 snapshot.sessions[0].capabilities.set_config = true;
5508 snapshot.sessions[0].config_options = vec![ViewerConfigOption {
5509 key: "model".into(),
5510 label: "model".into(),
5511 current: None,
5512 choices: vec![ViewerConfigChoice {
5513 value: "sonnet".into(),
5514 name: "Sonnet".into(),
5515 description: None,
5516 }],
5517 }];
5518 };
5519
5520 for (body, why) in [
5521 (
5522 r#"{"action":"set-config","session_id":"session-1","key":"effort","value":"high"}"#,
5523 "an unadvertised key",
5524 ),
5525 (
5526 r#"{"action":"set-config","session_id":"session-1","key":"model","value":"gpt-9"}"#,
5527 "an unoffered value",
5528 ),
5529 ] {
5530 let (app, mut actions, _, _, _) = app_with_snapshot(capable);
5531 let response = post_action(app, cookie(), body.to_owned()).await;
5532 assert_eq!(
5533 response.status(),
5534 StatusCode::BAD_REQUEST,
5535 "{why} was accepted"
5536 );
5537 assert!(actions.try_recv().is_err(), "{why} reached the controller");
5538 }
5539
5540 let (app, mut actions, _, _, _) = app_with_snapshot(capable);
5542 let response = tokio::spawn(post_action(
5543 app,
5544 cookie(),
5545 r#"{"action":"set-config","session_id":"session-1","key":"model","value":"sonnet"}"#
5546 .to_owned(),
5547 ));
5548 let action = actions
5549 .recv()
5550 .await
5551 .expect("the action reached the controller");
5552 assert!(
5553 matches!(
5554 action.action,
5555 ControllerAction::SetConfig { ref key, ref value, .. }
5556 if key == "model" && value == "sonnet"
5557 ),
5558 "the advertised value was not forwarded unchanged"
5559 );
5560 action.reply.send(ActionOutcome::accepted()).unwrap();
5561 assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
5562 }
5563
5564 #[tokio::test]
5567 async fn a_dirty_acknowledgement_is_bounded_and_names_repositories() {
5568 let oversized = (0..40)
5569 .map(|index| format!(r#""repo-{index}""#))
5570 .collect::<Vec<_>>()
5571 .join(",");
5572 for (ack, why) in [
5573 (oversized.as_str(), "an unbounded acknowledgement"),
5574 (r#""""#, "an empty repository name"),
5575 ] {
5576 let (app, mut actions, _, _, _) = app();
5577 let body = format!(
5578 r#"{{"action":"new","workspace_id":"default","profile_id":"codex-1","bundle_id":"hel","target_id":"podman","dirty_ack":[{ack}]}}"#
5579 );
5580 let response = post_action(app, cookie(), body).await;
5581 assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{why}");
5582 assert!(actions.try_recv().is_err(), "{why} reached the controller");
5583 }
5584 }
5585
5586 #[tokio::test]
5589 async fn a_new_session_without_a_title_is_accepted() {
5590 let (app, mut actions, _, _, _) = app();
5591 let response = tokio::spawn(post_action(
5592 app,
5593 cookie(),
5594 r#"{"action":"new","workspace_id":"default","profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#
5595 .to_owned(),
5596 ));
5597 let action = actions
5600 .recv()
5601 .await
5602 .expect("the action reached the controller");
5603 assert!(
5604 matches!(
5605 action.action,
5606 ControllerAction::New { title: None, ref workspace_id, .. }
5607 if workspace_id == "default"
5608 ),
5609 "the workspace or the absent title did not survive the boundary"
5610 );
5611 action.reply.send(ActionOutcome::accepted()).unwrap();
5612 assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
5613 }
5614
5615 #[test]
5619 fn a_cookie_names_one_viewer_and_two_cookies_never_collide() {
5620 let key = b"01234567890123456789012345678901";
5621 let expiry = now_unix().saturating_add(3600);
5622 let first = signed_cookie_value(key, "viewer-a", expiry);
5623 let second = signed_cookie_value(key, "viewer-b", expiry);
5624 assert_ne!(
5625 first, second,
5626 "two viewers unlocking in the same second share a cookie"
5627 );
5628 assert_eq!(
5629 cookie_viewer(key, &first, now_unix()),
5630 Some(Some("viewer-a".to_owned()))
5631 );
5632 assert_eq!(
5633 cookie_viewer(key, &second, now_unix()),
5634 Some(Some("viewer-b".to_owned()))
5635 );
5636 }
5637
5638 #[test]
5642 fn a_legacy_cookie_still_authenticates_and_stores_nothing() {
5643 let key = b"01234567890123456789012345678901";
5644 let expiry = now_unix().saturating_add(3600);
5645 let legacy = legacy_signed_cookie_value(key, expiry);
5646 assert_eq!(cookie_viewer(key, &legacy, now_unix()), Some(None));
5647 assert!(session_cookie_valid(key, &legacy, now_unix()));
5648 assert!(
5649 !session_cookie_valid(key, &legacy, expiry),
5650 "an expired legacy cookie still authenticated"
5651 );
5652 }
5653
5654 #[test]
5656 fn a_tampered_cookie_is_refused() {
5657 let key = b"01234567890123456789012345678901";
5658 let expiry = now_unix().saturating_add(3600);
5659 let honest = signed_cookie_value(key, "viewer-a", expiry);
5660 let swapped = honest.replacen("viewer-a", "viewer-b", 1);
5661 assert_eq!(cookie_viewer(key, &swapped, now_unix()), None);
5662 assert_eq!(cookie_viewer(key, "nonsense", now_unix()), None);
5663 assert_eq!(cookie_viewer(key, &format!("{expiry}."), now_unix()), None);
5664 }
5665
5666 #[tokio::test]
5669 async fn an_oversized_draft_is_refused_with_a_stable_code() {
5670 let (app, _, _, _, mut stored) = app();
5671 let draft = "x".repeat(64 * 1024 + 1);
5672 let response = app
5673 .oneshot(
5674 Request::put("/api/sessions/session-1/draft")
5675 .header(COOKIE, cookie())
5676 .header(CONTENT_TYPE, "application/json")
5677 .body(Body::from(
5678 serde_json::json!({ "draft": draft }).to_string(),
5679 ))
5680 .unwrap(),
5681 )
5682 .await
5683 .unwrap();
5684 assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
5685 assert!(stored.try_recv().is_err(), "an oversized draft was stored");
5686 }
5687
5688 #[tokio::test]
5691 async fn a_legacy_viewer_reads_empty_state_and_cannot_store_a_draft() {
5692 let key = b"01234567890123456789012345678901";
5693 let legacy = format!(
5694 "{COOKIE_NAME}={}",
5695 legacy_signed_cookie_value(key, now_unix().saturating_add(3600))
5696 );
5697
5698 let (reader, _, _, _, mut stored) = app();
5699 let response = reader
5700 .oneshot(
5701 Request::get("/api/sessions/session-1/client-state")
5702 .header(COOKIE, legacy.clone())
5703 .body(Body::empty())
5704 .unwrap(),
5705 )
5706 .await
5707 .unwrap();
5708 assert_eq!(response.status(), StatusCode::OK);
5709 let body = response.into_body().collect().await.unwrap().to_bytes();
5710 let state: ViewerClientState = serde_json::from_slice(&body).unwrap();
5711 assert_eq!(state, ViewerClientState::default());
5712 assert!(
5713 stored.try_recv().is_err(),
5714 "a legacy viewer read stored state"
5715 );
5716
5717 let (writer, _, _, _, mut stored) = app();
5718 let response = writer
5719 .oneshot(
5720 Request::put("/api/sessions/session-1/draft")
5721 .header(COOKIE, legacy)
5722 .header(CONTENT_TYPE, "application/json")
5723 .body(Body::from(r#"{"draft":"text"}"#))
5724 .unwrap(),
5725 )
5726 .await
5727 .unwrap();
5728 assert_eq!(response.status(), StatusCode::CONFLICT);
5729 assert!(stored.try_recv().is_err(), "a legacy viewer stored a draft");
5730 }
5731
5732 #[tokio::test]
5734 async fn prompt_history_refuses_an_unknown_scope() {
5735 let (app, _, _, _, mut stored) = app();
5736 let response = app
5737 .oneshot(
5738 Request::get("/api/sessions/session-1/history?q=ship&scope=everything")
5739 .header(COOKIE, cookie())
5740 .body(Body::empty())
5741 .unwrap(),
5742 )
5743 .await
5744 .unwrap();
5745 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
5746 assert!(
5747 stored.try_recv().is_err(),
5748 "the search reached the controller"
5749 );
5750 }
5751
5752 fn new_preflight(request: PreflightRequest) -> NewPreflightRequest {
5758 match request {
5759 PreflightRequest::New(request) => request,
5760 other => panic!("expected a new-session preflight, got {other:?}"),
5761 }
5762 }
5763
5764 #[tokio::test]
5765 async fn a_preflight_validates_before_it_reaches_the_controller() {
5766 for (body, why) in [
5767 (
5768 r#"{"profile_id":"nope","bundle_id":"hel","target_id":"podman"}"#,
5769 "an unknown profile",
5770 ),
5771 (
5772 r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw"}"#,
5773 "a bare target with no directory",
5774 ),
5775 ] {
5776 let (app, _, _, mut preflights, _) = app();
5777 let response = app
5778 .oneshot(
5779 Request::post("/api/preflight/new")
5780 .header(COOKIE, cookie())
5781 .header(CONTENT_TYPE, "application/json")
5782 .body(Body::from(body))
5783 .unwrap(),
5784 )
5785 .await
5786 .unwrap();
5787 assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{why}");
5788 assert!(
5789 preflights.try_recv().is_err(),
5790 "{why} reached the controller"
5791 );
5792 }
5793 }
5794
5795 #[tokio::test]
5799 async fn a_bare_preflight_forwards_directory_validation_to_the_controller() {
5800 let (app, _, _, mut preflights, _) = app();
5801 let response = tokio::spawn(app.oneshot(
5802 Request::post("/api/preflight/new")
5803 .header(COOKIE, cookie())
5804 .header(CONTENT_TYPE, "application/json")
5805 .body(Body::from(
5806 r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw","project_directory":"~/project"}"#,
5807 ))
5808 .unwrap(),
5809 ));
5810 let request = new_preflight(preflights.recv().await.expect("the controller was asked"));
5811 assert_eq!(request.bundle_id, "hel");
5812 assert_eq!(request.target_id, "raw");
5813 assert_eq!(request.project_directory, Some(PathBuf::from("~/project")));
5814 request
5815 .reply
5816 .send(Ok(PreflightNew {
5817 managed_worktree: Default::default(),
5818 project_directory: Some("/remote/project".into()),
5819 remote_repairs: Vec::new(),
5820 dirty_repositories: Vec::new(),
5821 remote_repositories: Vec::new(),
5822 local_changes_excluded: false,
5823 }))
5824 .unwrap();
5825 let response = response.await.unwrap().unwrap();
5826 assert_eq!(response.status(), StatusCode::OK);
5827 let body = response.into_body().collect().await.unwrap().to_bytes();
5828 let answer: PreflightNew = serde_json::from_slice(&body).unwrap();
5829 assert!(answer.dirty_repositories.is_empty());
5830 assert_eq!(answer.project_directory, Some("/remote/project".into()));
5831 }
5832
5833 #[tokio::test]
5836 async fn a_resume_preflight_returns_the_conversion_preview_it_was_given() {
5837 let (app, _, _, mut preflights, _) = app();
5838 let response = tokio::spawn(
5839 app.oneshot(
5840 Request::post("/api/preflight/resume")
5841 .header(COOKIE, cookie())
5842 .header(CONTENT_TYPE, "application/json")
5843 .body(Body::from(
5844 r#"{"session_id":"session-1","target_id":"podman"}"#,
5845 ))
5846 .unwrap(),
5847 ),
5848 );
5849 let request = preflights.recv().await.expect("the controller was asked");
5850 let PreflightRequest::Resume(request) = request else {
5851 panic!("expected a resume preflight");
5852 };
5853 assert_eq!(request.session_id, "session-1");
5854 assert_eq!(request.target_id, "podman");
5855 request
5856 .reply
5857 .send(Ok(PreflightResume::ConvertingRawCheckout {
5858 preview: Box::new(mj_core::state::RawConversionPreview {
5859 checkout: "/work/repo".into(),
5860 destination: "/workspace/repo".into(),
5861 branch: Some("mj/session-1".into()),
5862 fetch_url: "https://github.com/example/repo.git".into(),
5863 push_urls: Vec::new(),
5864 default_branch: "main".into(),
5865 unpushed_commits: 1,
5866 staged_files: 0,
5867 unstaged_files: 1,
5868 untracked_files: 0,
5869 untracked_bytes: 0,
5870 host_checkout_retained: true,
5871 }),
5872 }))
5873 .unwrap();
5874 let response = response.await.unwrap().unwrap();
5875 assert_eq!(response.status(), StatusCode::OK);
5876 let body = response.into_body().collect().await.unwrap().to_bytes();
5877 let answer: serde_json::Value = serde_json::from_slice(&body).unwrap();
5878 assert_eq!(answer["kind"], "converting-raw-checkout");
5879 assert_eq!(answer["preview"]["branch"], "mj/session-1");
5880 assert_eq!(answer["preview"]["host_checkout_retained"], true);
5881 }
5882
5883 #[tokio::test]
5885 async fn a_resume_preflight_for_an_unknown_session_is_refused_without_the_controller() {
5886 let (app, _, _, mut preflights, _) = app();
5887 let response = app
5888 .oneshot(
5889 Request::post("/api/preflight/resume")
5890 .header(COOKIE, cookie())
5891 .header(CONTENT_TYPE, "application/json")
5892 .body(Body::from(
5893 r#"{"session_id":"missing","target_id":"podman"}"#,
5894 ))
5895 .unwrap(),
5896 )
5897 .await
5898 .unwrap();
5899 assert_eq!(response.status(), StatusCode::NOT_FOUND);
5900 assert!(preflights.try_recv().is_err());
5901 }
5902
5903 #[tokio::test]
5904 async fn a_bare_preflight_validation_failure_is_actionable_without_its_details() {
5905 let (app, _, _, mut preflights, _) = app();
5906 let response = tokio::spawn(app.oneshot(
5907 Request::post("/api/preflight/new")
5908 .header(COOKIE, cookie())
5909 .header(CONTENT_TYPE, "application/json")
5910 .body(Body::from(
5911 r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw","project_directory":"/private/project"}"#,
5912 ))
5913 .unwrap(),
5914 ));
5915 let request = new_preflight(preflights.recv().await.expect("the controller was asked"));
5916 request
5917 .reply
5918 .send(Err(PreflightFailure::Validation))
5919 .unwrap();
5920 let response = response.await.unwrap().unwrap();
5921 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
5922 let body = response.into_body().collect().await.unwrap().to_bytes();
5923 assert_eq!(
5924 serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
5925 serde_json::json!({
5926 "error": "project validation failed; check that the directory exists, is accessible, and contains a Git repository with a valid HEAD"
5927 })
5928 );
5929 assert!(!String::from_utf8_lossy(&body).contains("/private/project"));
5930 }
5931
5932 #[tokio::test]
5933 async fn a_bundle_preflight_controller_failure_keeps_the_generic_service_error() {
5934 let (app, _, _, mut preflights, _) = app();
5935 let response = tokio::spawn(
5936 app.oneshot(
5937 Request::post("/api/preflight/new")
5938 .header(COOKIE, cookie())
5939 .header(CONTENT_TYPE, "application/json")
5940 .body(Body::from(
5941 r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#,
5942 ))
5943 .unwrap(),
5944 ),
5945 );
5946 let request = new_preflight(preflights.recv().await.expect("the controller was asked"));
5947 request
5948 .reply
5949 .send(Err(PreflightFailure::Controller(
5950 "private /source/hel details".into(),
5951 )))
5952 .unwrap();
5953 let response = response.await.unwrap().unwrap();
5954 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
5955 let body = response.into_body().collect().await.unwrap().to_bytes();
5956 assert_eq!(
5957 serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
5958 serde_json::json!({"error": "the controller could not check this project"})
5959 );
5960 assert!(!String::from_utf8_lossy(&body).contains("/source/hel"));
5961 }
5962
5963 #[tokio::test]
5966 async fn a_bundle_preflight_reports_network_sources_and_excludes_local_changes() {
5967 let (app, _, _, mut preflights, _) = app();
5968 let response = tokio::spawn(
5969 app.oneshot(
5970 Request::post("/api/preflight/new")
5971 .header(COOKIE, cookie())
5972 .header(CONTENT_TYPE, "application/json")
5973 .body(Body::from(
5974 r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#,
5975 ))
5976 .unwrap(),
5977 ),
5978 );
5979 let request = new_preflight(preflights.recv().await.expect("the controller was asked"));
5980 assert_eq!(request.bundle_id, "hel");
5981 assert_eq!(request.target_id, "podman");
5982 assert_eq!(request.project_directory, None);
5983 request
5984 .reply
5985 .send(Ok(PreflightNew {
5986 managed_worktree: Default::default(),
5987 project_directory: None,
5988 remote_repairs: Vec::new(),
5989 dirty_repositories: Vec::new(),
5990 remote_repositories: vec![PreflightRepository {
5991 id: "hel".into(),
5992 fetch_url: "https://github.com/example/hel.git".into(),
5993 default_branch: "main".into(),
5994 push_urls: vec!["ssh://git@example/hel.git".into()],
5995 }],
5996 local_changes_excluded: true,
5997 }))
5998 .unwrap();
5999 let response = response.await.unwrap().unwrap();
6000 assert_eq!(response.status(), StatusCode::OK);
6001 let body = response.into_body().collect().await.unwrap().to_bytes();
6002 let answer: PreflightNew = serde_json::from_slice(&body).unwrap();
6003 assert!(answer.dirty_repositories.is_empty());
6004 assert!(answer.local_changes_excluded);
6005 assert_eq!(answer.remote_repositories[0].default_branch, "main");
6006 assert_eq!(answer.remote_repositories[0].push_urls.len(), 1);
6007 }
6008
6009 #[test]
6014 fn the_markdown_renderer_builds_structure_and_refuses_injection() {
6015 run_web_check(
6016 "markdown",
6017 r#"import { installDocument, elements, only, check, checkEqual } from './test-dom.js';
6018installDocument();
6019const { renderMarkdown, renderDiffSummary, safeHref } = await import('./markdown.js');
6020
6021const render = source => {
6022 const host = document.createElement('section');
6023 host.append(renderMarkdown(source));
6024 return host;
6025};
6026
6027// Headings
6028checkEqual(only(render('# Title'), 'h1').textContent, 'Title', 'h1');
6029checkEqual(only(render('### Deep'), 'h3').textContent, 'Deep', 'h3');
6030
6031// Nested lists
6032const nested = render('- one\n - inner\n- two');
6033check(elements(nested, 'ul').length === 2, 'nested list produced ' + elements(nested, 'ul').length + ' lists');
6034check(elements(elements(nested, 'ul')[0], 'li').length >= 2, 'outer list lost items');
6035
6036// Ordered lists
6037checkEqual(elements(render('1. a\n2. b'), 'ol').length, 1, 'ordered list');
6038
6039// Fenced code stays unparsed
6040const fenced = render('```rust\nlet x = *y*;\n```');
6041checkEqual(only(fenced, 'code').textContent, 'let x = *y*;', 'fenced code');
6042check(elements(fenced, 'span').some(s => s.className === 'tok-kw'), 'fenced rust untinted');
6043checkEqual(elements(fenced, 'em').length, 0, 'fence emphasised its contents');
6044checkEqual(only(fenced, 'pre').dataset.lang, 'rust', 'fence language');
6045
6046// Inline code beats emphasis
6047checkEqual(only(render('`*not em*`'), 'code').textContent, '*not em*', 'inline code');
6048checkEqual(elements(render('`*not em*`'), 'em').length, 0, 'inline code emphasised');
6049
6050// Emphasis
6051checkEqual(only(render('**bold**'), 'strong').textContent, 'bold', 'strong');
6052checkEqual(only(render('*it*'), 'em').textContent, 'it', 'em');
6053checkEqual(only(render('~~gone~~'), 'del').textContent, 'gone', 'del');
6054
6055// Tables
6056const table = render('| a | b |\n| --- | ---: |\n| 1 | 2 |');
6057checkEqual(elements(table, 'table').length, 1, 'table');
6058checkEqual(elements(table, 'th').length, 2, 'table header cells');
6059checkEqual(elements(table, 'td').length, 2, 'table body cells');
6060checkEqual(elements(table, 'th')[1].className, 'align-right', 'table alignment class');
6061checkEqual(only(table, 'div').className, 'scroll-x', 'table scroll wrapper');
6062
6063// Blockquote and rule
6064checkEqual(elements(render('> quoted'), 'blockquote').length, 1, 'blockquote');
6065checkEqual(elements(render('---'), 'hr').length, 1, 'rule');
6066
6067// XSS: markup is text, never elements
6068const injected = render('<img src=x onerror=alert(1)>');
6069checkEqual(elements(injected, 'img').length, 0, 'raw HTML became an element');
6070check(injected.textContent.includes('<img src=x onerror=alert(1)>'), 'raw HTML lost its text');
6071
6072// XSS: refused link schemes
6073for (const target of ['javascript:alert(1)', 'JaVaScRiPt:alert(1)', 'java\tscript:alert(1)', 'data:text/html,<script>', 'vbscript:x']) {
6074 const out = render(`[click](${target})`);
6075 checkEqual(elements(out, 'a').length, 0, `link scheme ${JSON.stringify(target)} was allowed`);
6076 check(out.textContent.includes('click'), `link scheme ${JSON.stringify(target)} lost its label`);
6077}
6078
6079// Accepted schemes keep their href and carry safe rel/target
6080for (const target of ['https://example.com', 'http://example.com/a', 'mailto:someone@example.com']) {
6081 const anchor = only(render(`[click](${target})`), 'a');
6082 checkEqual(anchor.getAttribute('href'), target, 'href');
6083 checkEqual(anchor.getAttribute('rel'), 'noreferrer noopener', 'rel');
6084 checkEqual(anchor.getAttribute('target'), '_blank', 'target');
6085}
6086
6087// safeHref directly
6088checkEqual(safeHref('javascript:alert(1)'), null, 'safeHref allowed javascript:');
6089checkEqual(safeHref(' https://x.test '), 'https://x.test', 'safeHref cleaned value');
6090
6091// Inline markup inside a link label
6092checkEqual(only(render('[**bold link**](https://x.test)'), 'strong').textContent, 'bold link', 'link label markup');
6093
6094// An unclosed delimiter is literal, not markup
6095checkEqual(render('a * b').textContent, 'a * b', 'unclosed emphasis');
6096checkEqual(elements(render('a * b'), 'em').length, 0, 'unclosed emphasis made an element');
6097
6098// Diff summaries: the real format from format_diffstat, two spaces and U+2212
6099const diff = renderDiffSummary(['src/main.rs +12 −3', 'unparseable line']);
6100const items = elements(diff, 'li');
6101checkEqual(items.length, 2, 'diffstat rows');
6102checkEqual(elements(items[0], 'span')[0].textContent, 'src/main.rs', 'diffstat path');
6103checkEqual(elements(items[0], 'span')[1].textContent, '+12', 'diffstat additions');
6104checkEqual(elements(items[0], 'span')[2].textContent, '−3', 'diffstat deletions');
6105checkEqual(elements(items[1], 'span').length, 1, 'unparseable diffstat produced counts');
6106checkEqual(elements(items[1], 'span')[0].textContent, 'unparseable line', 'unparseable diffstat lost its text');
6107
6108console.log('all markdown checks passed');
6109"#,
6110 );
6111 }
6112
6113 #[test]
6118 fn tool_output_is_tinted_folded_and_never_read_as_markdown() {
6119 run_web_check(
6120 "tool-output",
6121 r#"import { installDocument, elements, only, check, checkEqual, openFold } from './test-dom.js';
6122installDocument();
6123const { renderToolOutput, codeBlock, detectLang, appendCommandTokens, isPathLike } = await import(
6124 './tool-output.js'
6125);
6126
6127const classes = root => elements(root, 'span').map(s => s.className);
6128
6129// A shell command is told apart into program, subcommand, flag and path.
6130const line = document.createElement('pre');
6131appendCommandTokens(line, 'cargo test --workspace src/lib.rs');
6132const seen = classes(line);
6133check(seen.includes('cmd-program'), 'no program: ' + seen);
6134check(seen.includes('cmd-subcommand'), 'no subcommand: ' + seen);
6135check(seen.includes('cmd-flag'), 'no flag: ' + seen);
6136check(seen.includes('cmd-path'), 'no path: ' + seen);
6137checkEqual(line.textContent, 'cargo test --workspace src/lib.rs', 'command text changed');
6138
6139// An operator starts the program count again, so both programs are found.
6140const piped = document.createElement('pre');
6141appendCommandTokens(piped, 'git status && cargo build');
6142checkEqual(classes(piped).filter(c => c === 'cmd-program').length, 2, 'pipeline reset');
6143
6144// Prose with a slash is not a path; a real path is.
6145check(!isPathLike('and/or'), '"and/or" read as a path');
6146check(isPathLike('src/lib/thing.rs'), 'a real path did not');
6147check(isPathLike('./x'), 'a relative path did not');
6148check(isPathLike('Cargo.toml'), 'a file with an extension did not');
6149
6150// JSON is pretty-printed and tinted, keys apart from values.
6151const json = renderToolOutput('{"name":"hel","count":3,"ok":true}');
6152const jsonClasses = classes(json);
6153check(jsonClasses.includes('tok-key'), 'no JSON key: ' + jsonClasses);
6154check(jsonClasses.includes('tok-str'), 'no JSON string: ' + jsonClasses);
6155check(jsonClasses.includes('tok-num'), 'no JSON number: ' + jsonClasses);
6156check(jsonClasses.includes('tok-kw'), 'no JSON keyword: ' + jsonClasses);
6157check(json.textContent.includes('"name"'), 'JSON lost its content');
6158
6159// Rust is tinted; an unknown language is not.
6160const rust = codeBlock('pub fn main() {\n let x = 1;\n}', 'rust');
6161check(classes(rust).includes('tok-kw'), 'rust keywords untinted');
6162checkEqual(only(rust, 'pre').dataset.lang, 'rust', 'rust data-lang');
6163const plain = codeBlock('nothing in particular here', 'brainfuck');
6164checkEqual(classes(plain).length, 0, 'unknown language was tinted');
6165
6166// Sniffing is conservative: a log stays plain, real code does not.
6167checkEqual(detectLang('12:03 INFO started\n12:04 INFO done\n12:05 INFO stopped'), '', 'a log was sniffed');
6168checkEqual(
6169 detectLang('fn a() {}\nfn b() {}\nlet mut x = 1;\nuse std::fmt;\nimpl Foo {}\nlet y = x.unwrap();'),
6170 'rust',
6171 'rust was not sniffed',
6172);
6173checkEqual(detectLang('--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+new'), 'diff', 'diff was not sniffed');
6174
6175// A long dump is one closed fold that has built nothing yet.
6176const long = Array.from({ length: 400 }, (_, i) => `line ${i}`).join('\n');
6177const folded = renderToolOutput(long);
6178checkEqual(folded.nodeName, 'DETAILS', 'a 400-line dump was not folded');
6179checkEqual(elements(folded, 'pre').length, 0, 'a closed fold built its content anyway');
6180check(only(folded, 'summary').textContent.includes('400 lines'), 'fold summary: ' + only(folded, 'summary').textContent);
6181openFold(folded);
6182checkEqual(elements(folded, 'pre').length, 1, 'an opened fold built nothing');
6183check(elements(folded, 'pre')[0].textContent.includes('line 399'), 'the fold lost its content');
6184
6185// Opening twice builds once.
6186openFold(folded);
6187checkEqual(elements(folded, 'pre').length, 1, 'reopening rebuilt the content');
6188
6189// A short dump is not folded.
6190checkEqual(renderToolOutput('one\ntwo').nodeName, 'PRE', 'a short dump was folded');
6191
6192// Tool output is never parsed as Markdown, so an underscore is an underscore.
6193const literal = renderToolOutput('a _b_ c <img src=x>');
6194checkEqual(elements(literal, 'em').length, 0, 'tool output was emphasised');
6195checkEqual(elements(literal, 'img').length, 0, 'tool output produced an element');
6196check(literal.textContent.includes('<img src=x>'), 'tool output lost its text');
6197
6198console.log('all tool-output checks passed');
6199"#,
6200 );
6201 }
6202
6203 #[test]
6210 fn no_web_module_builds_markup_from_a_string() {
6211 const SINKS: [&str; 5] = [
6212 "innerHTML",
6213 "outerHTML",
6214 "insertAdjacentHTML",
6215 "document.write",
6216 "new Function",
6217 ];
6218 const ALLOWED: [(&str, &str); 0] = [];
6221 for (name, source) in [
6222 ("viewer.js", VIEWER_JS),
6223 ("markdown.js", MARKDOWN_JS),
6224 ("tool-output.js", TOOL_OUTPUT_JS),
6225 ] {
6226 for (number, line) in source.lines().enumerate() {
6227 let trimmed = line.trim();
6228 if trimmed.starts_with("//") || trimmed.starts_with("///") {
6229 continue;
6230 }
6231 for sink in SINKS {
6232 if !trimmed.contains(sink) {
6233 continue;
6234 }
6235 assert!(
6236 ALLOWED
6237 .iter()
6238 .any(|(file, allowed)| *file == name && trimmed == *allowed),
6239 "{name}:{} builds markup from a string: {trimmed}",
6240 number + 1
6241 );
6242 }
6243 }
6244 }
6245 }
6246
6247 #[test]
6251 fn embedded_viewer_keeps_elicitation_answers_across_snapshot_polls() {
6252 let source = viewer_source(
6253 "const elicitationCards = new Map()",
6254 "async function submitElicitation",
6255 );
6256 let dom = r#"
6257let replaceCalls = 0;
6258function makeEl(tag) {
6259 return {
6260 tagName: tag.toUpperCase(),
6261 children: [],
6262 options: [],
6263 selectedOptions: [],
6264 className: "",
6265 textContent: "",
6266 disabled: false,
6267 required: false,
6268 value: "",
6269 appendChild(child) {
6270 this.children.push(child);
6271 if (this.tagName === "SELECT") this.options.push(child);
6272 return child;
6273 },
6274 append(...kids) {
6275 this.children.push(...kids);
6276 },
6277 replaceChildren(...kids) {
6278 replaceCalls += 1;
6279 this.children = kids;
6280 },
6281 addEventListener() {},
6282 querySelectorAll(selector) {
6283 const found = [];
6284 const visit = node => {
6285 for (const child of node.children) {
6286 if (child.tagName === "INPUT" && (selector === "input" || child.checked)) found.push(child);
6287 visit(child);
6288 }
6289 };
6290 visit(this);
6291 return found;
6292 },
6293 querySelector(selector) { return this.querySelectorAll(selector)[0] || null; },
6294 setCustomValidity() {},
6295 reportValidity() {
6296 return true;
6297 },
6298 };
6299}
6300const created = [];
6301const document = {
6302 createElement(tag) {
6303 const el = makeEl(tag);
6304 created.push(el);
6305 return el;
6306 },
6307};
6308const elicitations = makeEl("div");
6309function el(tag, className, text) {
6310 const node = document.createElement(tag);
6311 node.className = className || "";
6312 node.textContent = text || "";
6313 return node;
6314}
6315async function submitElicitation() {}
6316"#;
6317 let checks = r#"
6318const request = {
6319 id: "elicitation-1",
6320 message: "Which CI architecture?",
6321 title: "CI",
6322 fields: [
6323 {
6324 id: "question_0",
6325 title: "CI architecture",
6326 required: false,
6327 kind: "single_select",
6328 options: [{ value: "reusable", title: "Reusable" }, { value: "matrix", title: "Matrix" }],
6329 },
6330 { id: "question_0_custom", title: "Other", required: false, kind: "text" },
6331 ],
6332};
6333const session = { id: "session-1", pending_elicitations: [request] };
6334renderElicitations(session);
6335const card = elicitations.children[0];
6336const radio = created.find((el) => el.tagName === "INPUT" && el.value === "reusable");
6337const text = created.find((el) => el.tagName === "INPUT" && el.type === "text");
6338radio.checked = true;
6339text.value = "keep me";
6340const attachments = replaceCalls;
6341renderElicitations(session);
6342if (elicitations.children[0] !== card) {
6343 throw new Error("a snapshot rebuilt the pending card");
6344}
6345if (!radio.checked || text.value !== "keep me") {
6346 throw new Error("a snapshot wiped the half-filled answer");
6347}
6348if (replaceCalls !== attachments) {
6349 throw new Error("a snapshot re-attached an unchanged card and dropped focus");
6350}
6351sentElicitations.add(elicitationKey("session-1", request.id));
6352renderElicitations(session);
6353if (elicitations.children[0] !== card) {
6354 throw new Error("a sent answer rebuilt the card");
6355}
6356if (!radio.disabled || !text.disabled) {
6357 throw new Error("a sent answer left the controls live");
6358}
6359if (!radio.checked) {
6360 throw new Error("a sent answer wiped the reply");
6361}
6362renderElicitations({ id: "session-1", pending_elicitations: [] });
6363if (elicitations.children.length !== 0 || elicitationCards.size !== 0) {
6364 throw new Error("an answered request stayed rendered");
6365}
6366if (sentElicitations.size !== 0) {
6367 throw new Error("a resolved request kept its sent marker");
6368}
6369"#;
6370 run_viewer_script(
6371 "elicitation-rendering",
6372 &format!("{dom}\n{source}\n{checks}"),
6373 );
6374 }
6375
6376 fn sample_image(pixels: usize) -> ViewerPromptImage {
6377 ViewerPromptImage {
6378 data_base64: base64::engine::general_purpose::STANDARD.encode(vec![7_u8; pixels]),
6379 mime_type: "image/png".into(),
6380 width: 32,
6381 height: 24,
6382 attachment: None,
6383 }
6384 }
6385
6386 fn sample_valid_image() -> ViewerPromptImage {
6387 ViewerPromptImage {
6388 data_base64: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
6389 .into(),
6390 mime_type: "image/png".into(),
6391 width: 1,
6392 height: 1,
6393 attachment: None,
6394 }
6395 }
6396
6397 fn image_capable(snapshot: &mut ViewerSnapshot) {
6398 snapshot.sessions[0].prompt_images_supported = true;
6399 }
6400
6401 async fn post_action(app: Router, cookie: String, body: String) -> Response<Body> {
6402 app.oneshot(
6403 Request::post("/api/actions")
6404 .header(COOKIE, cookie)
6405 .header(CONTENT_TYPE, "application/json")
6406 .body(Body::from(body))
6407 .unwrap(),
6408 )
6409 .await
6410 .unwrap()
6411 }
6412
6413 #[tokio::test]
6414 async fn image_prompt_reaches_the_controller_with_its_images() {
6415 let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
6416 let cookie = login_cookie(&app).await;
6417 let image = sample_valid_image();
6418 let body = serde_json::to_string(&ControllerAction::Prompt {
6419 session_id: "session-1".into(),
6420 text: String::new(),
6421 images: vec![image.clone(), image.clone()],
6422 })
6423 .unwrap();
6424 let response = tokio::spawn(post_action(app, cookie, body));
6425 let request = actions.recv().await.unwrap();
6426 let ControllerRequest { action, reply } = request;
6427 let ControllerAction::Prompt {
6428 session_id,
6429 text,
6430 images,
6431 } = action
6432 else {
6433 panic!("expected a prompt action")
6434 };
6435 assert_eq!(session_id, "session-1");
6436 assert!(text.is_empty());
6437 assert_eq!(images.len(), 2);
6438 assert!(
6439 images
6440 .iter()
6441 .all(|image| { image.data_base64.is_empty() && image.attachment.is_some() })
6442 );
6443 reply.send(ActionOutcome::accepted()).unwrap();
6444 assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
6445 }
6446
6447 #[tokio::test]
6448 async fn browser_attachment_upload_returns_a_stored_reference_without_inline_bytes() {
6449 let (app, _, _, _, _) = app_with_snapshot(image_capable);
6450 let cookie = login_cookie(&app).await;
6451 let bytes = base64::engine::general_purpose::STANDARD
6452 .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")
6453 .unwrap();
6454 let response = app
6455 .oneshot(
6456 Request::post("/api/sessions/session-1/attachments")
6457 .header(COOKIE, cookie)
6458 .header(CONTENT_TYPE, "image/png")
6459 .body(Body::from(bytes))
6460 .unwrap(),
6461 )
6462 .await
6463 .unwrap();
6464 assert_eq!(response.status(), StatusCode::OK);
6465 let body = response.into_body().collect().await.unwrap().to_bytes();
6466 let image: ViewerPromptImage = serde_json::from_slice(&body).unwrap();
6467 assert!(image.data_base64.is_empty());
6468 let reference = image.attachment.expect("upload should return a reference");
6469 assert_eq!(reference.mime_type, "image/png");
6470 assert_eq!(reference.width, 1);
6471 assert_eq!(reference.height, 1);
6472 assert!(reference.size <= 700 * 1024);
6473 }
6474
6475 #[tokio::test]
6479 async fn multi_image_prompts_are_accepted_over_the_general_body_limit() {
6480 let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
6481 let cookie = login_cookie(&app).await;
6482 let image = sample_valid_image();
6483 let mut body = serde_json::to_string(&ControllerAction::Prompt {
6484 session_id: "session-1".into(),
6485 text: "look at these".into(),
6486 images: vec![image.clone(), image],
6487 })
6488 .unwrap();
6489 body.push_str(&" ".repeat(MAX_BODY_BYTES));
6490 assert!(body.len() > MAX_BODY_BYTES);
6491 assert!(body.len() < MAX_PROMPT_BODY_BYTES);
6492 let response = tokio::spawn(post_action(app, cookie, body));
6493 let action = actions.recv().await.unwrap();
6494 action.reply.send(ActionOutcome::accepted()).unwrap();
6495 assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
6496 }
6497
6498 #[tokio::test]
6499 async fn a_body_over_the_prompt_limit_is_still_refused() {
6500 let (app, _actions, _, _, _) = app_with_snapshot(image_capable);
6501 let cookie = login_cookie(&app).await;
6502 let image = sample_image(MAX_PROMPT_BODY_BYTES);
6503 let body = serde_json::to_string(&ControllerAction::Prompt {
6504 session_id: "session-1".into(),
6505 text: String::new(),
6506 images: vec![image],
6507 })
6508 .unwrap();
6509 assert!(body.len() > MAX_PROMPT_BODY_BYTES);
6510 let response = post_action(app, cookie, body).await;
6511 assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
6512 }
6513
6514 #[tokio::test]
6515 async fn malformed_image_payloads_never_reach_the_controller() {
6516 let cases = [
6517 ("aW1hZ2U=", "text/plain", 32, 24),
6518 ("aW1hZ2U=", "image/png", 0, 24),
6519 ("not base64!", "image/png", 32, 24),
6520 ("", "image/png", 32, 24),
6521 ];
6522 for (data, mime, width, height) in cases {
6523 let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
6524 let cookie = login_cookie(&app).await;
6525 let body = serde_json::to_string(&ControllerAction::Prompt {
6526 session_id: "session-1".into(),
6527 text: String::new(),
6528 images: vec![ViewerPromptImage {
6529 data_base64: data.into(),
6530 mime_type: mime.into(),
6531 width,
6532 height,
6533 attachment: None,
6534 }],
6535 })
6536 .unwrap();
6537 let response = post_action(app, cookie, body).await;
6538 assert_eq!(
6539 response.status(),
6540 StatusCode::BAD_REQUEST,
6541 "expected {data:?}/{mime} {width}x{height} to be refused"
6542 );
6543 assert!(actions.try_recv().is_err());
6544 }
6545 }
6546
6547 #[test]
6548 fn image_prompts_need_text_or_an_image_and_an_agent_that_takes_them() {
6549 let (config, state) = sample_config_state();
6550 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6551 let prompt = |text: &str, images: Vec<ViewerPromptImage>| ControllerAction::Prompt {
6552 session_id: "session-1".into(),
6553 text: text.into(),
6554 images,
6555 };
6556
6557 assert!(validate_action(&prompt("ship it", Vec::new()), &snapshot).is_ok());
6559 assert!(validate_action(&prompt("", vec![sample_image(8)]), &snapshot).is_err());
6560
6561 image_capable(&mut snapshot);
6562 assert!(validate_action(&prompt("", vec![sample_image(8)]), &snapshot).is_ok());
6564 assert!(
6565 validate_action(
6566 &prompt("", vec![sample_image(8); MAX_PROMPT_IMAGES + 1]),
6567 &snapshot,
6568 )
6569 .is_err()
6570 );
6571 assert!(validate_action(&prompt(" ", Vec::new()), &snapshot).is_err());
6572 assert!(validate_action(&prompt("", Vec::new()), &snapshot).is_err());
6573 assert!(validate_action(&prompt("!ls", vec![sample_image(8)]), &snapshot).is_err());
6575 }
6576
6577 #[test]
6580 fn embedded_viewer_reads_multiline_composer_text_out_of_its_dom() {
6581 let source = viewer_source("function composerText()", "function setComposerText(");
6582 let harness = r##"
6583const Node = { TEXT_NODE: 3 };
6584function textNode(value) {
6585 return { nodeType: 3, nodeValue: value, nodeName: "#text", childNodes: [], dataset: {} };
6586}
6587function element(name, children = [], dataset = {}) {
6588 const node = { nodeType: 1, nodeName: name, dataset, childNodes: children };
6589 children.forEach((child, index) => {
6590 child.nextSibling = children[index + 1] || null;
6591 });
6592 return node;
6593}
6594let promptText = null;
6595function read(children) {
6596 promptText = element("DIV", children);
6597 return composerText();
6598}
6599"##;
6600 let checks = r#"
6601const plain = read([textNode("ship it")]);
6602if (plain !== "ship it") throw new Error(`plain text became ${JSON.stringify(plain)}`);
6603
6604const broken = read([textNode("first"), element("BR"), textNode("second")]);
6605if (broken !== "first\nsecond") throw new Error(`line break became ${JSON.stringify(broken)}`);
6606
6607// The trailing break a browser leaves behind to keep the caret on a new line
6608// is scaffolding, not a line the user typed.
6609const filler = read([
6610 textNode("first"),
6611 element("BR"),
6612 element("BR", [], { composerFiller: "true" }),
6613]);
6614if (filler !== "first\n") throw new Error(`filler break became ${JSON.stringify(filler)}`);
6615
6616const blocks = read([
6617 textNode("first"),
6618 element("DIV", [textNode("second")]),
6619 element("DIV", [textNode("third")]),
6620]);
6621if (blocks !== "first\nsecond\nthird") throw new Error(`blocks became ${JSON.stringify(blocks)}`);
6622
6623const carriage = read([textNode("first\r\nsecond")]);
6624if (carriage !== "first\nsecond") throw new Error(`CRLF became ${JSON.stringify(carriage)}`);
6625"#;
6626 run_viewer_script("composer-reader", &format!("{harness}\n{source}\n{checks}"));
6627 }
6628
6629 #[tokio::test]
6633 async fn viewer_declares_the_icon_route_instead_of_requesting_a_missing_favicon() {
6634 let (app, _, _, _, _) = app();
6635 let page = fetch_text(app.clone(), "/").await;
6636 assert!(page.contains(r#"rel="icon""#), "the page declares no icon");
6637 assert!(page.contains("/icon.svg"), "the page names no icon route");
6638 let icon = app
6639 .oneshot(Request::get("/icon.svg").body(Body::empty()).unwrap())
6640 .await
6641 .unwrap();
6642 assert_eq!(icon.status(), StatusCode::OK);
6643 assert_eq!(
6644 icon.headers().get(CONTENT_TYPE).unwrap(),
6645 "image/svg+xml",
6646 "the icon route does not serve an SVG"
6647 );
6648 }
6649
6650 #[tokio::test]
6651 async fn valid_action_is_typed_and_forwarded() {
6652 let (app, mut actions, _, _, _) = app();
6653 let cookie = login_cookie(&app).await;
6654 let response = tokio::spawn(
6655 app.oneshot(
6656 Request::post("/api/actions")
6657 .header(COOKIE, cookie)
6658 .header(CONTENT_TYPE, "application/json")
6659 .body(Body::from(
6660 r#"{"action":"prompt","session_id":"session-1","text":"ship it"}"#,
6661 ))
6662 .unwrap(),
6663 ),
6664 );
6665 let action = actions.recv().await.unwrap();
6666 assert_eq!(
6667 action.action,
6668 ControllerAction::Prompt {
6669 session_id: "session-1".into(),
6670 text: "ship it".into(),
6671 images: Vec::new(),
6672 }
6673 );
6674 action.reply.send(ActionOutcome::accepted()).unwrap();
6675 let response = response.await.unwrap().unwrap();
6676 assert_eq!(response.status(), StatusCode::ACCEPTED);
6677 }
6678
6679 #[tokio::test]
6680 async fn move_preparation_is_read_only_and_returns_the_daemon_fingerprint() {
6681 let (app, mut preparations) = app_with_move_receiver();
6682 let cookie = login_cookie(&app).await;
6683 let response = tokio::spawn(
6684 app.oneshot(
6685 Request::post("/api/moves/prepare")
6686 .header(COOKIE, cookie)
6687 .header(CONTENT_TYPE, "application/json")
6688 .body(Body::from(
6689 r#"{"session_id":"session-1","profile_id":"codex-1","target_template_id":"podman","clear_resource_allocation":false,"additional_mounts":null,"resource_allocation":null}"#,
6690 ))
6691 .unwrap(),
6692 ),
6693 );
6694 let request = preparations
6695 .recv()
6696 .await
6697 .expect("preparation reached daemon");
6698 assert_eq!(request.selection.session_id, "session-1");
6699 assert_eq!(request.selection.profile_id.as_deref(), Some("codex-1"));
6700 assert_eq!(
6701 request.selection.target_template_id.as_deref(),
6702 Some("podman")
6703 );
6704 request
6705 .reply
6706 .send(Ok(MovePreparation {
6707 source_unavailable: false,
6708 conversion: None,
6709 selection: request.selection,
6710 source_profile_id: "codex-1".into(),
6711 source_target_template_id: "podman".into(),
6712 cross_harness: false,
6713 active: true,
6714 queued_commands: vec![mj_core::state::MaterializedQueuedPrompt {
6715 accepted_ordinal: None,
6716 command_id: "queued-1".into(),
6717 kind: mj_core::state::QueuedCommandKind::Prompt,
6718 content: vec![serde_json::json!({
6719 "type": "image",
6720 "mimeType": "image/png",
6721 "data": "secret-image-bytes"
6722 })],
6723 queued_at_ms: 1,
6724 }],
6725 fingerprint: "fingerprint".into(),
6726 operation_id: "move-1".into(),
6727 }))
6728 .unwrap();
6729 let response = response.await.unwrap().unwrap();
6730 assert_eq!(response.status(), StatusCode::OK);
6731 let body = response.into_body().collect().await.unwrap().to_bytes();
6732 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
6733 assert_eq!(body["operation_id"], "move-1");
6734 assert_eq!(body["active"], true);
6735 assert_eq!(
6736 body["queued_commands"][0]["content"][0]["text"],
6737 "[Image attachment: image/png]"
6738 );
6739 assert!(body.to_string().contains("[Image attachment: image/png]"));
6740 assert!(!body.to_string().contains("secret-image-bytes"));
6741 }
6742
6743 #[tokio::test]
6744 async fn confirmed_move_action_forwards_the_fingerprinted_request() {
6745 let (app, mut actions, _, _, _) = app_with_snapshot(|snapshot| {
6746 snapshot.sessions[0].capabilities.move_session = true;
6747 });
6748 let cookie = login_cookie(&app).await;
6749 let response = tokio::spawn(
6750 app.oneshot(
6751 Request::post("/api/actions")
6752 .header(COOKIE, cookie)
6753 .header(CONTENT_TYPE, "application/json")
6754 .body(Body::from(
6755 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}}"#,
6756 ))
6757 .unwrap(),
6758 ),
6759 );
6760 let action = actions.recv().await.expect("move action reached daemon");
6761 assert!(matches!(action.action, ControllerAction::Move { .. }));
6762 action.reply.send(ActionOutcome::accepted()).unwrap();
6763 assert_eq!(
6764 response.await.unwrap().unwrap().status(),
6765 StatusCode::ACCEPTED
6766 );
6767 }
6768
6769 #[tokio::test]
6770 async fn shell_action_is_typed_and_forwarded() {
6771 let (app, mut actions, _, _, _) = app();
6772 let cookie = login_cookie(&app).await;
6773 let response = tokio::spawn(
6774 app.oneshot(
6775 Request::post("/api/actions")
6776 .header(COOKIE, cookie)
6777 .header(CONTENT_TYPE, "application/json")
6778 .body(Body::from(
6779 r#"{"action":"run-shell","session_id":"session-1","command":"cargo test"}"#,
6780 ))
6781 .unwrap(),
6782 ),
6783 );
6784 let action = actions.recv().await.unwrap();
6785 assert_eq!(
6786 action.action,
6787 ControllerAction::RunShell {
6788 session_id: "session-1".into(),
6789 command: "cargo test".into(),
6790 }
6791 );
6792 action.reply.send(ActionOutcome::accepted()).unwrap();
6793 assert_eq!(
6794 response.await.unwrap().unwrap().status(),
6795 StatusCode::ACCEPTED
6796 );
6797 }
6798
6799 #[test]
6800 fn shell_action_validation_reserves_bang_prompts_and_checks_cancellation_ids() {
6801 let (config, state) = sample_config_state();
6802 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6803 assert!(
6804 validate_action(
6805 &ControllerAction::Prompt {
6806 session_id: "session-1".into(),
6807 text: "!cargo test".into(),
6808 images: Vec::new(),
6809 },
6810 &snapshot,
6811 )
6812 .is_err()
6813 );
6814 assert!(
6815 validate_action(
6816 &ControllerAction::RunShell {
6817 session_id: "session-1".into(),
6818 command: "cargo test".into(),
6819 },
6820 &snapshot,
6821 )
6822 .is_ok()
6823 );
6824 assert!(
6825 validate_action(
6826 &ControllerAction::CancelShell {
6827 session_id: "session-1".into(),
6828 shell_command_id: "shell-1".into(),
6829 },
6830 &snapshot,
6831 )
6832 .is_err()
6833 );
6834
6835 snapshot.sessions[0]
6836 .active_user_shells
6837 .push(ViewerUserShell {
6838 id: "shell-1".into(),
6839 command: "cargo test".into(),
6840 started_at_ms: Some(10),
6841 });
6842 assert!(
6843 validate_action(
6844 &ControllerAction::CancelShell {
6845 session_id: "session-1".into(),
6846 shell_command_id: "shell-1".into(),
6847 },
6848 &snapshot,
6849 )
6850 .is_ok()
6851 );
6852 }
6853
6854 #[tokio::test]
6855 async fn bare_new_action_forwards_an_explicit_safe_project_directory() {
6856 let (app, mut actions, _, _, _) = app();
6857 let cookie = login_cookie(&app).await;
6858 let response = tokio::spawn(
6859 app.oneshot(
6860 Request::post("/api/actions")
6861 .header(COOKIE, cookie)
6862 .header(CONTENT_TYPE, "application/json")
6863 .body(Body::from(
6864 r#"{"action":"new","profile_id":"codex-1","bundle_id":"hel","target_id":"raw","title":"Raw work","project_directory":"/work/project"}"#,
6865 ))
6866 .unwrap(),
6867 ),
6868 );
6869 let action = actions.recv().await.unwrap();
6870 assert_eq!(
6871 action.action,
6872 ControllerAction::New {
6873 mjolnir_subagents: None,
6874 create_managed_worktree: None,
6875 workspace_id: String::new(),
6876 profile_id: "codex-1".into(),
6877 bundle_id: "hel".into(),
6878 target_id: "raw".into(),
6879 title: Some("Raw work".into()),
6880 project_directory: Some(PathBuf::from("/work/project")),
6881 dirty_ack: Vec::new(),
6882 }
6883 );
6884 action.reply.send(ActionOutcome::accepted()).unwrap();
6885 assert_eq!(
6886 response.await.unwrap().unwrap().status(),
6887 StatusCode::ACCEPTED
6888 );
6889 }
6890
6891 #[test]
6892 fn new_action_requires_project_directory_exactly_for_bare_targets() {
6893 let (config, state) = sample_config_state();
6894 let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6895 let action = |target_id: &str, project_directory: Option<PathBuf>| ControllerAction::New {
6896 mjolnir_subagents: None,
6897 create_managed_worktree: None,
6898 workspace_id: String::new(),
6899 profile_id: "codex-1".into(),
6900 bundle_id: "hel".into(),
6901 target_id: target_id.into(),
6902 title: Some("New work".into()),
6903 project_directory,
6904 dirty_ack: Vec::new(),
6905 };
6906
6907 assert!(validate_action(&action("podman", None), &snapshot).is_ok());
6908 assert_eq!(
6909 validate_action(&action("podman", Some("/work".into())), &snapshot)
6910 .unwrap_err()
6911 .status,
6912 StatusCode::BAD_REQUEST
6913 );
6914 assert_eq!(
6915 validate_action(&action("raw", None), &snapshot)
6916 .unwrap_err()
6917 .status,
6918 StatusCode::BAD_REQUEST
6919 );
6920 assert_eq!(
6921 validate_action(&action("raw", Some("relative".into())), &snapshot)
6922 .unwrap_err()
6923 .status,
6924 StatusCode::BAD_REQUEST
6925 );
6926 assert_eq!(
6927 validate_action(&action("raw", Some("/work/../secret".into())), &snapshot)
6928 .unwrap_err()
6929 .status,
6930 StatusCode::BAD_REQUEST
6931 );
6932 assert!(validate_action(&action("raw", Some("/work/project".into())), &snapshot).is_ok());
6933 }
6934
6935 #[tokio::test]
6936 async fn cancel_action_is_typed_and_forwarded() {
6937 let (app, mut actions, _, _, _) = app();
6938 let cookie = login_cookie(&app).await;
6939 let response = tokio::spawn(
6940 app.oneshot(
6941 Request::post("/api/actions")
6942 .header(COOKIE, cookie)
6943 .header(CONTENT_TYPE, "application/json")
6944 .body(Body::from(
6945 r#"{"action":"cancel","session_id":"session-1"}"#,
6946 ))
6947 .unwrap(),
6948 ),
6949 );
6950 let action = actions.recv().await.unwrap();
6951 assert_eq!(
6952 action.action,
6953 ControllerAction::Cancel {
6954 session_id: "session-1".into(),
6955 }
6956 );
6957 action.reply.send(ActionOutcome::accepted()).unwrap();
6958 assert_eq!(
6959 response.await.unwrap().unwrap().status(),
6960 StatusCode::ACCEPTED
6961 );
6962 }
6963
6964 #[tokio::test]
6965 async fn action_validation_accepts_cross_harness_resume_and_rejects_unknown() {
6966 let (mut config, state) = sample_config_state();
6967 config.profiles.insert(
6968 "claude-1".into(),
6969 HarnessProfile {
6970 enabled: true,
6971 context_window_bytes: None,
6972 guardian_review_model: None,
6973 kind: HarnessKind::Claude,
6974 home: "/secret/claude".into(),
6975 environment: BTreeMap::new(),
6976 },
6977 );
6978 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
6979 snapshot.workspaces.push(ViewerWorkspace {
6980 id: "workspace-1".into(),
6981 name: "One".into(),
6982 });
6983 validate_action(
6984 &ControllerAction::Resume {
6985 session_id: "session-1".into(),
6986 workspace_id: "workspace-1".into(),
6987 profile_id: "claude-1".into(),
6988 target_id: "podman".into(),
6989 queue: ResumeQueueDisposition::Start,
6990 additional_mounts: None,
6991 resource_allocation: None,
6992 },
6993 &snapshot,
6994 )
6995 .unwrap();
6996
6997 let error = validate_action(
6998 &ControllerAction::Resume {
6999 session_id: "session-1".into(),
7000 workspace_id: "missing".into(),
7001 profile_id: "claude-1".into(),
7002 target_id: "podman".into(),
7003 queue: ResumeQueueDisposition::Start,
7004 additional_mounts: None,
7005 resource_allocation: None,
7006 },
7007 &snapshot,
7008 )
7009 .unwrap_err();
7010 assert_eq!(error.status, StatusCode::BAD_REQUEST);
7011
7012 let error = validate_action(
7013 &ControllerAction::Close {
7014 session_id: "not-managed".into(),
7015 },
7016 &snapshot,
7017 )
7018 .unwrap_err();
7019 assert_eq!(error.status, StatusCode::NOT_FOUND);
7020 }
7021
7022 #[test]
7025 fn a_running_review_projects_to_the_phone() {
7026 use crate::review_host::{RuntimeReviewView, VerdictKind, VerdictView};
7027 use mj_core::review::driver::{Resolution, RoleState, RoleStatus, TurnReviewPhase};
7028
7029 let review = RuntimeReviewView {
7030 session_id: "session-1".into(),
7031 tier: mj_core::review::lanes::ReviewTier::Extended,
7032 phase: TurnReviewPhase::Verdict(mj_core::review::verdict::ReviewVerdict::Findings {
7033 synthesis: "[P1] src/lib.rs:1 -- unbounded retry".into(),
7034 evidence: Default::default(),
7035 }),
7036 roles: vec![
7037 RoleStatus {
7038 role: "supervisor".into(),
7039 label: "Supervisor".into(),
7040 state: RoleState::Clean,
7041 },
7042 RoleStatus {
7043 role: "tests".into(),
7044 label: "Tests".into(),
7045 state: RoleState::Findings,
7046 },
7047 ],
7048 status: "Enter to act".into(),
7049 verdict: Some(VerdictView {
7050 kind: VerdictKind::Findings,
7051 text: "[P1] src/lib.rs:1 -- unbounded retry".into(),
7052 allowed: vec![
7053 Resolution::Forwarded,
7054 Resolution::Dismissed,
7055 Resolution::Cancelled,
7056 ],
7057 }),
7058 };
7059
7060 let projected = ViewerTurnReview::from_runtime(&review);
7061
7062 assert_eq!(projected.tier, "extended");
7063 assert_eq!(
7064 projected
7065 .roles
7066 .iter()
7067 .map(|role| (role.label.as_str(), role.state.as_str()))
7068 .collect::<Vec<_>>(),
7069 vec![("Supervisor", "done"), ("Tests", "findings")]
7070 );
7071 let verdict = projected.verdict.expect("a findings verdict travels");
7072 assert_eq!(verdict.kind, "findings");
7073 assert!(verdict.text.contains("unbounded retry"));
7074 assert_eq!(verdict.allowed, vec!["forward", "dismiss", "cancel"]);
7075 }
7076
7077 #[test]
7081 fn resolving_a_review_is_gated_on_what_the_daemon_published() {
7082 let (config, state) = sample_config_state();
7083 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
7084
7085 let resolve = |resolution: &str| ControllerAction::ResolveReview {
7086 session_id: "session-1".into(),
7087 resolution: resolution.into(),
7088 };
7089
7090 let error = validate_action(&resolve("cancel"), &snapshot).unwrap_err();
7092 assert_eq!(error.status, StatusCode::BAD_REQUEST);
7093
7094 snapshot.sessions[0].turn_review = Some(ViewerTurnReview {
7095 tier: "quick".into(),
7096 status: "the reviewer is reading the change…".into(),
7097 roles: Vec::new(),
7098 verdict: None,
7099 });
7100 validate_action(&resolve("cancel"), &snapshot).unwrap();
7102 assert_eq!(
7103 validate_action(&resolve("forward"), &snapshot)
7104 .unwrap_err()
7105 .status,
7106 StatusCode::BAD_REQUEST
7107 );
7108
7109 snapshot.sessions[0].turn_review = Some(ViewerTurnReview {
7111 tier: "quick".into(),
7112 status: "the review failed".into(),
7113 roles: Vec::new(),
7114 verdict: Some(ViewerReviewVerdict {
7115 kind: "failed".into(),
7116 text: "bifrost exited with 1".into(),
7117 allowed: vec!["dismiss".into(), "cancel".into()],
7118 }),
7119 });
7120 validate_action(&resolve("dismiss"), &snapshot).unwrap();
7121 assert_eq!(
7122 validate_action(&resolve("forward"), &snapshot)
7123 .unwrap_err()
7124 .status,
7125 StatusCode::BAD_REQUEST
7126 );
7127 assert_eq!(
7129 validate_action(&resolve("approve"), &snapshot)
7130 .unwrap_err()
7131 .status,
7132 StatusCode::BAD_REQUEST
7133 );
7134
7135 validate_action(
7137 &ControllerAction::StartReview {
7138 session_id: "session-1".into(),
7139 },
7140 &snapshot,
7141 )
7142 .unwrap();
7143 assert_eq!(
7144 validate_action(
7145 &ControllerAction::StartReview {
7146 session_id: "not-managed".into(),
7147 },
7148 &snapshot,
7149 )
7150 .unwrap_err()
7151 .status,
7152 StatusCode::NOT_FOUND
7153 );
7154 }
7155
7156 #[test]
7157 fn resume_action_refuses_a_target_the_session_cannot_use() {
7158 let (mut config, state) = sample_config_state();
7159 config.bundles.get_mut("hel").unwrap().repositories[0].local = None;
7162 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
7163 snapshot.workspaces.push(ViewerWorkspace {
7164 id: "workspace-1".into(),
7165 name: "One".into(),
7166 });
7167 assert_eq!(
7168 snapshot.sessions[0].incompatible_resume_targets,
7169 vec!["raw".to_owned()]
7170 );
7171
7172 let error = validate_action(
7173 &ControllerAction::Resume {
7174 session_id: "session-1".into(),
7175 workspace_id: "workspace-1".into(),
7176 profile_id: "codex-1".into(),
7177 target_id: "raw".into(),
7178 queue: ResumeQueueDisposition::Start,
7179 additional_mounts: None,
7180 resource_allocation: None,
7181 },
7182 &snapshot,
7183 )
7184 .unwrap_err();
7185
7186 assert_eq!(error.status, StatusCode::BAD_REQUEST);
7187 }
7188
7189 #[test]
7190 fn move_confirmation_requires_interruption_ack_and_an_explicit_queue_choice() {
7191 let (config, state) = sample_config_state();
7192 let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
7193 snapshot.sessions[0].capabilities.move_session = true;
7194 let selection = MoveSelection {
7195 clear_resource_allocation: false,
7196 session_id: "session-1".into(),
7197 profile_id: Some("codex-1".into()),
7198 target_template_id: Some("podman".into()),
7199 additional_mounts: None,
7200 resource_allocation: None,
7201 };
7202 let preparation = MovePreparation {
7203 source_unavailable: false,
7204 conversion: None,
7205 selection,
7206 source_profile_id: "codex-1".into(),
7207 source_target_template_id: "podman".into(),
7208 cross_harness: false,
7209 active: true,
7210 queued_commands: vec![mj_core::state::MaterializedQueuedPrompt {
7211 accepted_ordinal: None,
7212 command_id: "command-1".into(),
7213 kind: mj_core::state::QueuedCommandKind::Prompt,
7214 content: vec![serde_json::json!({"type": "text", "text": "continue"})],
7215 queued_at_ms: 1,
7216 }],
7217 fingerprint: "fingerprint".into(),
7218 operation_id: "move-1".into(),
7219 };
7220 let request = |queue, acknowledge_interruption| MoveSessionRequest {
7221 preparation: preparation.clone(),
7222 queue,
7223 acknowledge_interruption,
7224 };
7225 assert_eq!(
7226 validate_action(
7227 &ControllerAction::Move {
7228 request: request(Some(ResumeQueueDisposition::Discard), false),
7229 },
7230 &snapshot,
7231 )
7232 .unwrap_err()
7233 .status,
7234 StatusCode::CONFLICT
7235 );
7236 assert_eq!(
7237 validate_action(
7238 &ControllerAction::Move {
7239 request: request(None, true),
7240 },
7241 &snapshot,
7242 )
7243 .unwrap_err()
7244 .status,
7245 StatusCode::BAD_REQUEST
7246 );
7247 validate_action(
7248 &ControllerAction::Move {
7249 request: request(Some(ResumeQueueDisposition::Discard), true),
7250 },
7251 &snapshot,
7252 )
7253 .unwrap();
7254 }
7255
7256 #[tokio::test]
7257 async fn snapshot_endpoint_returns_only_public_projection() {
7258 let (app, _, _, _, _) = app();
7259 let cookie = login_cookie(&app).await;
7260 let response = app
7261 .oneshot(
7262 Request::get("/api/snapshot")
7263 .header(COOKIE, cookie)
7264 .body(Body::empty())
7265 .unwrap(),
7266 )
7267 .await
7268 .unwrap();
7269 let body = response.into_body().collect().await.unwrap().to_bytes();
7270 let body = String::from_utf8(body.to_vec()).unwrap();
7271 assert!(body.contains("session-1"));
7272 assert!(!body.contains("secret-token"));
7273 assert!(!body.contains("native-secret-id"));
7274 assert!(!body.contains("/private/source/hel"));
7275
7276 let snapshot: serde_json::Value = serde_json::from_str(&body).unwrap();
7277 let repository = &snapshot["bundles"][0]["repositories"][0];
7278 assert_eq!(repository["id"], "hel");
7279 assert_eq!(repository["github"], "owner/hel");
7280 assert_eq!(repository["destination"], "hel");
7281 assert!(repository.get("local").is_none());
7282 }
7283
7284 #[tokio::test]
7285 async fn snapshot_clock_anchor_is_fresh_even_when_the_projection_has_not_changed() {
7286 let (app, _, _, _, _) = app_with_snapshot(|snapshot| snapshot.server_time_ms = 1);
7287 let cookie = login_cookie(&app).await;
7288 for _ in 0..2 {
7289 let before = mj_core::clock::epoch_millis();
7290 let response = app
7291 .clone()
7292 .oneshot(
7293 Request::get("/api/snapshot")
7294 .header(COOKIE, &cookie)
7295 .body(Body::empty())
7296 .unwrap(),
7297 )
7298 .await
7299 .unwrap();
7300 let body = response.into_body().collect().await.unwrap().to_bytes();
7301 let snapshot: ViewerSnapshot = serde_json::from_slice(&body).unwrap();
7302 assert!(snapshot.server_time_ms >= before);
7303 assert!(snapshot.server_time_ms <= mj_core::clock::epoch_millis());
7304 }
7305 }
7306
7307 #[tokio::test]
7308 async fn conversation_endpoint_returns_authenticated_bounded_deltas() {
7309 let transcript = BrowserTranscript {
7310 latest_seq: 8,
7311 presentation_key: "key-1".into(),
7312 window_start_seq: 3,
7313 reset: false,
7314 entries: vec![
7315 BrowserTranscriptEntry {
7316 id: 3,
7317 updated_seq: 3,
7318 role: "user",
7319 label: "You".into(),
7320 recorded_at_ms: None,
7321 lines: vec!["begin".into()],
7322 glyph: "\u{276f}",
7323 tone: "user",
7324 tool_status: None,
7325 diffstats: Vec::new(),
7326 },
7327 BrowserTranscriptEntry {
7328 id: 7,
7329 updated_seq: 8,
7330 role: "agent",
7331 label: "Agent".into(),
7332 recorded_at_ms: None,
7333 lines: vec!["live".into()],
7334 glyph: "\u{25cf}",
7335 tone: "agent",
7336 tool_status: None,
7337 diffstats: Vec::new(),
7338 },
7339 ],
7340 };
7341 let (app, _, _, _, _) =
7342 app_with_conversations(BTreeMap::from([("session-1".into(), transcript)]));
7343 let cookie = login_cookie(&app).await;
7344 let response = app
7345 .clone()
7346 .oneshot(
7347 Request::get("/api/conversations/session-1?after_seq=3")
7348 .header(COOKIE, &cookie)
7349 .body(Body::empty())
7350 .unwrap(),
7351 )
7352 .await
7353 .unwrap();
7354 assert_eq!(response.status(), StatusCode::OK);
7355 let body = response.into_body().collect().await.unwrap().to_bytes();
7356 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
7357 assert_eq!(body["latest_seq"], 8);
7358 assert_eq!(body["reset"], false);
7359 assert_eq!(body["entries"].as_array().unwrap().len(), 1);
7360 assert_eq!(body["entries"][0]["lines"][0], "live");
7361
7362 let response = app
7363 .oneshot(
7364 Request::get("/api/conversations/session-1?after_seq=8&presentation_key=stale-key")
7365 .header(COOKIE, &cookie)
7366 .body(Body::empty())
7367 .unwrap(),
7368 )
7369 .await
7370 .unwrap();
7371 assert_eq!(response.status(), StatusCode::OK);
7372 let body = response.into_body().collect().await.unwrap().to_bytes();
7373 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
7374 assert_eq!(body["reset"], true);
7375 assert_eq!(body["presentation_key"], "key-1");
7376 assert_eq!(body["entries"].as_array().unwrap().len(), 2);
7377 }
7378
7379 #[tokio::test]
7380 async fn conversation_endpoint_rejects_cached_transcript_during_transition() {
7381 let transcript = BrowserTranscript {
7382 latest_seq: 1,
7383 presentation_key: "key-1".into(),
7384 window_start_seq: 1,
7385 reset: false,
7386 entries: vec![BrowserTranscriptEntry {
7387 id: 1,
7388 updated_seq: 1,
7389 role: "agent",
7390 label: "Agent".into(),
7391 recorded_at_ms: None,
7392 lines: vec!["stale".into()],
7393 glyph: "●",
7394 tone: "agent",
7395 tool_status: None,
7396 diffstats: Vec::new(),
7397 }],
7398 };
7399 let (app, _, _, _, _) = app_with(
7400 BTreeMap::from([("session-1".into(), transcript)]),
7401 |snapshot| snapshot.sessions[0].transitioning = true,
7402 );
7403 let cookie = login_cookie(&app).await;
7404 let response = app
7405 .oneshot(
7406 Request::get("/api/conversations/session-1")
7407 .header(COOKIE, cookie)
7408 .body(Body::empty())
7409 .unwrap(),
7410 )
7411 .await
7412 .unwrap();
7413 assert_eq!(response.status(), StatusCode::CONFLICT);
7414 }
7415
7416 #[tokio::test]
7417 async fn conversation_read_receipt_never_contends_with_a_running_action() {
7418 let (app, mut actions, mut receipts, _, _) = app();
7419 let cookie = login_cookie(&app).await;
7420 let prompt = tokio::spawn(
7424 app.clone().oneshot(
7425 Request::post("/api/actions")
7426 .header(COOKIE, cookie.clone())
7427 .header(CONTENT_TYPE, "application/json")
7428 .body(Body::from(
7429 r#"{"action":"prompt","session_id":"session-1","text":"ship it"}"#,
7430 ))
7431 .unwrap(),
7432 ),
7433 );
7434 let action = actions.recv().await.unwrap();
7435
7436 let response = tokio::spawn(
7437 app.oneshot(
7438 Request::post("/api/conversations/session-1/read")
7439 .header(COOKIE, cookie)
7440 .header(CONTENT_TYPE, "application/json")
7441 .body(Body::from(r#"{"through":42}"#))
7442 .unwrap(),
7443 ),
7444 );
7445 let receipt = receipts.recv().await.unwrap();
7446 assert_eq!(receipt.session_id, "session-1");
7447 assert_eq!(receipt.through, 42);
7448 receipt.reply.send(Ok(())).unwrap();
7449 assert_eq!(
7450 response.await.unwrap().unwrap().status(),
7451 StatusCode::NO_CONTENT
7452 );
7453 assert!(
7454 actions.try_recv().is_err(),
7455 "a read receipt must not queue a controller action"
7456 );
7457
7458 action.reply.send(ActionOutcome::accepted()).unwrap();
7459 assert_eq!(
7460 prompt.await.unwrap().unwrap().status(),
7461 StatusCode::ACCEPTED
7462 );
7463 }
7464
7465 #[tokio::test]
7466 async fn each_rejected_action_keeps_its_own_status_and_guidance() {
7467 for (outcome, status, guidance) in [
7468 (
7469 ActionOutcome::Busy,
7470 StatusCode::TOO_MANY_REQUESTS,
7471 "concurrent action limit",
7472 ),
7473 (
7474 ActionOutcome::SessionBusy,
7475 StatusCode::CONFLICT,
7476 "another operation is already running",
7477 ),
7478 (
7479 ActionOutcome::NotCancellable,
7480 StatusCode::CONFLICT,
7481 "no cancellable operation",
7482 ),
7483 (
7484 ActionOutcome::Failed,
7485 StatusCode::INTERNAL_SERVER_ERROR,
7486 "could not start this action",
7487 ),
7488 ] {
7489 let (app, mut actions, _, _, _) = app();
7490 let cookie = login_cookie(&app).await;
7491 let response = tokio::spawn(
7492 app.oneshot(
7493 Request::post("/api/actions")
7494 .header(COOKIE, cookie)
7495 .header(CONTENT_TYPE, "application/json")
7496 .body(Body::from(r#"{"action":"close","session_id":"session-1"}"#))
7497 .unwrap(),
7498 ),
7499 );
7500 let request = actions.recv().await.unwrap();
7501 request.reply.send(outcome.clone()).unwrap();
7502
7503 let response = response.await.unwrap().unwrap();
7504 assert_eq!(response.status(), status, "{outcome:?}");
7505 let body = response.into_body().collect().await.unwrap().to_bytes();
7506 let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
7507 let error = body["error"].as_str().unwrap();
7508 assert!(error.contains(guidance), "{outcome:?} answered {error:?}");
7509 }
7510 }
7511
7512 #[tokio::test]
7513 async fn the_viewer_shows_a_session_whose_action_failed_after_it_was_accepted() {
7514 let (app, _, _, _, _) = app();
7518 let script = fetch_text(app, "/viewer.js").await;
7519 assert!(script.contains("has_error"), "viewer ignores has_error");
7520 }
7521
7522 #[tokio::test]
7525 async fn every_response_carries_the_security_headers() {
7526 for path in [
7527 "/",
7528 "/viewer.js",
7529 "/voice-worklet.js",
7530 "/voice-worker.js",
7531 "/viewer.css",
7532 "/manifest.webmanifest",
7533 "/api/snapshot",
7534 ] {
7535 let (app, _, _, _, _) = app();
7536 let response = app
7537 .oneshot(Request::get(path).body(Body::empty()).unwrap())
7538 .await
7539 .unwrap();
7540 let headers = response.headers();
7541 let policy = headers
7542 .get(CONTENT_SECURITY_POLICY_HEADER)
7543 .unwrap_or_else(|| panic!("{path} carries no content-security policy"))
7544 .to_str()
7545 .unwrap();
7546 assert!(
7547 policy.starts_with("default-src 'none';"),
7548 "{path} does not refuse unlisted sources: {policy}"
7549 );
7550 assert!(
7551 policy.contains("script-src 'self'") && !policy.contains("unsafe-inline"),
7552 "{path} permits inline script: {policy}"
7553 );
7554 assert!(
7555 policy.contains("frame-ancestors 'none'"),
7556 "{path} can be framed: {policy}"
7557 );
7558 assert_eq!(
7559 headers.get(X_CONTENT_TYPE_OPTIONS).unwrap(),
7560 "nosniff",
7561 "{path} permits content sniffing"
7562 );
7563 assert_eq!(
7564 headers.get(REFERRER_POLICY).unwrap(),
7565 "no-referrer",
7566 "{path} leaks a referrer"
7567 );
7568 }
7569 }
7570
7571 #[tokio::test]
7575 async fn the_page_carries_no_inline_script_or_style() {
7576 let (app, _, _, _, _) = app();
7577 let page = fetch_text(app, "/").await;
7578 assert!(
7579 !page.contains("<script>") && !page.contains("<style>"),
7580 "the page inlines script or style, which the policy blocks"
7581 );
7582 assert!(
7583 page.contains(r#"src="/viewer.js""#) && page.contains(r#"href="/viewer.css""#),
7584 "the page does not load its script and style as separate assets"
7585 );
7586 }
7587
7588 #[tokio::test]
7591 async fn live_state_and_the_service_worker_are_never_stored() {
7592 for path in ["/", "/service-worker.js", "/api/snapshot"] {
7593 let (app, _, _, _, _) = app();
7594 let response = app
7595 .oneshot(Request::get(path).body(Body::empty()).unwrap())
7596 .await
7597 .unwrap();
7598 assert_eq!(
7599 response.headers().get(CACHE_CONTROL).unwrap(),
7600 "no-store",
7601 "{path} may be stored"
7602 );
7603 }
7604 }
7605
7606 #[test]
7609 fn the_service_worker_declines_to_handle_live_state() {
7610 assert!(
7611 SERVICE_WORKER.contains("url.pathname.startsWith('/api/')"),
7612 "the service worker does not exclude the API"
7613 );
7614 assert!(
7615 SERVICE_WORKER.contains("url.pathname.startsWith('/auth/')"),
7616 "the service worker does not exclude authentication"
7617 );
7618 assert!(
7619 SERVICE_WORKER.contains("caches.delete"),
7620 "the service worker never deletes a superseded cache"
7621 );
7622 }
7623
7624 #[tokio::test]
7627 async fn the_installable_assets_are_served() {
7628 for (path, content_type) in [
7629 ("/icon-192.png", "image/png"),
7630 ("/icon-512.png", "image/png"),
7631 ("/maskable-512.png", "image/png"),
7632 ("/apple-touch-icon.png", "image/png"),
7633 ("/fonts/jetbrains-mono.woff2", "font/woff2"),
7634 ] {
7635 let (app, _, _, _, _) = app();
7636 let response = app
7637 .oneshot(Request::get(path).body(Body::empty()).unwrap())
7638 .await
7639 .unwrap();
7640 assert_eq!(response.status(), StatusCode::OK, "{path} is not served");
7641 assert_eq!(
7642 response.headers().get(CONTENT_TYPE).unwrap(),
7643 content_type,
7644 "{path} is served as the wrong type"
7645 );
7646 }
7647 }
7648
7649 async fn fetch_text(app: Router, path: &str) -> String {
7653 let response = app
7654 .oneshot(Request::get(path).body(Body::empty()).unwrap())
7655 .await
7656 .unwrap();
7657 assert_eq!(response.status(), StatusCode::OK, "{path} is not served");
7658 let body = response.into_body().collect().await.unwrap().to_bytes();
7659 String::from_utf8(body.to_vec()).expect("assets are UTF-8")
7660 }
7661
7662 #[tokio::test]
7663 async fn repeated_wrong_codes_lock_the_login_endpoint() {
7664 let (app, _, _, _, _) = app();
7665 let attempt = |code: &'static str| {
7666 let app = app.clone();
7667 async move {
7668 app.oneshot(
7669 Request::post("/auth/session")
7670 .header(CONTENT_TYPE, "application/json")
7671 .body(Body::from(format!(r#"{{"code":"{code}"}}"#)))
7672 .unwrap(),
7673 )
7674 .await
7675 .unwrap()
7676 .status()
7677 }
7678 };
7679 for _ in 0..MAX_CODE_FAILURES {
7680 assert_eq!(attempt("000000").await, StatusCode::UNAUTHORIZED);
7681 }
7682 assert_eq!(attempt("000000").await, StatusCode::TOO_MANY_REQUESTS);
7683 assert_eq!(attempt("123456").await, StatusCode::TOO_MANY_REQUESTS);
7686 }
7687
7688 #[test]
7689 fn viewer_code_lockouts_lengthen_instead_of_resetting_after_every_wait() {
7690 let serve_one_lockout = |guard: &mut CodeGuard, now: Instant| {
7691 for _ in 0..MAX_CODE_FAILURES {
7692 assert!(!guard.locked_at(now));
7693 guard.record_failure_at(now);
7694 }
7695 assert!(guard.locked_at(now));
7696 guard.locked_until.expect("the guard is locked") - now
7697 };
7698
7699 let start = Instant::now();
7700 let mut guard = CodeGuard::default();
7701 let first = serve_one_lockout(&mut guard, start);
7702 assert_eq!(first, CODE_LOCKOUT_BASE);
7703
7704 let second_round = start + first;
7708 let second = serve_one_lockout(&mut guard, second_round);
7709 assert_eq!(second, CODE_LOCKOUT_BASE * 2);
7710 let third = serve_one_lockout(&mut guard, second_round + second);
7711 assert_eq!(third, CODE_LOCKOUT_BASE * 4);
7712 assert_eq!(code_lockout(u32::MAX), CODE_LOCKOUT_CAP);
7713
7714 let mut recovered = CodeGuard::default();
7717 assert_eq!(serve_one_lockout(&mut recovered, start), CODE_LOCKOUT_BASE);
7718 }
7719
7720 #[test]
7721 fn persisted_cookie_key_survives_a_restart_and_stays_owner_only() {
7722 let directory = tempfile::tempdir().unwrap();
7723 let path = directory.path().join("phone-cookie-key");
7724
7725 let first = load_or_create_cookie_key(&path).unwrap();
7726 assert!(first.len() >= COOKIE_KEY_BYTES);
7727 assert_eq!(std::fs::read(&path).unwrap(), first);
7728 #[cfg(unix)]
7729 {
7730 use std::os::unix::fs::PermissionsExt as _;
7731 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
7732 assert_eq!(mode & 0o777, 0o600);
7733 }
7734
7735 let mut restarted = detached_options();
7738 restarted
7739 .set_cookie_key(load_or_create_cookie_key(&path).unwrap())
7740 .unwrap();
7741 let mut original = detached_options();
7742 original.set_cookie_key(first.clone()).unwrap();
7743 let cookie = signed_cookie_value(&original.cookie_key, "test-viewer", 200);
7744 assert!(session_cookie_valid(&restarted.cookie_key, &cookie, 100));
7745 assert!(!session_cookie_valid(
7746 &detached_options().cookie_key,
7747 &cookie,
7748 100
7749 ));
7750
7751 std::fs::remove_file(&path).unwrap();
7753 let rotated = load_or_create_cookie_key(&path).unwrap();
7754 assert_ne!(rotated, first);
7755 assert!(!session_cookie_valid(&rotated, &cookie, 100));
7756 }
7757
7758 #[test]
7759 fn corrupt_cookie_key_is_regenerated_instead_of_blocking_startup() {
7760 let directory = tempfile::tempdir().unwrap();
7761 let path = directory.path().join("phone-cookie-key");
7762 std::fs::write(&path, b"short").unwrap();
7763
7764 let key = load_or_create_cookie_key(&path).unwrap();
7765
7766 assert!(key.len() >= COOKIE_KEY_BYTES);
7767 assert_eq!(std::fs::read(&path).unwrap(), key);
7768 assert_eq!(load_or_create_cookie_key(&path).unwrap(), key);
7769 }
7770}