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