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