use std::collections::BTreeMap;
use std::convert::Infallible;
use std::net::SocketAddr;
use std::path::{Component, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result as AnyResult};
use axum::body::{Body, Bytes, to_bytes};
use axum::extract::{DefaultBodyLimit, Path, Query, Request, State};
use axum::http::header::{
CACHE_CONTROL, CONTENT_SECURITY_POLICY as CONTENT_SECURITY_POLICY_HEADER, CONTENT_TYPE, COOKIE,
HeaderValue, LOCATION, REFERRER_POLICY, SET_COOKIE, X_CONTENT_TYPE_OPTIONS,
};
use axum::http::{HeaderMap, Response, StatusCode};
use axum::middleware::Next;
use axum::response::IntoResponse;
use axum::response::sse::{Event, KeepAlive, Sse};
use axum::routing::{get, post, put};
use axum::{Json, Router};
use base64::Engine as _;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use tokio::sync::Semaphore;
use tokio::sync::{mpsc, watch};
use tokio_stream::wrappers::ReceiverStream;
use tokio_util::sync::CancellationToken;
use hel::hel_attachment::{AttachmentRef, AttachmentStore, MAX_IMAGE_BYTES, MAX_IMAGES};
use hel::hel_config::{HelConfig, TargetTemplate, project_history_host, validate_id};
use hel::hel_elicitation::{ElicitationRequest, ElicitationResponse, MAX_ELICITATION_BYTES};
use hel::hel_state::{
HelState, MoveOperation, MovePhase, MovePreparation, MoveSelection, MoveSessionRequest,
ProjectSourceIdentity, SessionResourceAllocation, SessionState, SessionTransitionKind,
};
use hel::hel_targets::AdditionalMount;
use crate::hel_dictation::{
DictationError, DictationOperation, DictationRequest, DictationResponse, MAX_AUDIO_BYTES,
validate_wav,
};
use crate::hel_image::optimize_image;
pub use hel::hel_state::ResumeQueueDisposition;
pub fn install_rustls_crypto_provider() {
let _ = rustls::crypto::ring::default_provider().install_default();
}
pub const COOKIE_NAME: &str = "hel_viewer_session";
const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(30 * 24 * 60 * 60);
const EPHEMERAL_SESSION_TTL: Duration = Duration::from_secs(24 * 60 * 60);
const MAX_BODY_BYTES: usize = 128 * 1024;
const MAX_CODE_FAILURES: u32 = 5;
const CODE_LOCKOUT_BASE: Duration = Duration::from_secs(30);
const CODE_LOCKOUT_CAP: Duration = Duration::from_secs(60 * 60);
const MAX_TITLE_CHARS: usize = 120;
const MAX_PROMPT_CHARS: usize = 64 * 1024;
const MAX_DIRTY_ACKNOWLEDGEMENTS: usize = 32;
const MAX_DRAFT_BYTES: usize = 64 * 1024;
pub const MAX_HISTORY_MATCHES: usize = 40;
const MAX_PROMPT_BODY_BYTES: usize = 32 * 1024 * 1024;
const MAX_ATTACHMENT_UPLOAD_BYTES: usize = 64 * 1024 * 1024;
const MAX_CONCURRENT_DICTATIONS: usize = 2;
pub const MAX_PROMPT_IMAGES: usize = MAX_IMAGES;
const COOKIE_KEY_BYTES: usize = 32;
const COOKIE_KEY_FILE: &str = "phone-cookie-key";
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BrowserTranscript {
pub latest_seq: u64,
pub window_start_seq: u64,
pub reset: bool,
pub entries: Vec<BrowserTranscriptEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BrowserTranscriptEntry {
pub id: u64,
pub updated_seq: u64,
pub role: &'static str,
pub label: String,
pub recorded_at_ms: Option<i64>,
pub lines: Vec<String>,
pub glyph: &'static str,
pub tone: &'static str,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_status: Option<&'static str>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub diffstats: Vec<BrowserDiffStat>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BrowserDiffStat {
pub path: String,
pub insertions: u32,
pub deletions: u32,
}
pub const fn default_session_ttl() -> Duration {
DEFAULT_SESSION_TTL
}
pub fn cookie_key_path() -> PathBuf {
hel::hel_config::data_dir().join(COOKIE_KEY_FILE)
}
pub fn load_or_create_cookie_key(path: &std::path::Path) -> AnyResult<Vec<u8>> {
match std::fs::read(path) {
Ok(key) if key.len() >= COOKIE_KEY_BYTES => return Ok(key),
Ok(key) => tracing::warn!(
path = %path.display(),
bytes = key.len(),
"phone cookie key is shorter than {COOKIE_KEY_BYTES} bytes; generating a new key signs every phone out"
),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => tracing::warn!(
path = %path.display(),
"could not read the phone cookie key ({error}); generating a new key signs every phone out"
),
}
let key = generate_cookie_key()?;
hel::hel_config::atomic_write(path, &key)
.with_context(|| format!("persist Mjolnir phone cookie key {}", path.display()))?;
Ok(key.to_vec())
}
#[derive(Clone)]
pub struct ServerOptions {
pub bind: SocketAddr,
pub snapshot_rx: watch::Receiver<ViewerSnapshot>,
pub conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
pub action_tx: mpsc::Sender<ControllerRequest>,
pub bundle_tx: mpsc::Sender<BundleRequest>,
pub receipt_tx: mpsc::Sender<ReadReceiptRequest>,
pub preflight_tx: mpsc::Sender<PreflightRequest>,
pub move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
pub client_state_tx: mpsc::Sender<ClientStateRequest>,
pub dictation_tx: mpsc::Sender<DictationRequest>,
pub shutdown: CancellationToken,
pub session_ttl: Duration,
pub secure_cookie: bool,
tls_config: Option<axum_server::tls_rustls::RustlsConfig>,
viewer_code: String,
login_token: String,
cookie_key: Vec<u8>,
}
pub struct ServerRequests {
pub action_tx: mpsc::Sender<ControllerRequest>,
pub bundle_tx: mpsc::Sender<BundleRequest>,
pub receipt_tx: mpsc::Sender<ReadReceiptRequest>,
pub preflight_tx: mpsc::Sender<PreflightRequest>,
pub move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
pub client_state_tx: mpsc::Sender<ClientStateRequest>,
pub dictation_tx: mpsc::Sender<DictationRequest>,
}
impl ServerOptions {
pub fn new(
bind: SocketAddr,
snapshot_rx: watch::Receiver<ViewerSnapshot>,
conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
requests: ServerRequests,
) -> AnyResult<Self> {
Ok(Self {
bind,
snapshot_rx,
conversation_rx,
action_tx: requests.action_tx,
bundle_tx: requests.bundle_tx,
receipt_tx: requests.receipt_tx,
preflight_tx: requests.preflight_tx,
move_preparation_tx: requests.move_preparation_tx,
client_state_tx: requests.client_state_tx,
dictation_tx: requests.dictation_tx,
shutdown: CancellationToken::new(),
session_ttl: DEFAULT_SESSION_TTL,
secure_cookie: true,
tls_config: None,
viewer_code: generate_viewer_code()?,
login_token: generate_login_token()?,
cookie_key: generate_cookie_key()?.to_vec(),
})
}
pub fn viewer_code(&self) -> &str {
&self.viewer_code
}
pub fn login_token(&self) -> &str {
&self.login_token
}
pub fn set_tls_config(&mut self, config: axum_server::tls_rustls::RustlsConfig) {
self.tls_config = Some(config);
self.secure_cookie = true;
}
pub fn set_cookie_key(&mut self, key: Vec<u8>) -> AnyResult<()> {
anyhow::ensure!(
key.len() >= COOKIE_KEY_BYTES,
"cookie signing key must be at least {COOKIE_KEY_BYTES} bytes"
);
self.cookie_key = key;
Ok(())
}
#[cfg(test)]
fn with_test_credentials(mut self, code: &str, key: &[u8]) -> Self {
self.viewer_code = code.to_string();
self.login_token = "test-login-token".into();
self.cookie_key = key.to_vec();
self.secure_cookie = false;
self
}
}
pub async fn run_server(options: ServerOptions) -> AnyResult<()> {
let listener = tokio::net::TcpListener::bind(options.bind)
.await
.with_context(|| format!("bind web viewer to {}", options.bind))?;
run_server_on_listener(options, listener).await
}
pub async fn run_server_on_listener(
options: ServerOptions,
listener: tokio::net::TcpListener,
) -> AnyResult<()> {
let mut options = options;
let bind = listener.local_addr().context("read web viewer address")?;
let shutdown = options.shutdown.clone();
let viewer_code = options.viewer_code.clone();
let tls_config = options.tls_config.take();
let app = router(options);
println!("Mjolnir viewer code: {viewer_code}");
let listener = listener.into_std().context("prepare web viewer listener")?;
let handle = axum_server::Handle::new();
let shutdown_handle = handle.clone();
let serve = async move {
if let Some(tls_config) = tls_config {
axum_server::from_tcp_rustls(listener, tls_config)
.handle(handle)
.serve(app.into_make_service())
.await
} else {
axum_server::from_tcp(listener)
.handle(handle)
.serve(app.into_make_service())
.await
}
};
tokio::pin!(serve);
tokio::select! {
result = &mut serve => result,
_ = shutdown.cancelled() => {
shutdown_handle.graceful_shutdown(Some(Duration::from_secs(2)));
serve.await
}
}
.with_context(|| format!("serve web viewer on {bind}"))
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum WebViewerAccess {
Starting,
Ready {
viewer_url: String,
viewer_code: String,
qr_login_url: Option<String>,
fallback_reason: Option<String>,
},
Failed {
address: SocketAddr,
message: String,
port_conflict: bool,
},
Unavailable(String),
}
impl std::fmt::Debug for WebViewerAccess {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Starting => formatter.write_str("Starting"),
Self::Ready {
viewer_url,
fallback_reason,
..
} => formatter
.debug_struct("Ready")
.field("viewer_url", viewer_url)
.field("credentials", &"[redacted]")
.field("fallback_reason", fallback_reason)
.finish(),
Self::Failed {
address,
message,
port_conflict,
} => formatter
.debug_struct("Failed")
.field("address", address)
.field("message", message)
.field("port_conflict", port_conflict)
.finish(),
Self::Unavailable(message) => {
formatter.debug_tuple("Unavailable").field(message).finish()
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WebListenerProcess {
pub pid: u32,
pub name: String,
pub executable: PathBuf,
pub started_at: u64,
pub stop_disabled_reason: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum WebViewerRecovery {
Retry,
AnotherPort,
StopAndRetry(WebListenerProcess),
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerSnapshot {
pub revision: u64,
pub generated_at: String,
#[serde(default)]
pub server_time_ms: i64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub workspaces: Vec<ViewerWorkspace>,
pub sessions: Vec<ViewerSession>,
pub profiles: Vec<ViewerProfile>,
pub targets: Vec<ViewerTarget>,
pub bundles: Vec<ViewerBundle>,
#[serde(default)]
pub review_config: ViewerReviewConfig,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub capacity: Vec<ViewerTargetCapacity>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub launch_failures: Vec<ViewerLaunchFailure>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ViewerLaunchFailure {
pub id: String,
pub workspace_id: String,
}
impl ViewerSnapshot {
pub fn from_config_state(config: &HelConfig, state: &HelState, revision: u64) -> Self {
let sessions = state
.sessions
.values()
.map(|session| {
let incompatible = config
.targets
.keys()
.filter(|target_id| {
crate::hel_controller::resume_compatibility(session, config, target_id)
.is_err()
})
.cloned()
.collect::<Vec<_>>();
let lifecycle = ViewerLifecycleCategory::of(session.state);
let source = session.project_source(config);
ViewerSession {
id: session.id.clone(),
workspace_id: session.workspace_id.clone(),
title: session.display_title().to_owned(),
harness_kind: session.harness_kind.id().into(),
profile_id: session.last_profile.clone(),
bundle_id: session.bundle_id.clone(),
target_id: session.target_template_id.clone(),
state: session_state_name(session.state).into(),
created_at: session.created_at.clone(),
updated_at: session.updated_at.clone(),
has_error: session.last_error.is_some(),
preview: Vec::new(),
queued_prompts: Vec::new(),
active_user_shells: Vec::new(),
pending_elicitations: Vec::new(),
conversation_available: false,
prompt_images_supported: false,
incompatible_resume_targets: incompatible.clone(),
compatible_resume_targets: config
.targets
.keys()
.filter(|target_id| !incompatible.contains(*target_id))
.cloned()
.collect(),
project_label: source.short,
project_key: project_key(&source.key),
display_location: session.project_target(config, &session.target_template_id),
lifecycle,
transitioning: session.state.transition_kind().is_some(),
latest_event_ordinal: 0,
last_activity_at_ms: None,
activity_details: None,
activity: String::new(),
operation: None,
move_recovery: None,
chat_phase: ViewerChatPhase::default(),
is_idle: false,
config_options: Vec::new(),
plan_mode_active: None,
turn_review: None,
available_commands: Vec::new(),
capabilities: ViewerSessionCapabilities {
open: false,
prompt: false,
run_shell: false,
cancel_turn: false,
cancel_operation: false,
stop: lifecycle.is_dashboard_visible(),
rename: true,
resume: !lifecycle.is_dashboard_visible(),
move_session: false,
set_config: false,
set_plan_mode: false,
},
}
})
.collect();
let profiles = config
.profiles
.iter()
.map(|(id, profile)| ViewerProfile {
id: id.clone(),
harness_kind: profile.kind.id().into(),
quota: None,
})
.collect();
let targets = config
.targets
.iter()
.map(|(id, target)| ViewerTarget {
id: id.clone(),
kind: target_kind_name(target).into(),
requires_project_directory: matches!(
target,
TargetTemplate::LocalBare | TargetTemplate::SshBare { .. }
),
recent_project_directories: project_history_host(target)
.map(|host| {
state
.project_directories(host)
.iter()
.map(|directory| directory.to_string_lossy().into_owned())
.collect()
})
.unwrap_or_default(),
})
.collect();
let bundles = config
.bundles
.iter()
.map(|(id, bundle)| ViewerBundle {
id: id.clone(),
primary_repository: bundle.primary_repo.clone(),
repositories: bundle
.repositories
.iter()
.map(|repository| ViewerRepository {
id: repository.id.clone(),
github: repository.github.clone(),
destination: repository.destination.to_string_lossy().into_owned(),
})
.collect(),
})
.collect();
Self {
revision,
generated_at: now_unix().to_string(),
server_time_ms: hel::clock::epoch_millis(),
workspaces: Vec::new(),
sessions,
profiles,
targets,
bundles,
review_config: ViewerReviewConfig {
enabled: config.review.enabled,
tier: config.review.tier.label().to_owned(),
profile: config.review.profile.clone(),
},
capacity: Vec::new(),
launch_failures: Vec::new(),
}
}
}
fn project_key(identity: &str) -> String {
use sha2::Digest as _;
let digest = Sha256::digest(identity.as_bytes());
digest[..8]
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerSession {
pub id: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub workspace_id: String,
pub title: String,
pub harness_kind: String,
pub profile_id: String,
pub bundle_id: String,
pub target_id: String,
pub state: String,
pub created_at: String,
pub updated_at: String,
pub has_error: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub preview: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub queued_prompts: Vec<ViewerQueuedPrompt>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub active_user_shells: Vec<ViewerUserShell>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub pending_elicitations: Vec<ElicitationRequest>,
pub conversation_available: bool,
#[serde(default)]
pub prompt_images_supported: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub incompatible_resume_targets: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub compatible_resume_targets: Vec<String>,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub project_label: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub project_key: String,
#[serde(default)]
pub display_location: String,
pub lifecycle: ViewerLifecycleCategory,
#[serde(default)]
pub transitioning: bool,
#[serde(default)]
pub latest_event_ordinal: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_activity_at_ms: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub activity_details: Option<ViewerActivityDetails>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operation: Option<ViewerOperation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub move_recovery: Option<ViewerMoveRecovery>,
#[serde(default)]
pub chat_phase: ViewerChatPhase,
#[serde(default)]
pub is_idle: bool,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub activity: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub config_options: Vec<ViewerConfigOption>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub plan_mode_active: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub turn_review: Option<ViewerTurnReview>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub available_commands: Vec<ViewerMjCommand>,
pub capabilities: ViewerSessionCapabilities,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerMoveRecovery {
pub operation_id: String,
pub source_profile_id: String,
pub source_target_template_id: String,
pub destination_profile_id: String,
pub destination_target_template_id: String,
pub phase: String,
pub queue: String,
pub clear_resource_allocation: bool,
#[serde(default)]
pub source_additional_mounts: Vec<AdditionalMount>,
#[serde(default)]
pub source_resource_allocation: Option<SessionResourceAllocation>,
#[serde(default)]
pub destination_additional_mounts: Vec<AdditionalMount>,
#[serde(default)]
pub destination_resource_allocation: Option<SessionResourceAllocation>,
pub checkpoint_retained: bool,
pub destination_ready: bool,
pub queue_admission_started: bool,
pub queue_admission_finished: bool,
}
impl ViewerMoveRecovery {
#[must_use]
pub fn from_operation(operation: &MoveOperation) -> Option<Self> {
if matches!(operation.phase, MovePhase::Completed) {
return None;
}
Some(Self {
operation_id: operation.operation_id.clone(),
source_profile_id: operation.source_profile_id.clone(),
source_target_template_id: operation.source_target_template_id.clone(),
destination_profile_id: operation.selection.profile_id.clone().unwrap_or_default(),
destination_target_template_id: operation
.selection
.target_template_id
.clone()
.unwrap_or_default(),
phase: match operation.phase {
MovePhase::Preparing => "preparing",
MovePhase::ClosingSource => "closing_source",
MovePhase::ResumingDestination => "resuming_destination",
MovePhase::StartingQueue => "starting_queue",
MovePhase::Completed => "completed",
MovePhase::Failed => "failed",
MovePhase::Cancelled => "cancelled",
}
.into(),
queue: match operation.queue {
ResumeQueueDisposition::Start => "start",
ResumeQueueDisposition::Discard => "discard",
}
.into(),
clear_resource_allocation: operation.selection.clear_resource_allocation,
source_additional_mounts: operation.source_additional_mounts.clone(),
source_resource_allocation: operation.source_resource_allocation.clone(),
destination_additional_mounts: operation
.selection
.additional_mounts
.clone()
.unwrap_or_default(),
destination_resource_allocation: operation.selection.resource_allocation.clone(),
checkpoint_retained: operation.checkpoint.is_some(),
destination_ready: operation.destination_target.is_some()
&& operation.destination_native_session_id.is_some(),
queue_admission_started: operation.queue_admission_started,
queue_admission_finished: operation.queue_admission_finished,
})
}
}
impl ViewerSession {
pub fn set_project_source(&mut self, source: &ProjectSourceIdentity) {
self.project_label = source.short.clone();
self.project_key = project_key(&source.key);
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerActivityDetails {
pub kind: ViewerActivityKind,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub turn_started_at_ms: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub step_started_at_ms: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub background_started_at_ms: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub idle_since_ms: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ViewerActivityKind {
Turn,
Step,
Background,
Idle,
Lifecycle,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerMjCommand {
pub name: String,
pub description: String,
pub source: ViewerCommandSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub argument: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ViewerCommandSource {
Mj,
Agent,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerReviewConfig {
pub enabled: bool,
pub tier: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerTurnReview {
pub tier: String,
pub status: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub roles: Vec<ViewerReviewRole>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub verdict: Option<ViewerReviewVerdict>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerReviewRole {
pub label: String,
pub state: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerReviewVerdict {
pub kind: String,
pub text: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed: Vec<String>,
}
impl ViewerTurnReview {
#[must_use]
pub fn from_runtime(review: &crate::hel_review_host::RuntimeReviewView) -> Self {
Self {
tier: review.tier.label().to_owned(),
status: review.status.clone(),
roles: review
.roles
.iter()
.map(|role| ViewerReviewRole {
label: role.label.clone(),
state: role.state.label().to_owned(),
})
.collect(),
verdict: review.verdict.as_ref().map(|verdict| ViewerReviewVerdict {
kind: match verdict.kind {
crate::hel_review_host::VerdictKind::Clean => "clean",
crate::hel_review_host::VerdictKind::Findings => "findings",
crate::hel_review_host::VerdictKind::Failed => "failed",
}
.to_owned(),
text: verdict.text.clone(),
allowed: verdict
.allowed
.iter()
.filter_map(resolution_name)
.map(str::to_owned)
.collect(),
}),
}
}
}
#[must_use]
pub fn resolution_name(resolution: &hel::hel_review::driver::Resolution) -> Option<&'static str> {
match resolution {
hel::hel_review::driver::Resolution::Forwarded => Some("forward"),
hel::hel_review::driver::Resolution::Dismissed => Some("dismiss"),
hel::hel_review::driver::Resolution::Cancelled => Some("cancel"),
hel::hel_review::driver::Resolution::NothingToReview
| hel::hel_review::driver::Resolution::CoverageStarted => None,
}
}
#[must_use]
pub fn resolution_from_name(name: &str) -> Option<hel::hel_review::driver::Resolution> {
match name {
"forward" => Some(hel::hel_review::driver::Resolution::Forwarded),
"dismiss" => Some(hel::hel_review::driver::Resolution::Dismissed),
"cancel" => Some(hel::hel_review::driver::Resolution::Cancelled),
_ => None,
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerWorkspace {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerQueuedPrompt {
pub id: String,
pub text: String,
pub created_at: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerUserShell {
pub id: String,
pub command: String,
pub started_at_ms: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerProfile {
pub id: String,
pub harness_kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quota: Option<ViewerQuota>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerQuotaWindow {
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub percent_used: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resets_at: Option<String>,
pub projects_exhaustion_before_reset: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerQuota {
pub summary: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub windows: Vec<ViewerQuotaWindow>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resets_at: Option<String>,
pub stale: bool,
#[serde(default)]
pub refreshed_at_epoch_seconds: u64,
pub has_error: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerTargetCapacity {
pub id: String,
pub label: String,
pub target_ids: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cpu_percent: Option<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_used_bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub memory_total_bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logical_cores: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disk_total_bytes: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub virtual_machines: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sampled_at_epoch_seconds: Option<u64>,
pub refreshing: bool,
pub stale: bool,
pub has_error: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerTarget {
pub id: String,
pub kind: String,
pub requires_project_directory: bool,
#[serde(default)]
pub recent_project_directories: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerBundle {
pub id: String,
pub primary_repository: String,
pub repositories: Vec<ViewerRepository>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerRepository {
pub id: String,
pub github: Option<String>,
pub destination: String,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerSessionCapabilities {
pub open: bool,
pub prompt: bool,
pub run_shell: bool,
pub cancel_turn: bool,
pub cancel_operation: bool,
pub stop: bool,
pub rename: bool,
pub resume: bool,
#[serde(default)]
pub move_session: bool,
pub set_config: bool,
pub set_plan_mode: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ViewerLifecycleCategory {
Live,
Starting,
Stopping,
Stopped,
Failed,
}
impl ViewerLifecycleCategory {
const fn of(state: SessionState) -> Self {
match state {
SessionState::Provisioning => Self::Starting,
SessionState::Running | SessionState::Disconnected | SessionState::Checkpointing => {
Self::Live
}
SessionState::Closing | SessionState::Destroying => Self::Stopping,
SessionState::Stopped => Self::Stopped,
SessionState::Lost | SessionState::Error | SessionState::DestroyedWithDataLoss => {
Self::Failed
}
}
}
pub const fn is_dashboard_visible(self) -> bool {
matches!(self, Self::Live | Self::Starting | Self::Stopping)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ViewerOperationKind {
Create,
Resume,
Move,
Stop,
Destroy,
Cleanup,
Checkpoint,
}
impl ViewerOperationKind {
pub const fn transition_kind(self) -> Option<SessionTransitionKind> {
match self {
Self::Create => Some(SessionTransitionKind::Starting),
Self::Resume => Some(SessionTransitionKind::Resuming),
Self::Move => Some(SessionTransitionKind::Moving),
Self::Stop => Some(SessionTransitionKind::Stopping),
Self::Destroy | Self::Cleanup => Some(SessionTransitionKind::Destroying),
Self::Checkpoint => None,
}
}
pub const fn label(self) -> &'static str {
match self {
Self::Create => "Starting",
Self::Resume => "Resuming",
Self::Move => "Moving",
Self::Stop => "Stopping",
Self::Destroy => "Destroying",
Self::Cleanup => "Cleaning up",
Self::Checkpoint => "Checkpointing",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerOperationStage {
pub label: String,
pub started_at_epoch_seconds: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerOperation {
pub id: String,
pub session_id: String,
pub kind: ViewerOperationKind,
pub started_at_epoch_seconds: u64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub stages: Vec<ViewerOperationStage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notice: Option<String>,
pub cancellable: bool,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum ViewerChatPhase {
#[default]
Idle,
Running,
Closing,
Closed,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerConfigChoice {
pub value: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerConfigOption {
pub key: String,
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current: Option<String>,
pub choices: Vec<ViewerConfigChoice>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "action", rename_all = "kebab-case", deny_unknown_fields)]
pub enum ControllerAction {
New {
#[serde(default)]
workspace_id: String,
profile_id: String,
bundle_id: String,
target_id: String,
#[serde(default)]
title: Option<String>,
#[serde(default)]
project_directory: Option<PathBuf>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
dirty_ack: Vec<String>,
},
Rename {
session_id: String,
title: String,
},
CancelTurn {
session_id: String,
},
SetConfig {
session_id: String,
key: String,
value: String,
},
SetPlanMode {
session_id: String,
active: bool,
},
RefreshQuota {
profile_id: String,
},
RefreshCapacity {
target_id: String,
},
Resume {
session_id: String,
workspace_id: String,
profile_id: String,
target_id: String,
queue: ResumeQueueDisposition,
#[serde(default)]
additional_mounts: Option<Vec<AdditionalMount>>,
#[serde(default)]
resource_allocation: Option<SessionResourceAllocation>,
},
Move {
request: MoveSessionRequest,
},
Open {
session_id: String,
},
Prompt {
session_id: String,
text: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
images: Vec<ViewerPromptImage>,
},
RunShell {
session_id: String,
command: String,
},
CancelShell {
session_id: String,
shell_command_id: String,
},
Close {
session_id: String,
},
Cancel {
session_id: String,
},
StartReview {
session_id: String,
},
ResolveReview {
session_id: String,
resolution: String,
},
RemoveQueuedPrompt {
session_id: String,
queue_id: String,
},
RespondElicitation {
session_id: String,
elicitation_id: String,
response: ElicitationResponse,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerPromptImage {
#[serde(default)]
pub data_base64: String,
pub mime_type: String,
pub width: u32,
pub height: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub attachment: Option<AttachmentRef>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ActionOutcome {
Accepted,
Busy,
SessionBusy,
NotCancellable,
Failed,
}
impl ActionOutcome {
const fn rejection(self) -> Option<ApiError> {
match self {
Self::Accepted => None,
Self::Busy => Some(ApiError::new(
StatusCode::TOO_MANY_REQUESTS,
"the controller is at its concurrent action limit; retry shortly",
)),
Self::SessionBusy => Some(ApiError::new(
StatusCode::CONFLICT,
"another operation is already running for this session",
)),
Self::NotCancellable => Some(ApiError::new(
StatusCode::CONFLICT,
"the session has no cancellable operation",
)),
Self::Failed => Some(ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"the controller could not start this action",
)),
}
}
}
#[derive(Debug)]
pub struct ControllerRequest {
pub action: ControllerAction,
pub reply: tokio::sync::oneshot::Sender<ActionOutcome>,
}
#[derive(Debug)]
pub struct BundleRequest {
pub source: String,
pub reply: tokio::sync::oneshot::Sender<Result<String, BundleFailure>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BundleFailure {
InvalidSource,
Controller,
}
#[derive(Debug)]
pub struct PreflightRequest {
pub bundle_id: String,
pub target_id: String,
pub project_directory: Option<PathBuf>,
pub reply: tokio::sync::oneshot::Sender<Result<PreflightNew, PreflightFailure>>,
}
#[derive(Debug)]
pub struct MovePreparationRequest {
pub selection: MoveSelection,
pub reply: tokio::sync::oneshot::Sender<Result<MovePreparation, String>>,
}
#[derive(Debug)]
pub enum PreflightFailure {
Validation,
Controller(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PreflightNew {
pub dirty_repositories: Vec<String>,
}
#[derive(Debug)]
pub enum ClientStateRequest {
Read {
client_id: String,
session_id: String,
reply: tokio::sync::oneshot::Sender<Result<ViewerClientState, String>>,
},
SaveDraft {
client_id: String,
session_id: String,
draft: String,
reply: tokio::sync::oneshot::Sender<Result<(), String>>,
},
MarkWorkspaceRead {
client_id: String,
workspace_id: String,
reply: tokio::sync::oneshot::Sender<Result<(), String>>,
},
History {
session_id: String,
query: String,
scope: String,
reply: tokio::sync::oneshot::Sender<Result<ViewerPromptHistory, String>>,
},
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerClientState {
pub draft: String,
pub through_event_ordinal: u64,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerPromptHistory {
pub entries: Vec<String>,
pub truncated: bool,
}
#[derive(Debug)]
pub struct ReadReceiptRequest {
pub client_id: String,
pub session_id: String,
pub through: u64,
pub reply: tokio::sync::oneshot::Sender<Result<(), String>>,
}
#[derive(Clone)]
struct ServerState {
snapshot_rx: watch::Receiver<ViewerSnapshot>,
conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
action_tx: mpsc::Sender<ControllerRequest>,
bundle_tx: mpsc::Sender<BundleRequest>,
receipt_tx: mpsc::Sender<ReadReceiptRequest>,
preflight_tx: mpsc::Sender<PreflightRequest>,
move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
client_state_tx: mpsc::Sender<ClientStateRequest>,
dictation_tx: mpsc::Sender<DictationRequest>,
dictation_permits: Arc<Semaphore>,
dictation_probe_permits: Arc<Semaphore>,
shutdown: CancellationToken,
viewer_code: Arc<str>,
login_token: Arc<str>,
cookie_key: Arc<[u8]>,
session_ttl: Duration,
secure_cookie: bool,
code_guard: Arc<Mutex<CodeGuard>>,
}
#[derive(Debug, Default)]
struct CodeGuard {
failures: u32,
lockouts: u32,
locked_until: Option<Instant>,
}
impl CodeGuard {
fn locked_at(&mut self, now: Instant) -> bool {
match self.locked_until {
Some(until) if now < until => true,
Some(_) => {
self.locked_until = None;
self.failures = 0;
false
}
None => false,
}
}
fn record_failure_at(&mut self, now: Instant) {
self.failures = self.failures.saturating_add(1);
if self.failures < MAX_CODE_FAILURES {
return;
}
self.failures = 0;
self.lockouts = self.lockouts.saturating_add(1);
self.locked_until = Some(now + code_lockout(self.lockouts));
}
}
fn code_lockout(lockouts: u32) -> Duration {
let multiplier = 1_u32
.checked_shl(lockouts.saturating_sub(1))
.unwrap_or(u32::MAX);
CODE_LOCKOUT_BASE
.saturating_mul(multiplier)
.min(CODE_LOCKOUT_CAP)
}
fn router(options: ServerOptions) -> Router {
let state = ServerState {
snapshot_rx: options.snapshot_rx,
conversation_rx: options.conversation_rx,
action_tx: options.action_tx,
bundle_tx: options.bundle_tx,
receipt_tx: options.receipt_tx,
preflight_tx: options.preflight_tx,
move_preparation_tx: options.move_preparation_tx,
client_state_tx: options.client_state_tx,
dictation_tx: options.dictation_tx,
dictation_permits: Arc::new(Semaphore::new(MAX_CONCURRENT_DICTATIONS)),
dictation_probe_permits: Arc::new(Semaphore::new(MAX_CONCURRENT_DICTATIONS)),
shutdown: options.shutdown,
viewer_code: options.viewer_code.into(),
login_token: options.login_token.into(),
cookie_key: options.cookie_key.into(),
session_ttl: options.session_ttl,
secure_cookie: options.secure_cookie,
code_guard: Arc::new(Mutex::new(CodeGuard::default())),
};
let protected = Router::new()
.route("/api/snapshot", get(snapshot))
.route("/api/conversations/{session_id}", get(conversation))
.route(
"/api/conversations/{session_id}/read",
post(mark_conversation_read),
)
.route("/api/events", get(events))
.route("/api/bundles", post(create_bundle))
.route("/api/preflight/new", post(preflight_new))
.route("/api/moves/prepare", post(prepare_move))
.route("/api/sessions/{session_id}/client-state", get(client_state))
.route(
"/api/sessions/{session_id}/dictation",
get(dictation_availability).post(upload_dictation),
)
.route(
"/api/sessions/{session_id}/attachments",
post(upload_attachment).layer(DefaultBodyLimit::max(MAX_ATTACHMENT_UPLOAD_BYTES)),
)
.route(
"/api/sessions/{session_id}/draft",
put(save_draft).layer(DefaultBodyLimit::max(MAX_DRAFT_BYTES)),
)
.route("/api/sessions/{session_id}/history", get(prompt_history))
.route(
"/api/workspaces/{workspace_id}/read",
post(mark_workspace_read),
)
.route(
"/api/actions",
post(action).layer(DefaultBodyLimit::max(MAX_PROMPT_BODY_BYTES)),
)
.route_layer(axum::middleware::from_fn_with_state(
state.clone(),
require_session,
));
Router::new()
.route("/", get(viewer))
.route("/login", get(viewer))
.route("/viewer.css", get(viewer_css))
.route("/viewer.js", get(viewer_js))
.route("/voice-worklet.js", get(voice_worklet_js))
.route("/voice-worker.js", get(voice_worker_js))
.route("/markdown.js", get(markdown_js))
.route("/tool-output.js", get(tool_output_js))
.route("/manifest.webmanifest", get(manifest))
.route("/service-worker.js", get(service_worker))
.route("/icon.svg", get(icon))
.route("/icon-192.png", get(icon_192))
.route("/icon-512.png", get(icon_512))
.route("/maskable-512.png", get(maskable_512))
.route("/apple-touch-icon.png", get(apple_touch_icon))
.route("/fonts/jetbrains-mono.woff2", get(mono_font))
.route("/auth/session", post(create_session).delete(clear_session))
.route("/auth/login", get(create_session_from_query))
.merge(protected)
.layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
.layer(axum::middleware::from_fn(security_headers))
.with_state(state)
}
async fn require_session(
State(state): State<ServerState>,
request: Request,
next: Next,
) -> Result<Response<Body>, ApiError> {
let cookie = request
.headers()
.get(COOKIE)
.and_then(|value| value.to_str().ok())
.and_then(|header| cookie_value(header, COOKIE_NAME));
if cookie.is_some_and(|value| session_cookie_valid(&state.cookie_key, value, now_unix())) {
Ok(next.run(request).await)
} else {
Err(ApiError::unauthorized())
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct LoginRequest {
code: String,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct LoginQuery {
token: String,
}
async fn create_session_from_query(
State(state): State<ServerState>,
Query(query): Query<LoginQuery>,
) -> Result<Response<Body>, ApiError> {
if !constant_time_eq(state.login_token.as_bytes(), query.token.trim().as_bytes()) {
return Err(ApiError::unauthorized());
}
let mut response = issue_session_cookie(&state, StatusCode::SEE_OTHER)?;
response
.headers_mut()
.insert(LOCATION, HeaderValue::from_static("/"));
response
.headers_mut()
.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
Ok(response)
}
async fn create_session(
State(state): State<ServerState>,
Json(request): Json<LoginRequest>,
) -> Result<Response<Body>, ApiError> {
if code_locked(&state) {
return Err(ApiError::new(
StatusCode::TOO_MANY_REQUESTS,
"too many incorrect codes; wait and try again",
));
}
if !constant_time_eq(state.viewer_code.as_bytes(), request.code.trim().as_bytes()) {
record_code_failure(&state);
return Err(ApiError::unauthorized());
}
reset_code_failures(&state);
issue_session_cookie(&state, StatusCode::NO_CONTENT)
}
fn issue_session_cookie(
state: &ServerState,
status: StatusCode,
) -> Result<Response<Body>, ApiError> {
let ephemeral = state.session_ttl.is_zero();
let validity = if ephemeral {
EPHEMERAL_SESSION_TTL
} else {
state.session_ttl
};
let value = signed_cookie_value(
&state.cookie_key,
&generate_viewer_id().map_err(|_| {
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "cookie creation failed")
})?,
now_unix().saturating_add(validity.as_secs()),
);
let cookie = session_cookie_header(
&value,
(!ephemeral).then_some(validity.as_secs()),
state.secure_cookie,
)?;
let mut response = status.into_response();
response.headers_mut().insert(SET_COOKIE, cookie);
Ok(response)
}
async fn clear_session(State(state): State<ServerState>) -> Response<Body> {
let mut response = StatusCode::NO_CONTENT.into_response();
response
.headers_mut()
.insert(SET_COOKIE, clear_cookie_header(state.secure_cookie));
response
}
async fn snapshot(State(state): State<ServerState>) -> Response<Body> {
let mut projection = state.snapshot_rx.borrow().clone();
projection.server_time_ms = hel::clock::epoch_millis();
let mut response = Json(projection).into_response();
response
.headers_mut()
.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
response
}
async fn upload_attachment(
State(state): State<ServerState>,
Path(session_id): Path<String>,
body: Bytes,
) -> Result<Json<ViewerPromptImage>, ApiError> {
validate_public_id(&session_id)?;
let prompt_images_supported = {
let snapshot = state.snapshot_rx.borrow();
require_session_record(&snapshot, &session_id)?.prompt_images_supported
};
if !prompt_images_supported {
return Err(ApiError::bad_request(
"this session does not support image prompts",
));
}
if body.is_empty() {
return Err(ApiError::bad_request("image upload must not be empty"));
}
let result = tokio::task::spawn_blocking(move || {
let optimized = optimize_image(&body).map_err(|_| {
ApiError::bad_request("unsupported image format or image could not be decoded")
})?;
if optimized.bytes.is_empty() || optimized.bytes.len() > MAX_IMAGE_BYTES {
return Err(ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"the image optimizer returned an invalid image size",
));
}
let reference = AttachmentRef::new(
&optimized.bytes,
optimized.mime_type.clone(),
optimized.width,
optimized.height,
)
.map_err(|_| {
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"could not create image attachment",
)
})?;
let store = AttachmentStore::controller(&session_id).map_err(|_| {
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"could not open the image attachment store",
)
})?;
store.install(&reference, &optimized.bytes).map_err(|_| {
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"could not store the image attachment",
)
})?;
Ok(ViewerPromptImage {
data_base64: String::new(),
mime_type: reference.mime_type.clone(),
width: reference.width,
height: reference.height,
attachment: Some(reference),
})
})
.await
.map_err(|_| {
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"the server could not process the image upload",
)
})??;
Ok(Json(result))
}
async fn action(
State(state): State<ServerState>,
Json(action): Json<ControllerAction>,
) -> Result<StatusCode, ApiError> {
validate_action(&action, &state.snapshot_rx.borrow())?;
let action = decode_prompt_images_off_task(action).await?;
let (reply, outcome) = tokio::sync::oneshot::channel();
state
.action_tx
.send(ControllerRequest { action, reply })
.await
.map_err(|_| ApiError::controller_unavailable())?;
let outcome = outcome
.await
.map_err(|_| ApiError::controller_unavailable())?;
match outcome.rejection() {
Some(rejection) => Err(rejection),
None => Ok(StatusCode::ACCEPTED),
}
}
const MAX_BUNDLE_SOURCE_CHARS: usize = 1024;
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct CreateBundleRequest {
source: String,
}
#[derive(Debug, Serialize)]
struct CreateBundleResponse {
bundle_id: String,
}
async fn create_bundle(
State(state): State<ServerState>,
Json(request): Json<CreateBundleRequest>,
) -> Result<Json<CreateBundleResponse>, ApiError> {
if request.source.trim().is_empty() {
return Err(ApiError::bad_request("repository source cannot be empty"));
}
if request.source.chars().count() > MAX_BUNDLE_SOURCE_CHARS {
return Err(ApiError::bad_request(
"repository source must contain 1024 characters or fewer",
));
}
let (reply, result) = tokio::sync::oneshot::channel();
state
.bundle_tx
.send(BundleRequest {
source: request.source,
reply,
})
.await
.map_err(|_| ApiError::controller_unavailable())?;
let bundle_id = result
.await
.map_err(|_| ApiError::controller_unavailable())?
.map_err(|failure| match failure {
BundleFailure::InvalidSource => ApiError::bad_request(
"use a GitHub owner/repository or an existing Git checkout on the controller host",
),
BundleFailure::Controller => ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"the controller could not create the bundle",
),
})?;
Ok(Json(CreateBundleResponse { bundle_id }))
}
#[derive(Debug, Deserialize)]
struct ConversationQuery {
after_seq: Option<u64>,
}
async fn conversation(
State(state): State<ServerState>,
Path(session_id): Path<String>,
Query(query): Query<ConversationQuery>,
) -> Result<Json<BrowserTranscript>, ApiError> {
validate_public_id(&session_id)?;
let transitioning = {
let snapshot = state.snapshot_rx.borrow();
require_session_record(&snapshot, &session_id)?.transitioning
};
if transitioning {
return Err(ApiError::new(
StatusCode::CONFLICT,
"conversation unavailable while the session is transitioning",
));
}
let conversations = state.conversation_rx.borrow();
let transcript = conversations
.get(&session_id)
.ok_or_else(|| ApiError::not_found("conversation unavailable"))?;
let mut response = transcript.clone();
if let Some(after) = query.after_seq {
response.reset = after < response.window_start_seq;
if !response.reset {
response.entries.retain(|entry| entry.updated_seq > after);
}
}
Ok(Json(response))
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct ReadRequest {
through: u64,
}
async fn mark_conversation_read(
State(state): State<ServerState>,
Path(session_id): Path<String>,
headers: HeaderMap,
Json(request): Json<ReadRequest>,
) -> Result<StatusCode, ApiError> {
validate_public_id(&session_id)?;
let transitioning = {
let snapshot = state.snapshot_rx.borrow();
require_session_record(&snapshot, &session_id)?.transitioning
};
if transitioning {
return Err(ApiError::new(
StatusCode::CONFLICT,
"conversation unavailable while the session is transitioning",
));
}
let (reply, result) = tokio::sync::oneshot::channel();
let client_id = viewer_client_id(&state, &headers).ok_or_else(ApiError::unauthorized)?;
state
.receipt_tx
.send(ReadReceiptRequest {
client_id,
session_id,
through: request.through,
reply,
})
.await
.map_err(|_| ApiError::controller_unavailable())?;
result
.await
.map_err(|_| ApiError::controller_unavailable())?
.map_err(|_| ApiError::new(StatusCode::CONFLICT, "read receipt failed"))?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct PreflightNewRequest {
#[serde(default)]
workspace_id: String,
profile_id: String,
bundle_id: String,
target_id: String,
#[serde(default)]
project_directory: Option<PathBuf>,
}
async fn preflight_new(
State(state): State<ServerState>,
Json(request): Json<PreflightNewRequest>,
) -> Result<Json<PreflightNew>, ApiError> {
let project_validation = request.project_directory.is_some();
let action = ControllerAction::New {
workspace_id: request.workspace_id,
profile_id: request.profile_id,
bundle_id: request.bundle_id.clone(),
target_id: request.target_id.clone(),
title: None,
project_directory: request.project_directory.clone(),
dirty_ack: Vec::new(),
};
validate_action(&action, &state.snapshot_rx.borrow())?;
let (reply, result) = tokio::sync::oneshot::channel();
state
.preflight_tx
.send(PreflightRequest {
bundle_id: request.bundle_id,
target_id: request.target_id,
project_directory: request.project_directory,
reply,
})
.await
.map_err(|_| ApiError::controller_unavailable())?;
result
.await
.map_err(|_| ApiError::controller_unavailable())?
.map(Json)
.map_err(|failure| match failure {
PreflightFailure::Validation if project_validation => ApiError::bad_request(
"project validation failed; check that the directory exists, is accessible, and contains a Git repository with a valid HEAD",
),
PreflightFailure::Validation | PreflightFailure::Controller(_) => ApiError::new(
StatusCode::SERVICE_UNAVAILABLE,
"the controller could not check this project",
),
})
}
async fn prepare_move(
State(state): State<ServerState>,
Json(selection): Json<MoveSelection>,
) -> Result<Json<MovePreparation>, ApiError> {
validate_move_selection(&selection, &state.snapshot_rx.borrow())?;
let (reply, result) = tokio::sync::oneshot::channel();
state
.move_preparation_tx
.send(MovePreparationRequest { selection, reply })
.await
.map_err(|_| ApiError::controller_unavailable())?;
let preparation = result
.await
.map_err(|_| ApiError::controller_unavailable())?
.map_err(|error| {
tracing::debug!(error = %error, "move preparation was rejected");
ApiError::new(
StatusCode::CONFLICT,
"move preparation was rejected; refresh and try again",
)
})?;
Ok(Json(inspector_move_preparation(preparation)))
}
fn inspector_move_preparation(mut preparation: MovePreparation) -> MovePreparation {
for command in &mut preparation.queued_commands {
for block in &mut command.content {
if block.get("type").and_then(serde_json::Value::as_str) != Some("image") {
continue;
}
let mime = block
.get("mimeType")
.or_else(|| block.get("mime_type"))
.and_then(serde_json::Value::as_str)
.unwrap_or("image");
*block = serde_json::json!({
"type": "text",
"text": format!("[Image attachment: {mime}]")
});
}
}
preparation
}
async fn ask_client_state<T>(
state: &ServerState,
build: impl FnOnce(tokio::sync::oneshot::Sender<Result<T, String>>) -> ClientStateRequest,
) -> Result<T, ApiError> {
let (reply, answer) = tokio::sync::oneshot::channel();
state
.client_state_tx
.send(build(reply))
.await
.map_err(|_| ApiError::controller_unavailable())?;
answer
.await
.map_err(|_| ApiError::controller_unavailable())?
.map_err(|_| {
ApiError::new(
StatusCode::SERVICE_UNAVAILABLE,
"the controller could not reach stored viewer state",
)
})
}
async fn client_state(
State(state): State<ServerState>,
Path(session_id): Path<String>,
headers: HeaderMap,
) -> Result<Json<ViewerClientState>, ApiError> {
validate_public_id(&session_id)?;
require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
let Some(client_id) = viewer_client_id(&state, &headers) else {
return Ok(Json(ViewerClientState::default()));
};
ask_client_state(&state, |reply| ClientStateRequest::Read {
client_id,
session_id,
reply,
})
.await
.map(Json)
}
#[derive(Debug, Serialize)]
struct DictationAvailability {
available: bool,
#[serde(skip_serializing_if = "Option::is_none")]
reason: Option<String>,
}
#[derive(Debug, Serialize)]
struct DictationTranscript {
text: String,
}
async fn dictation_availability(
State(state): State<ServerState>,
Path(session_id): Path<String>,
) -> Result<Json<DictationAvailability>, ApiError> {
validate_public_id(&session_id)?;
require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
let _permit = state
.dictation_probe_permits
.clone()
.try_acquire_owned()
.map_err(|_| ApiError::new(StatusCode::TOO_MANY_REQUESTS, "too many dictation requests"))?;
let result = dispatch_dictation(&state, session_id, DictationOperation::Availability).await?;
match result {
DictationResponse::Availability { available, reason } => {
Ok(Json(DictationAvailability { available, reason }))
}
DictationResponse::Transcript { .. } => Err(ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"the controller returned an invalid dictation response",
)),
}
}
async fn upload_dictation(
State(state): State<ServerState>,
Path(session_id): Path<String>,
request: Request,
) -> Result<Json<DictationTranscript>, ApiError> {
validate_public_id(&session_id)?;
require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
let _permit = state
.dictation_permits
.clone()
.try_acquire_owned()
.map_err(|_| ApiError::new(StatusCode::TOO_MANY_REQUESTS, "too many dictation requests"))?;
if request
.headers()
.get(axum::http::header::CONTENT_LENGTH)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse::<u64>().ok())
.is_some_and(|length| length > MAX_AUDIO_BYTES as u64)
{
return Err(ApiError::new(
StatusCode::PAYLOAD_TOO_LARGE,
"audio upload is too large",
));
}
let body = tokio::select! {
biased;
_ = state.shutdown.cancelled() => return Err(ApiError::controller_unavailable()),
result = tokio::time::timeout(
crate::hel_dictation::DICTATION_TIMEOUT,
to_bytes(request.into_body(), MAX_AUDIO_BYTES),
) => match result {
Ok(result) => result.map_err(|_| {
ApiError::new(StatusCode::PAYLOAD_TOO_LARGE, "audio upload is too large")
})?,
Err(_) => return Err(ApiError::new(
StatusCode::GATEWAY_TIMEOUT,
"dictation upload timed out",
)),
},
};
let audio = body.clone();
tokio::task::spawn_blocking(move || validate_wav(&audio))
.await
.map_err(|error| {
tracing::warn!(%error, "dictation audio validation task failed");
ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "audio validation failed")
})?
.map_err(dictation_api_error)?;
let result =
dispatch_dictation(&state, session_id, DictationOperation::Transcribe(body)).await?;
match result {
DictationResponse::Transcript { text } => Ok(Json(DictationTranscript { text })),
DictationResponse::Availability { .. } => Err(ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"the controller returned an invalid dictation response",
)),
}
}
struct DictationCancellationGuard(CancellationToken);
impl Drop for DictationCancellationGuard {
fn drop(&mut self) {
self.0.cancel();
}
}
async fn dispatch_dictation(
state: &ServerState,
session_id: String,
operation: DictationOperation,
) -> Result<DictationResponse, ApiError> {
let cancel = CancellationToken::new();
let _guard = DictationCancellationGuard(cancel.clone());
let (reply, answer) = tokio::sync::oneshot::channel();
let request = DictationRequest {
session_id,
operation,
cancel: cancel.clone(),
reply,
};
tokio::select! {
biased;
_ = state.shutdown.cancelled() => return Err(ApiError::controller_unavailable()),
result = state.dictation_tx.send(request) => {
result.map_err(|_| ApiError::controller_unavailable())?;
}
}
let answer = tokio::select! {
biased;
_ = state.shutdown.cancelled() => return Err(ApiError::controller_unavailable()),
result = answer => result.map_err(|_| ApiError::controller_unavailable())?,
};
answer.map_err(dictation_api_error)
}
fn dictation_api_error(error: DictationError) -> ApiError {
match error {
DictationError::SessionNotFound => ApiError::not_found("unknown session"),
DictationError::CredentialsUnavailable => ApiError::new(
StatusCode::SERVICE_UNAVAILABLE,
"dictation is unavailable because no Codex subscription is signed in",
),
DictationError::InvalidAudio(message) => ApiError::bad_request(message),
DictationError::Cancelled => {
ApiError::new(StatusCode::REQUEST_TIMEOUT, "dictation cancelled")
}
DictationError::TimedOut => ApiError::new(
StatusCode::GATEWAY_TIMEOUT,
"dictation transcription timed out",
),
DictationError::CredentialProbe => ApiError::new(
StatusCode::SERVICE_UNAVAILABLE,
"dictation credentials could not be checked",
),
DictationError::Provider(error) => {
tracing::warn!(%error, "Codex dictation transcription failed");
ApiError::new(StatusCode::BAD_GATEWAY, "dictation transcription failed")
}
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct DraftRequest {
draft: String,
}
async fn save_draft(
State(state): State<ServerState>,
Path(session_id): Path<String>,
headers: HeaderMap,
Json(request): Json<DraftRequest>,
) -> Result<StatusCode, ApiError> {
validate_public_id(&session_id)?;
require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
if request.draft.len() > MAX_DRAFT_BYTES {
return Err(ApiError::new(
StatusCode::PAYLOAD_TOO_LARGE,
"draft must be 65536 bytes or fewer",
));
}
let Some(client_id) = viewer_client_id(&state, &headers) else {
return Err(ApiError::new(
StatusCode::CONFLICT,
"this viewer has no stored identity; unlock again to keep drafts",
));
};
ask_client_state(&state, |reply| ClientStateRequest::SaveDraft {
client_id,
session_id,
draft: request.draft,
reply,
})
.await?;
Ok(StatusCode::NO_CONTENT)
}
async fn mark_workspace_read(
State(state): State<ServerState>,
Path(workspace_id): Path<String>,
headers: HeaderMap,
) -> Result<StatusCode, ApiError> {
validate_public_id(&workspace_id)?;
let Some(client_id) = viewer_client_id(&state, &headers) else {
return Ok(StatusCode::NO_CONTENT);
};
ask_client_state(&state, |reply| ClientStateRequest::MarkWorkspaceRead {
client_id,
workspace_id,
reply,
})
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Debug, Deserialize)]
struct HistoryQuery {
#[serde(default)]
q: String,
#[serde(default)]
scope: Option<String>,
}
async fn prompt_history(
State(state): State<ServerState>,
Path(session_id): Path<String>,
Query(query): Query<HistoryQuery>,
) -> Result<Json<ViewerPromptHistory>, ApiError> {
validate_public_id(&session_id)?;
require_session_record(&state.snapshot_rx.borrow(), &session_id)?;
if query.q.chars().count() > MAX_TITLE_CHARS {
return Err(ApiError::bad_request("search text is too long"));
}
let scope = query.scope.unwrap_or_else(|| "project".to_owned());
if !matches!(scope.as_str(), "session" | "project" | "all") {
return Err(ApiError::bad_request(
"scope must be session, project or all",
));
}
ask_client_state(&state, |reply| ClientStateRequest::History {
session_id,
query: query.q,
scope,
reply,
})
.await
.map(Json)
}
async fn events(State(state): State<ServerState>) -> impl IntoResponse {
let mut snapshots = state.snapshot_rx.clone();
let (tx, rx) = mpsc::channel::<Result<Event, Infallible>>(8);
tokio::spawn(async move {
let initial = snapshots.borrow().revision;
if tx
.send(Ok(Event::default()
.event("revision")
.data(initial.to_string())))
.await
.is_err()
{
return;
}
while snapshots.changed().await.is_ok() {
let revision = snapshots.borrow_and_update().revision;
if tx
.send(Ok(Event::default()
.event("revision")
.data(revision.to_string())))
.await
.is_err()
{
break;
}
}
});
Sse::new(ReceiverStream::new(rx)).keep_alive(KeepAlive::default())
}
async fn decode_prompt_images_off_task(
action: ControllerAction,
) -> Result<ControllerAction, ApiError> {
let ControllerAction::Prompt { images, .. } = &action else {
return Ok(action);
};
if images.is_empty() {
return Ok(action);
}
tokio::task::spawn_blocking(move || {
let mut action = action;
let ControllerAction::Prompt {
session_id, images, ..
} = &action
else {
unreachable!("only prompt actions carry images")
};
validate_prompt_images(images)?;
let store = AttachmentStore::controller(session_id).map_err(|_| {
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"could not open the image attachment store",
)
})?;
let ControllerAction::Prompt { images, .. } = &mut action else {
unreachable!("only prompt actions carry images")
};
for image in images {
if let Some(reference) = image.attachment.clone() {
store
.read(&reference)
.map_err(|_| ApiError::bad_request("the image attachment is unavailable"))?;
image.data_base64.clear();
image.mime_type = reference.mime_type;
image.width = reference.width;
image.height = reference.height;
} else {
let bytes = base64::engine::general_purpose::STANDARD
.decode(&image.data_base64)
.map_err(|_| ApiError::bad_request("image data must be valid base64"))?;
let optimized = optimize_image(&bytes).map_err(|_| {
ApiError::bad_request("unsupported image format or image could not be decoded")
})?;
let reference = AttachmentRef::new(
&optimized.bytes,
optimized.mime_type.clone(),
optimized.width,
optimized.height,
)
.map_err(|_| {
ApiError::bad_request("the inline image could not become an attachment")
})?;
store.install(&reference, &optimized.bytes).map_err(|_| {
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"could not store the image attachment",
)
})?;
image.data_base64.clear();
image.attachment = Some(reference);
image.mime_type = optimized.mime_type;
image.width = optimized.width;
image.height = optimized.height;
}
}
Ok(action)
})
.await
.map_err(|_| {
ApiError::new(
StatusCode::INTERNAL_SERVER_ERROR,
"the server could not check the attached images",
)
})?
}
fn validate_prompt_images(images: &[ViewerPromptImage]) -> Result<(), ApiError> {
if images.len() > MAX_PROMPT_IMAGES {
return Err(ApiError::bad_request(
"a prompt may contain at most 10 images",
));
}
for image in images {
if !image.mime_type.starts_with("image/") {
return Err(ApiError::bad_request(
"image mime type must start with image/",
));
}
if image.width == 0 || image.height == 0 {
return Err(ApiError::bad_request(
"image dimensions must be greater than zero",
));
}
if let Some(reference) = &image.attachment {
if !image.data_base64.is_empty() {
return Err(ApiError::bad_request(
"an image cannot contain both inline data and an attachment",
));
}
if reference.mime_type != image.mime_type
|| reference.width != image.width
|| reference.height != image.height
{
return Err(ApiError::bad_request(
"image attachment metadata does not match the prompt",
));
}
continue;
}
let bytes = base64::engine::general_purpose::STANDARD
.decode(&image.data_base64)
.map_err(|_| ApiError::bad_request("image data must be valid base64"))?;
if bytes.is_empty() {
return Err(ApiError::bad_request("image data must not be empty"));
}
}
Ok(())
}
const MAX_MOVE_QUEUE_ITEMS: usize = 256;
const MAX_MOVE_MOUNTS: usize = 32;
fn validate_move_selection(
selection: &MoveSelection,
snapshot: &ViewerSnapshot,
) -> Result<(), ApiError> {
validate_public_id(&selection.session_id)?;
if selection.profile_id.is_none() && selection.target_template_id.is_none() {
return Err(ApiError::bad_request(
"move must select a profile, a target, or both",
));
}
if selection.clear_resource_allocation && selection.resource_allocation.is_some() {
return Err(ApiError::bad_request(
"clear resource sizing cannot be combined with an explicit allocation",
));
}
if let Some(profile_id) = selection.profile_id.as_deref() {
validate_public_id(profile_id)?;
require_profile(snapshot, profile_id)?;
}
if let Some(target_id) = selection.target_template_id.as_deref() {
validate_public_id(target_id)?;
require_target(snapshot, target_id)?;
let session = require_session_record(snapshot, &selection.session_id)?;
if session
.incompatible_resume_targets
.iter()
.any(|id| id == target_id)
{
return Err(ApiError::bad_request(
"this session cannot resume on that target",
));
}
} else {
require_session_record(snapshot, &selection.session_id)?;
}
if let Some(mounts) = &selection.additional_mounts {
validate_move_mounts(mounts)?;
}
let session = require_session_record(snapshot, &selection.session_id)?;
let retryable_move = session
.move_recovery
.as_ref()
.is_some_and(|recovery| recovery.checkpoint_retained);
if !session.capabilities.move_session && !retryable_move {
return Err(ApiError::new(
StatusCode::CONFLICT,
"this session cannot be moved now",
));
}
Ok(())
}
fn validate_move_mounts(mounts: &[AdditionalMount]) -> Result<(), ApiError> {
if mounts.len() > MAX_MOVE_MOUNTS {
return Err(ApiError::bad_request("a move may carry at most 32 mounts"));
}
for mount in mounts {
for path in [&mount.source, &mount.destination] {
if !path.is_absolute()
|| path
.components()
.any(|component| component == Component::ParentDir)
{
return Err(ApiError::bad_request(
"move mount paths must be absolute and must not contain '..'",
));
}
}
}
Ok(())
}
fn validate_resume_settings(
additional_mounts: Option<&Vec<AdditionalMount>>,
resource_allocation: Option<&SessionResourceAllocation>,
) -> Result<(), ApiError> {
if let Some(mounts) = additional_mounts {
validate_move_mounts(mounts)?;
}
if let Some(allocation) = resource_allocation {
allocation
.validate()
.map_err(|_| ApiError::bad_request("resource allocation is invalid"))?;
}
Ok(())
}
fn validate_move_request(
request: &MoveSessionRequest,
snapshot: &ViewerSnapshot,
) -> Result<(), ApiError> {
let preparation = &request.preparation;
validate_move_selection(&preparation.selection, snapshot)?;
let session = require_session_record(snapshot, &preparation.selection.session_id)?;
if preparation.operation_id.trim().is_empty() || preparation.fingerprint.trim().is_empty() {
return Err(ApiError::bad_request(
"move confirmation is missing its preparation identity",
));
}
if preparation.queued_commands.len() > MAX_MOVE_QUEUE_ITEMS {
return Err(ApiError::bad_request(
"move queue is too large; prepare again",
));
}
let active_now = preparation.active
|| session.chat_phase == ViewerChatPhase::Running
|| !session.active_user_shells.is_empty();
if active_now && !request.acknowledge_interruption {
return Err(ApiError::new(
StatusCode::CONFLICT,
"confirm that the active turn may be interrupted",
));
}
if !preparation.queued_commands.is_empty() && request.queue.is_none() {
return Err(ApiError::bad_request(
"choose whether queued work is discarded or started after the move",
));
}
Ok(())
}
fn validate_action(action: &ControllerAction, snapshot: &ViewerSnapshot) -> Result<(), ApiError> {
match action {
ControllerAction::New {
workspace_id,
profile_id,
bundle_id,
target_id,
title,
project_directory,
dirty_ack,
} => {
if !workspace_id.is_empty() {
validate_public_id(workspace_id)?;
}
validate_public_id(profile_id)?;
validate_public_id(bundle_id)?;
validate_public_id(target_id)?;
if let Some(title) = title {
validate_title(title)?;
}
if dirty_ack.len() > MAX_DIRTY_ACKNOWLEDGEMENTS
|| dirty_ack
.iter()
.any(|repository| repository.trim().is_empty() || repository.len() > 256)
{
return Err(ApiError::bad_request(
"dirty acknowledgement must name 0-32 repositories",
));
}
require_profile(snapshot, profile_id)?;
require_bundle(snapshot, bundle_id)?;
let target = require_target(snapshot, target_id)?;
if target.requires_project_directory != project_directory.is_some() {
return Err(ApiError::bad_request(
"project_directory is required exactly for bare targets",
));
}
if let Some(directory) = project_directory
&& (!directory.is_absolute()
|| directory
.components()
.any(|component| component == Component::ParentDir))
{
return Err(ApiError::bad_request(
"project_directory must be an absolute safe path",
));
}
}
ControllerAction::Resume {
session_id,
workspace_id,
profile_id,
target_id,
additional_mounts,
resource_allocation,
..
} => {
validate_public_id(session_id)?;
validate_public_id(workspace_id)?;
validate_public_id(profile_id)?;
validate_public_id(target_id)?;
let session = require_session_record(snapshot, session_id)?;
require_workspace(snapshot, workspace_id)?;
require_profile(snapshot, profile_id)?;
require_target(snapshot, target_id)?;
if session
.incompatible_resume_targets
.iter()
.any(|incompatible| incompatible == target_id)
{
return Err(ApiError::bad_request(
"this session cannot resume on that target",
));
}
validate_resume_settings(additional_mounts.as_ref(), resource_allocation.as_ref())?;
}
ControllerAction::Move { request } => validate_move_request(request, snapshot)?,
ControllerAction::Open { session_id }
| ControllerAction::Close { session_id }
| ControllerAction::Cancel { session_id }
| ControllerAction::StartReview { session_id } => {
validate_public_id(session_id)?;
require_session_record(snapshot, session_id)?;
}
ControllerAction::ResolveReview {
session_id,
resolution,
} => {
validate_public_id(session_id)?;
let session = require_session_record(snapshot, session_id)?;
let Some(resolution) = resolution_from_name(resolution) else {
return Err(ApiError::bad_request(
"a review is resolved by forward, dismiss, or cancel",
));
};
let Some(review) = session.turn_review.as_ref() else {
return Err(ApiError::bad_request("no review is open for that session"));
};
let allowed = resolution == hel::hel_review::driver::Resolution::Cancelled
|| review.verdict.as_ref().is_some_and(|verdict| {
resolution_name(&resolution)
.is_some_and(|name| verdict.allowed.iter().any(|allowed| allowed == name))
});
if !allowed {
return Err(ApiError::bad_request(
"that review cannot be resolved that way yet",
));
}
}
ControllerAction::Rename { session_id, title } => {
validate_public_id(session_id)?;
validate_title(title)?;
let session = require_session_record(snapshot, session_id)?;
if !session.capabilities.rename {
return Err(ApiError::bad_request("this session cannot be renamed"));
}
}
ControllerAction::CancelTurn { session_id } => {
validate_public_id(session_id)?;
let session = require_session_record(snapshot, session_id)?;
if !session.capabilities.cancel_turn {
return Err(ApiError::new(
StatusCode::CONFLICT,
"this session has no turn to cancel",
));
}
}
ControllerAction::SetConfig {
session_id,
key,
value,
} => {
validate_public_id(session_id)?;
let session = require_session_record(snapshot, session_id)?;
if !session.capabilities.set_config {
return Err(ApiError::bad_request(
"this session cannot change configuration now",
));
}
let option = session
.config_options
.iter()
.find(|option| option.key == *key)
.ok_or_else(|| ApiError::bad_request("this agent does not offer that setting"))?;
if !option.choices.iter().any(|choice| choice.value == *value) {
return Err(ApiError::bad_request(
"this agent does not offer that value for that setting",
));
}
}
ControllerAction::SetPlanMode { session_id, .. } => {
validate_public_id(session_id)?;
let session = require_session_record(snapshot, session_id)?;
if !session.capabilities.set_plan_mode {
return Err(ApiError::bad_request(
"this session cannot change plan mode now",
));
}
}
ControllerAction::RefreshQuota { profile_id } => {
validate_public_id(profile_id)?;
require_profile(snapshot, profile_id)?;
}
ControllerAction::RefreshCapacity { target_id } => {
validate_public_id(target_id)?;
require_target(snapshot, target_id)?;
}
ControllerAction::Prompt {
session_id,
text,
images,
} => {
validate_public_id(session_id)?;
let session = require_session_record(snapshot, session_id)?;
if images.len() > MAX_PROMPT_IMAGES {
return Err(ApiError::bad_request(
"a prompt may contain at most 10 images",
));
}
if text.starts_with('!') {
return Err(ApiError::bad_request(
"leading ! is reserved for shell commands",
));
}
if text.chars().count() > MAX_PROMPT_CHARS {
return Err(ApiError::bad_request(
"prompt must contain 1-65536 characters",
));
}
if text.trim().is_empty() && images.is_empty() {
return Err(ApiError::bad_request(
"prompt must contain text or an image",
));
}
if !images.is_empty() && !session.prompt_images_supported {
return Err(ApiError::bad_request(
"this session does not support image prompts",
));
}
if session.turn_review.is_some() {
return Err(ApiError::bad_request(
crate::hel_review_host::PROMPT_HELD_MESSAGE,
));
}
}
ControllerAction::RunShell {
session_id,
command,
} => {
validate_public_id(session_id)?;
require_session_record(snapshot, session_id)?;
if command.trim().is_empty() || command.chars().count() > MAX_PROMPT_CHARS {
return Err(ApiError::bad_request(
"shell command must contain 1-65536 characters",
));
}
}
ControllerAction::CancelShell {
session_id,
shell_command_id,
} => {
validate_public_id(session_id)?;
validate_public_id(shell_command_id)?;
let session = require_session_record(snapshot, session_id)?;
if !session
.active_user_shells
.iter()
.any(|shell| shell.id == *shell_command_id)
{
return Err(ApiError::bad_request("unknown active shell command"));
}
}
ControllerAction::RemoveQueuedPrompt {
session_id,
queue_id,
} => {
validate_public_id(session_id)?;
validate_public_id(queue_id)?;
require_session_record(snapshot, session_id)?;
}
ControllerAction::RespondElicitation {
session_id,
elicitation_id,
response,
} => {
validate_public_id(session_id)?;
validate_public_id(elicitation_id)?;
let session = require_session_record(snapshot, session_id)?;
let request = session
.pending_elicitations
.iter()
.find(|request| request.id == *elicitation_id)
.ok_or_else(|| ApiError::not_found("unknown elicitation"))?;
if serde_json::to_vec(response).map_or(usize::MAX, |encoded| encoded.len())
> MAX_ELICITATION_BYTES
{
return Err(ApiError::bad_request("elicitation answer is too large"));
}
if request.validate_response(response).is_err() {
return Err(ApiError::bad_request(
"the answer does not match this elicitation request",
));
}
}
}
Ok(())
}
fn validate_public_id(id: &str) -> Result<(), ApiError> {
validate_id("request", id).map_err(|_| ApiError::bad_request("invalid id"))
}
fn validate_title(title: &str) -> Result<(), ApiError> {
if title.trim().is_empty() || title.chars().count() > MAX_TITLE_CHARS {
Err(ApiError::bad_request("title must contain 1-120 characters"))
} else {
Ok(())
}
}
fn require_session_record<'a>(
snapshot: &'a ViewerSnapshot,
id: &str,
) -> Result<&'a ViewerSession, ApiError> {
snapshot
.sessions
.iter()
.find(|session| session.id == id)
.ok_or_else(|| ApiError::not_found("unknown session"))
}
fn require_workspace(snapshot: &ViewerSnapshot, id: &str) -> Result<(), ApiError> {
snapshot
.workspaces
.iter()
.any(|workspace| workspace.id == id)
.then_some(())
.ok_or_else(|| ApiError::bad_request("unknown workspace"))
}
fn require_profile<'a>(
snapshot: &'a ViewerSnapshot,
id: &str,
) -> Result<&'a ViewerProfile, ApiError> {
snapshot
.profiles
.iter()
.find(|profile| profile.id == id)
.ok_or_else(|| ApiError::bad_request("unknown profile"))
}
fn require_target<'a>(
snapshot: &'a ViewerSnapshot,
id: &str,
) -> Result<&'a ViewerTarget, ApiError> {
snapshot
.targets
.iter()
.find(|target| target.id == id)
.ok_or_else(|| ApiError::bad_request("unknown target"))
}
fn require_bundle(snapshot: &ViewerSnapshot, id: &str) -> Result<(), ApiError> {
snapshot
.bundles
.iter()
.any(|bundle| bundle.id == id)
.then_some(())
.ok_or_else(|| ApiError::bad_request("unknown bundle"))
}
#[derive(Debug, Serialize)]
struct ErrorBody<'a> {
error: &'a str,
}
#[derive(Debug)]
struct ApiError {
status: StatusCode,
message: &'static str,
}
impl ApiError {
const fn new(status: StatusCode, message: &'static str) -> Self {
Self { status, message }
}
const fn unauthorized() -> Self {
Self::new(StatusCode::UNAUTHORIZED, "unauthorized")
}
const fn bad_request(message: &'static str) -> Self {
Self::new(StatusCode::BAD_REQUEST, message)
}
const fn not_found(message: &'static str) -> Self {
Self::new(StatusCode::NOT_FOUND, message)
}
const fn controller_unavailable() -> Self {
Self::new(StatusCode::SERVICE_UNAVAILABLE, "controller unavailable")
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response<Body> {
(
self.status,
Json(ErrorBody {
error: self.message,
}),
)
.into_response()
}
}
fn code_locked(state: &ServerState) -> bool {
state
.code_guard
.lock()
.expect("viewer code guard poisoned")
.locked_at(Instant::now())
}
fn record_code_failure(state: &ServerState) {
state
.code_guard
.lock()
.expect("viewer code guard poisoned")
.record_failure_at(Instant::now());
}
fn reset_code_failures(state: &ServerState) {
*state.code_guard.lock().expect("viewer code guard poisoned") = CodeGuard::default();
}
fn generate_viewer_code() -> AnyResult<String> {
const RANGE: u32 = 1_000_000;
const LIMIT: u32 = u32::MAX - (u32::MAX % RANGE);
loop {
let mut bytes = [0_u8; 4];
getrandom::fill(&mut bytes)
.map_err(|error| anyhow::anyhow!("generate Mjolnir viewer code: {error}"))?;
let value = u32::from_le_bytes(bytes);
if value < LIMIT {
return Ok(format!("{:06}", value % RANGE));
}
}
}
fn generate_login_token() -> AnyResult<String> {
let mut token = [0_u8; 32];
getrandom::fill(&mut token)
.map_err(|error| anyhow::anyhow!("generate Mjolnir viewer login token: {error}"))?;
Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(token))
}
fn generate_cookie_key() -> AnyResult<[u8; COOKIE_KEY_BYTES]> {
let mut key = [0_u8; COOKIE_KEY_BYTES];
getrandom::fill(&mut key)
.map_err(|error| anyhow::anyhow!("generate Mjolnir cookie key: {error}"))?;
Ok(key)
}
fn generate_viewer_id() -> AnyResult<String> {
let mut id = [0_u8; 16];
getrandom::fill(&mut id)
.map_err(|error| anyhow::anyhow!("generate Mjolnir viewer id: {error}"))?;
Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(id))
}
fn signed_cookie_value(key: &[u8], viewer: &str, expiry: u64) -> String {
let canonical = format!("{viewer}|{expiry}");
let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts arbitrary key lengths");
mac.update(canonical.as_bytes());
let signature =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
format!("{viewer}.{expiry}.{signature}")
}
fn legacy_signed_cookie_value(key: &[u8], expiry: u64) -> String {
let canonical = expiry.to_string();
let mut mac = Hmac::<Sha256>::new_from_slice(key).expect("HMAC accepts arbitrary key lengths");
mac.update(canonical.as_bytes());
let signature =
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
format!("{canonical}.{signature}")
}
fn session_cookie_valid(key: &[u8], value: &str, now: u64) -> bool {
cookie_viewer(key, value, now).is_some()
}
pub fn mint_desktop_session_cookie(key: &[u8]) -> AnyResult<String> {
let viewer = generate_viewer_id()?;
Ok(signed_cookie_value(
key,
&viewer,
now_unix().saturating_add(EPHEMERAL_SESSION_TTL.as_secs()),
))
}
fn cookie_viewer(key: &[u8], value: &str, now: u64) -> Option<Option<String>> {
let parts = value.split('.').collect::<Vec<_>>();
let (viewer, expiry, expected) = match parts.as_slice() {
[viewer, expiry, _] => {
let expiry_value = expiry.parse::<u64>().ok()?;
(
Some((*viewer).to_owned()),
expiry_value,
signed_cookie_value(key, viewer, expiry_value),
)
}
[expiry, _] => {
let expiry_value = expiry.parse::<u64>().ok()?;
(
None,
expiry_value,
legacy_signed_cookie_value(key, expiry_value),
)
}
_ => return None,
};
if now >= expiry {
return None;
}
constant_time_eq(expected.as_bytes(), value.as_bytes()).then_some(viewer)
}
fn session_cookie_header(
value: &str,
max_age: Option<u64>,
secure: bool,
) -> Result<HeaderValue, ApiError> {
let mut header = format!("{COOKIE_NAME}={value}; Path=/; HttpOnly; SameSite=Strict");
if secure {
header.push_str("; Secure");
}
if let Some(max_age) = max_age {
header.push_str(&format!("; Max-Age={max_age}"));
}
HeaderValue::from_str(&header)
.map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "cookie creation failed"))
}
fn clear_cookie_header(secure: bool) -> HeaderValue {
let secure = if secure { "; Secure" } else { "" };
HeaderValue::from_str(&format!(
"{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Strict{secure}; Max-Age=0"
))
.expect("static cookie header is valid")
}
fn viewer_client_id(state: &ServerState, headers: &HeaderMap) -> Option<String> {
let cookie = headers
.get(COOKIE)
.and_then(|value| value.to_str().ok())
.and_then(|header| cookie_value(header, COOKIE_NAME))?;
cookie_viewer(&state.cookie_key, cookie, now_unix())
.flatten()
.map(|viewer| format!("phone:{viewer}"))
}
fn cookie_value<'a>(header: &'a str, name: &str) -> Option<&'a str> {
header
.split(';')
.filter_map(|part| part.trim().split_once('='))
.find(|(cookie_name, _)| *cookie_name == name)
.map(|(_, value)| value)
}
fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
if left.len() != right.len() {
return false;
}
left.iter()
.zip(right)
.fold(0_u8, |difference, (left, right)| {
difference | (left ^ right)
})
== 0
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|elapsed| elapsed.as_secs())
.unwrap_or(u64::MAX)
}
const fn session_state_name(state: SessionState) -> &'static str {
match state {
SessionState::Provisioning => "provisioning",
SessionState::Running => "running",
SessionState::Disconnected => "disconnected",
SessionState::Checkpointing => "checkpointing",
SessionState::Closing => "closing",
SessionState::Destroying => "destroying",
SessionState::Stopped => "stopped",
SessionState::Lost => "lost",
SessionState::Error => "error",
SessionState::DestroyedWithDataLoss => "destroyed-with-data-loss",
}
}
const fn target_kind_name(target: &TargetTemplate) -> &'static str {
match target {
TargetTemplate::LocalBare => "local-bare",
TargetTemplate::LocalPodman { .. } => "local-podman",
TargetTemplate::LocalDocker { .. } => "local-docker",
TargetTemplate::AppleContainer { .. } => "apple-container",
TargetTemplate::AwsEc2 { .. } => "aws-ec2",
TargetTemplate::SshBare { .. } => "ssh-bare",
TargetTemplate::SshPodman { .. } => "ssh-podman",
TargetTemplate::SshDocker { .. } => "ssh-docker",
}
}
const VIEWER_HTML: &str = include_str!("web/viewer.html");
const VIEWER_CSS: &str = include_str!("web/viewer.css");
const VIEWER_JS: &str = include_str!("web/viewer.js");
const MARKDOWN_JS: &str = include_str!("web/markdown.js");
const TOOL_OUTPUT_JS: &str = include_str!("web/tool-output.js");
const VOICE_WORKLET_JS: &str = include_str!("web/voice-worklet.js");
const VOICE_WORKER_JS: &str = include_str!("web/voice-worker.js");
#[cfg(test)]
const TEST_DOM_JS: &str = include_str!("web/test-dom.js");
const SERVICE_WORKER: &str = include_str!("web/service-worker.js");
const MANIFEST: &str = include_str!("web/manifest.webmanifest");
const ICON_SVG: &str = include_str!("../src/icons/icon.svg");
const ICON_192: &[u8] = include_bytes!("../src/icons/icon-192.png");
const ICON_512: &[u8] = include_bytes!("../src/icons/icon-512.png");
const MASKABLE_512: &[u8] = include_bytes!("../src/icons/maskable-512.png");
const APPLE_TOUCH_ICON: &[u8] = include_bytes!("../src/icons/apple-touch-icon.png");
const MONO_FONT: &[u8] = include_bytes!("../src/fonts/jetbrains-mono.woff2");
const CONTENT_SECURITY_POLICY: &str = "default-src 'none'; \
script-src 'self'; \
style-src 'self'; \
img-src 'self' data: blob:; \
font-src 'self'; \
connect-src 'self'; \
manifest-src 'self'; \
base-uri 'none'; \
form-action 'none'; \
frame-ancestors 'none'";
async fn viewer() -> Response<Body> {
static_response("text/html; charset=utf-8", VIEWER_HTML, true)
}
async fn viewer_css() -> Response<Body> {
static_response("text/css; charset=utf-8", VIEWER_CSS, false)
}
async fn viewer_js() -> Response<Body> {
static_response("text/javascript; charset=utf-8", VIEWER_JS, false)
}
async fn markdown_js() -> Response<Body> {
static_response("text/javascript; charset=utf-8", MARKDOWN_JS, false)
}
async fn voice_worklet_js() -> Response<Body> {
static_response("text/javascript; charset=utf-8", VOICE_WORKLET_JS, false)
}
async fn voice_worker_js() -> Response<Body> {
static_response("text/javascript; charset=utf-8", VOICE_WORKER_JS, false)
}
async fn tool_output_js() -> Response<Body> {
static_response("text/javascript; charset=utf-8", TOOL_OUTPUT_JS, false)
}
async fn manifest() -> Response<Body> {
static_response("application/manifest+json", MANIFEST, false)
}
async fn service_worker() -> Response<Body> {
static_response("text/javascript; charset=utf-8", SERVICE_WORKER, true)
}
async fn icon() -> Response<Body> {
static_response("image/svg+xml", ICON_SVG, false)
}
async fn icon_192() -> Response<Body> {
binary_response("image/png", ICON_192)
}
async fn icon_512() -> Response<Body> {
binary_response("image/png", ICON_512)
}
async fn maskable_512() -> Response<Body> {
binary_response("image/png", MASKABLE_512)
}
async fn apple_touch_icon() -> Response<Body> {
binary_response("image/png", APPLE_TOUCH_ICON)
}
async fn mono_font() -> Response<Body> {
binary_response("font/woff2", MONO_FONT)
}
fn static_response(
content_type: &'static str,
body: &'static str,
no_store: bool,
) -> Response<Body> {
finish_static(Response::new(Body::from(body)), content_type, no_store)
}
fn binary_response(content_type: &'static str, body: &'static [u8]) -> Response<Body> {
finish_static(Response::new(Body::from(body)), content_type, false)
}
fn finish_static(
mut response: Response<Body>,
content_type: &'static str,
no_store: bool,
) -> Response<Body> {
let headers = response.headers_mut();
headers.insert(CONTENT_TYPE, HeaderValue::from_static(content_type));
headers.insert(
CACHE_CONTROL,
HeaderValue::from_static(if no_store { "no-store" } else { "no-cache" }),
);
response
}
async fn security_headers(request: Request, next: Next) -> Response<Body> {
let live = {
let path = request.uri().path();
path.starts_with("/api/") || path.starts_with("/auth/")
};
let mut response = next.run(request).await;
let headers = response.headers_mut();
headers.insert(
CONTENT_SECURITY_POLICY_HEADER,
HeaderValue::from_static(CONTENT_SECURITY_POLICY),
);
headers.insert(X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
headers.insert(REFERRER_POLICY, HeaderValue::from_static("no-referrer"));
if live {
headers.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
}
response
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use std::path::Path;
use axum::http::Request;
use http_body_util::BodyExt as _;
use tower::ServiceExt as _;
use hel::hel_config::{
CONFIG_VERSION, ContainerTemplate, HarnessKind, HarnessProfile, PermissionMode,
ProjectBundle, ProjectRepository, SshConnection,
};
use hel::hel_state::{ProjectSourceIdentity, STATE_VERSION, SessionRecord};
#[test]
fn unified_tls_backends_use_the_selected_crypto_provider() {
install_rustls_crypto_provider();
assert!(rustls::crypto::CryptoProvider::get_default().is_some());
let _builder = rustls::ServerConfig::builder();
}
#[test]
fn minted_desktop_cookie_validates_and_names_a_viewer() {
let key = vec![7u8; COOKIE_KEY_BYTES];
let value = mint_desktop_session_cookie(&key).unwrap();
let viewer = cookie_viewer(&key, &value, now_unix());
assert!(
matches!(viewer, Some(Some(_))),
"minted cookie must validate and carry a viewer id: {value:?}"
);
assert!(!session_cookie_valid(
&[8u8; COOKIE_KEY_BYTES],
&value,
now_unix()
));
}
fn sample_config_state() -> (HelConfig, HelState) {
let config = HelConfig {
version: CONFIG_VERSION,
sessions_side: Default::default(),
show_stopped_sessions: true,
newer_config_version: None,
spinner: Default::default(),
theme: Default::default(),
phone: Default::default(),
review: Default::default(),
startup: Default::default(),
profiles: BTreeMap::from([(
"codex-1".into(),
HarnessProfile {
context_window_bytes: None,
kind: HarnessKind::Codex,
home: "/highly/secret/codex".into(),
environment: BTreeMap::from([("GH_TOKEN".into(), "secret-token".into())]),
},
)]),
bundles: BTreeMap::from([(
"hel".into(),
ProjectBundle {
primary_repo: "hel".into(),
repositories: vec![ProjectRepository {
id: "hel".into(),
github: Some("owner/hel".into()),
local: Some("/private/source/hel".into()),
destination: "hel".into(),
git_ref: None,
}],
},
)]),
targets: BTreeMap::from([
(
"podman".into(),
TargetTemplate::LocalPodman {
container: ContainerTemplate {
image: "secret.registry/image".into(),
pull_policy: Default::default(),
platform: None,
cpus: None,
memory: None,
environment: BTreeMap::from([("TOKEN".into(), "secret-target".into())]),
workspace_storage: Default::default(),
},
},
),
("raw".into(), TargetTemplate::LocalBare),
]),
};
let state = HelState {
version: STATE_VERSION,
sessions: BTreeMap::from([(
"session-1".into(),
SessionRecord {
workspace_id: hel::hel_workspace::DEFAULT_WORKSPACE_ID.to_owned(),
archived: false,
container_cpus: None,
container_memory: None,
id: "session-1".into(),
title: "Build Hel".into(),
harness_kind: HarnessKind::Codex,
last_profile: "codex-1".into(),
bundle_id: "hel".into(),
project_directory: None,
managed_worktree: None,
target_template_id: "podman".into(),
resource_allocation: None,
additional_mounts: vec![],
state: SessionState::Running,
target: None,
native_session_id: Some("native-secret-id".into()),
acp_session_title: Some("Build Hel".into()),
session_title_override: None,
created_at: "now".into(),
updated_at: "now".into(),
viewed_through_event_ordinal: 0,
draft_input: String::new(),
last_error: Some("secret-token at /highly/secret/codex".into()),
last_checkpoint_error: None,
checkpoint: None,
},
)]),
mount_history: BTreeMap::new(),
container_sizes: BTreeMap::new(),
};
(config, state)
}
type TestServer = (
Router,
mpsc::Receiver<ControllerRequest>,
mpsc::Receiver<ReadReceiptRequest>,
mpsc::Receiver<PreflightRequest>,
mpsc::Receiver<ClientStateRequest>,
);
fn app() -> TestServer {
app_with_conversations(BTreeMap::new())
}
fn app_with_move_receiver() -> (Router, mpsc::Receiver<MovePreparationRequest>) {
let (config, state) = sample_config_state();
let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
snapshot.sessions[0].capabilities.move_session = true;
let (_snapshot_tx, snapshot_rx) = watch::channel(snapshot);
let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
let (action_tx, _action_rx) = mpsc::channel(8);
let (bundle_tx, _bundle_rx) = mpsc::channel(8);
let (receipt_tx, _receipt_rx) = mpsc::channel(8);
let (preflight_tx, _preflight_rx) = mpsc::channel(8);
let (move_preparation_tx, move_preparation_rx) = mpsc::channel(8);
let (client_state_tx, _client_state_rx) = mpsc::channel(8);
let options = test_options(
snapshot_rx,
conversation_rx,
action_tx,
bundle_tx,
receipt_tx,
preflight_tx,
move_preparation_tx,
client_state_tx,
)
.with_test_credentials("123456", b"01234567890123456789012345678901");
(router(options), move_preparation_rx)
}
fn app_with_conversations(conversations: BTreeMap<String, BrowserTranscript>) -> TestServer {
app_with(conversations, |_| {})
}
fn app_with_snapshot(adjust: impl FnOnce(&mut ViewerSnapshot)) -> TestServer {
app_with(BTreeMap::new(), adjust)
}
fn app_with(
conversations: BTreeMap<String, BrowserTranscript>,
adjust: impl FnOnce(&mut ViewerSnapshot),
) -> TestServer {
let (config, state) = sample_config_state();
let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
adjust(&mut snapshot);
let (_snapshot_tx, snapshot_rx) = watch::channel(snapshot);
let (_conversation_tx, conversation_rx) = watch::channel(conversations);
let (action_tx, action_rx) = mpsc::channel(8);
let (bundle_tx, _bundle_rx) = mpsc::channel(8);
let (receipt_tx, receipt_rx) = mpsc::channel(8);
let (preflight_tx, preflight_rx) = mpsc::channel(8);
let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
let (client_state_tx, client_state_rx) = mpsc::channel(8);
let options = test_options(
snapshot_rx,
conversation_rx,
action_tx,
bundle_tx,
receipt_tx,
preflight_tx,
move_preparation_tx,
client_state_tx,
)
.with_test_credentials("123456", b"01234567890123456789012345678901");
(
router(options),
action_rx,
receipt_rx,
preflight_rx,
client_state_rx,
)
}
fn app_with_bundle_receiver() -> (Router, mpsc::Receiver<BundleRequest>) {
let (config, state) = sample_config_state();
let (_snapshot_tx, snapshot_rx) =
watch::channel(ViewerSnapshot::from_config_state(&config, &state, 1));
let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
let (action_tx, _action_rx) = mpsc::channel(8);
let (bundle_tx, bundle_rx) = mpsc::channel(8);
let (receipt_tx, _receipt_rx) = mpsc::channel(8);
let (preflight_tx, _preflight_rx) = mpsc::channel(8);
let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
let (client_state_tx, _client_state_rx) = mpsc::channel(8);
let options = test_options(
snapshot_rx,
conversation_rx,
action_tx,
bundle_tx,
receipt_tx,
preflight_tx,
move_preparation_tx,
client_state_tx,
)
.with_test_credentials("123456", b"01234567890123456789012345678901");
(router(options), bundle_rx)
}
#[allow(clippy::too_many_arguments)]
fn test_options(
snapshot_rx: watch::Receiver<ViewerSnapshot>,
conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
action_tx: mpsc::Sender<ControllerRequest>,
bundle_tx: mpsc::Sender<BundleRequest>,
receipt_tx: mpsc::Sender<ReadReceiptRequest>,
preflight_tx: mpsc::Sender<PreflightRequest>,
move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
client_state_tx: mpsc::Sender<ClientStateRequest>,
) -> ServerOptions {
test_options_with_dictation(
snapshot_rx,
conversation_rx,
action_tx,
bundle_tx,
receipt_tx,
preflight_tx,
move_preparation_tx,
client_state_tx,
)
.0
}
#[allow(clippy::too_many_arguments)]
fn test_options_with_dictation(
snapshot_rx: watch::Receiver<ViewerSnapshot>,
conversation_rx: watch::Receiver<BTreeMap<String, BrowserTranscript>>,
action_tx: mpsc::Sender<ControllerRequest>,
bundle_tx: mpsc::Sender<BundleRequest>,
receipt_tx: mpsc::Sender<ReadReceiptRequest>,
preflight_tx: mpsc::Sender<PreflightRequest>,
move_preparation_tx: mpsc::Sender<MovePreparationRequest>,
client_state_tx: mpsc::Sender<ClientStateRequest>,
) -> (ServerOptions, mpsc::Receiver<DictationRequest>) {
let (dictation_tx, dictation_rx) = mpsc::channel(8);
let options = ServerOptions::new(
"127.0.0.1:0".parse().unwrap(),
snapshot_rx,
conversation_rx,
ServerRequests {
action_tx,
bundle_tx,
receipt_tx,
preflight_tx,
move_preparation_tx,
client_state_tx,
dictation_tx,
},
)
.unwrap();
(options, dictation_rx)
}
fn app_with_dictation_receiver() -> (Router, mpsc::Receiver<DictationRequest>) {
let (config, state) = sample_config_state();
let (_snapshot_tx, snapshot_rx) =
watch::channel(ViewerSnapshot::from_config_state(&config, &state, 1));
let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
let (action_tx, _action_rx) = mpsc::channel(8);
let (bundle_tx, _bundle_rx) = mpsc::channel(8);
let (receipt_tx, _receipt_rx) = mpsc::channel(8);
let (preflight_tx, _preflight_rx) = mpsc::channel(8);
let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(8);
let (client_state_tx, _client_state_rx) = mpsc::channel(8);
let (options, dictation_rx) = test_options_with_dictation(
snapshot_rx,
conversation_rx,
action_tx,
bundle_tx,
receipt_tx,
preflight_tx,
move_preparation_tx,
client_state_tx,
);
(
router(options.with_test_credentials("123456", b"01234567890123456789012345678901")),
dictation_rx,
)
}
fn detached_options() -> ServerOptions {
let (config, state) = sample_config_state();
let (_snapshot_tx, snapshot_rx) =
watch::channel(ViewerSnapshot::from_config_state(&config, &state, 1));
let (_conversation_tx, conversation_rx) = watch::channel(BTreeMap::new());
let (action_tx, _action_rx) = mpsc::channel(1);
let (bundle_tx, _bundle_rx) = mpsc::channel(1);
let (receipt_tx, _receipt_rx) = mpsc::channel(1);
let (preflight_tx, _preflight_rx) = mpsc::channel(1);
let (move_preparation_tx, _move_preparation_rx) = mpsc::channel(1);
let (client_state_tx, _client_state_rx) = mpsc::channel(1);
test_options(
snapshot_rx,
conversation_rx,
action_tx,
bundle_tx,
receipt_tx,
preflight_tx,
move_preparation_tx,
client_state_tx,
)
}
fn cookie() -> String {
format!(
"{COOKIE_NAME}={}",
signed_cookie_value(
b"01234567890123456789012345678901",
"test-viewer",
now_unix().saturating_add(3600)
)
)
}
fn valid_wav() -> Bytes {
let samples = vec![0_u8; 320];
let mut wav = Vec::with_capacity(44 + samples.len());
wav.extend_from_slice(b"RIFF");
wav.extend_from_slice(&(36_u32 + samples.len() as u32).to_le_bytes());
wav.extend_from_slice(b"WAVEfmt ");
wav.extend_from_slice(&16_u32.to_le_bytes());
wav.extend_from_slice(&1_u16.to_le_bytes());
wav.extend_from_slice(&1_u16.to_le_bytes());
wav.extend_from_slice(&16_000_u32.to_le_bytes());
wav.extend_from_slice(&32_000_u32.to_le_bytes());
wav.extend_from_slice(&2_u16.to_le_bytes());
wav.extend_from_slice(&16_u16.to_le_bytes());
wav.extend_from_slice(b"data");
wav.extend_from_slice(&(samples.len() as u32).to_le_bytes());
wav.extend_from_slice(&samples);
Bytes::from(wav)
}
async fn login_cookie(app: &Router) -> String {
let response = app
.clone()
.oneshot(
Request::post("/auth/session")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"code":"123456"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NO_CONTENT);
response
.headers()
.get(SET_COOKIE)
.unwrap()
.to_str()
.unwrap()
.split(';')
.next()
.unwrap()
.to_string()
}
#[tokio::test]
async fn dictation_availability_requires_auth_and_forwards_typed_request() {
let (app, mut requests) = app_with_dictation_receiver();
let unauthorized = app
.clone()
.oneshot(
Request::get("/api/sessions/session-1/dictation")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
assert!(requests.try_recv().is_err());
let cookie = login_cookie(&app).await;
let pending = tokio::spawn({
let app = app.clone();
async move {
app.oneshot(
Request::get("/api/sessions/session-1/dictation")
.header(COOKIE, cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap()
}
});
let request = requests.recv().await.unwrap();
assert_eq!(request.session_id, "session-1");
assert!(matches!(
request.operation,
DictationOperation::Availability
));
request
.reply
.send(Ok(DictationResponse::Availability {
available: true,
reason: None,
}))
.unwrap();
let response = pending.await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(&body[..], br#"{"available":true}"#);
}
#[tokio::test]
async fn dictation_rejects_bad_wav_before_controller_dispatch() {
let (app, mut requests) = app_with_dictation_receiver();
let cookie = login_cookie(&app).await;
let response = app
.oneshot(
Request::post("/api/sessions/session-1/dictation")
.header(COOKIE, cookie)
.header(CONTENT_TYPE, "audio/wav")
.body(Body::from("not wav"))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert!(requests.try_recv().is_err());
}
#[tokio::test]
async fn dictation_rejects_a_third_upload_before_reading_its_body() {
let (app, mut requests) = app_with_dictation_receiver();
let cookie = login_cookie(&app).await;
let request = || {
Request::post("/api/sessions/session-1/dictation")
.header(COOKIE, cookie.clone())
.header(CONTENT_TYPE, "audio/wav")
.body(Body::from(valid_wav()))
.unwrap()
};
let first = tokio::spawn({
let app = app.clone();
let request = request();
async move { app.oneshot(request).await.unwrap() }
});
let second = tokio::spawn({
let app = app.clone();
let request = request();
async move { app.oneshot(request).await.unwrap() }
});
let first_request = requests.recv().await.unwrap();
let second_request = requests.recv().await.unwrap();
let third = app
.oneshot(
Request::post("/api/sessions/session-1/dictation")
.header(COOKIE, cookie)
.header(CONTENT_TYPE, "audio/wav")
.body(Body::from_stream(futures::stream::poll_fn(
|_| -> std::task::Poll<Option<Result<Bytes, std::io::Error>>> {
panic!("overloaded dictation polled its body")
},
)))
.unwrap(),
)
.await
.unwrap();
assert_eq!(third.status(), StatusCode::TOO_MANY_REQUESTS);
first_request
.reply
.send(Ok(DictationResponse::Transcript {
text: "first".into(),
}))
.unwrap();
second_request
.reply
.send(Ok(DictationResponse::Transcript {
text: "second".into(),
}))
.unwrap();
assert_eq!(first.await.unwrap().status(), StatusCode::OK);
assert_eq!(second.await.unwrap().status(), StatusCode::OK);
}
#[tokio::test]
async fn dictation_upload_rejects_unauthorized_missing_and_oversized_requests() {
let (app, mut requests) = app_with_dictation_receiver();
let response = app
.clone()
.oneshot(
Request::post("/api/sessions/session-1/dictation")
.body(Body::from(valid_wav()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
let cookie = login_cookie(&app).await;
let response = app
.clone()
.oneshot(
Request::post("/api/sessions/missing/dictation")
.header(COOKIE, &cookie)
.body(Body::from(valid_wav()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
let response = app
.oneshot(
Request::post("/api/sessions/session-1/dictation")
.header(COOKIE, cookie)
.body(Body::from(vec![0_u8; MAX_AUDIO_BYTES + 1]))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
assert!(requests.try_recv().is_err());
}
#[tokio::test]
async fn dictation_provider_failure_is_actionable_and_does_not_expose_details() {
let (app, mut requests) = app_with_dictation_receiver();
let cookie = login_cookie(&app).await;
let pending = tokio::spawn(async move {
app.oneshot(
Request::post("/api/sessions/session-1/dictation")
.header(COOKIE, cookie)
.body(Body::from(valid_wav()))
.unwrap(),
)
.await
.unwrap()
});
let request = requests.recv().await.unwrap();
request
.reply
.send(Err(DictationError::Provider(
"private provider details".into(),
)))
.unwrap();
let response = pending.await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let bytes = response.into_body().collect().await.unwrap().to_bytes();
let body = String::from_utf8(bytes.to_vec()).unwrap();
assert!(body.contains("transcription"));
assert!(!body.contains("private provider details"));
}
#[tokio::test]
async fn dropped_dictation_handler_cancels_controller_request() {
let (app, mut requests) = app_with_dictation_receiver();
let cookie = login_cookie(&app).await;
let pending = tokio::spawn({
let app = app.clone();
async move {
app.oneshot(
Request::post("/api/sessions/session-1/dictation")
.header(COOKIE, cookie)
.body(Body::from(valid_wav()))
.unwrap(),
)
.await
.unwrap()
}
});
let request = requests.recv().await.unwrap();
let cancel = request.cancel.clone();
pending.abort();
let _ = pending.await;
assert!(cancel.is_cancelled());
drop(request);
}
#[tokio::test]
async fn bundle_endpoint_authenticates_and_forwards_the_source() {
let (app, mut bundles) = app_with_bundle_receiver();
let unauthorized = app
.clone()
.oneshot(
Request::post("/api/bundles")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"source":"example/app"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
assert!(bundles.try_recv().is_err());
let cookie = login_cookie(&app).await;
let response = tokio::spawn({
let app = app.clone();
let cookie = cookie.clone();
async move {
app.oneshot(
Request::post("/api/bundles")
.header(CONTENT_TYPE, "application/json")
.header(COOKIE, cookie)
.body(Body::from(r#"{"source":"example/app"}"#))
.unwrap(),
)
.await
.unwrap()
}
});
let request = bundles.recv().await.expect("bundle request forwarded");
assert_eq!(request.source, "example/app");
request.reply.send(Ok("app".into())).unwrap();
let response = response.await.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(body.as_ref(), br#"{"bundle_id":"app"}"#);
}
#[tokio::test]
async fn bundle_endpoint_rejects_empty_and_oversized_sources_before_dispatch() {
for source in [String::new(), "x".repeat(MAX_BUNDLE_SOURCE_CHARS + 1)] {
let (app, mut bundles) = app_with_bundle_receiver();
let cookie = login_cookie(&app).await;
let response = app
.oneshot(
Request::post("/api/bundles")
.header(CONTENT_TYPE, "application/json")
.header(COOKIE, cookie)
.body(Body::from(
serde_json::to_string(&serde_json::json!({"source": source})).unwrap(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert!(bundles.try_recv().is_err());
}
}
#[tokio::test]
async fn bundle_endpoint_reports_invalid_source_as_a_client_error() {
let (app, mut bundles) = app_with_bundle_receiver();
let cookie = login_cookie(&app).await;
let response = tokio::spawn({
let app = app.clone();
async move {
app.oneshot(
Request::post("/api/bundles")
.header(CONTENT_TYPE, "application/json")
.header(COOKIE, cookie)
.body(Body::from(r#"{"source":"not a source"}"#))
.unwrap(),
)
.await
.unwrap()
}
});
let request = bundles.recv().await.expect("bundle request forwarded");
request
.reply
.send(Err(BundleFailure::InvalidSource))
.unwrap();
let response = response.await.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response.into_body().collect().await.unwrap().to_bytes();
assert!(String::from_utf8_lossy(&body).contains("GitHub owner/repository"));
}
#[tokio::test]
async fn api_requires_a_valid_signed_cookie() {
let (app, _, _, _, _) = app();
let unauthorized = app
.clone()
.oneshot(Request::get("/api/snapshot").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED);
let cookie = login_cookie(&app).await;
let authorized = app
.oneshot(
Request::get("/api/snapshot")
.header(COOKIE, cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(authorized.status(), StatusCode::OK);
}
#[tokio::test]
async fn qr_login_exchanges_the_secret_for_a_cookie_and_redirects_cleanly() {
let (app, _, _, _, _) = app();
let rejected = app
.clone()
.oneshot(
Request::get("/auth/login?token=wrong")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(rejected.status(), StatusCode::UNAUTHORIZED);
let accepted = app
.oneshot(
Request::get("/auth/login?token=test-login-token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(accepted.status(), StatusCode::SEE_OTHER);
assert_eq!(accepted.headers().get(LOCATION).unwrap(), "/");
assert_eq!(accepted.headers().get(CACHE_CONTROL).unwrap(), "no-store");
assert!(accepted.headers().contains_key(SET_COOKIE));
}
#[test]
fn signed_cookie_rejects_expiry_and_tampering() {
let key = b"01234567890123456789012345678901";
let cookie = signed_cookie_value(key, "test-viewer", 200);
assert!(session_cookie_valid(key, &cookie, 100));
assert!(!session_cookie_valid(key, &cookie, 200));
assert!(!session_cookie_valid(key, &format!("{cookie}x"), 100));
assert!(!session_cookie_valid(b"another-key", &cookie, 100));
}
#[test]
fn generated_code_and_cookie_attributes_are_phone_safe() {
let code = generate_viewer_code().unwrap();
assert_eq!(code.len(), 6);
assert!(code.bytes().all(|byte| byte.is_ascii_digit()));
let header = session_cookie_header("signed", Some(60), true)
.unwrap()
.to_str()
.unwrap()
.to_string();
assert!(header.contains("HttpOnly"));
assert!(header.contains("SameSite=Strict"));
assert!(header.contains("Secure"));
assert!(header.contains("Max-Age=60"));
}
#[test]
fn public_snapshot_omits_homes_environment_locators_and_raw_errors() {
let (config, state) = sample_config_state();
let json =
serde_json::to_string(&ViewerSnapshot::from_config_state(&config, &state, 9)).unwrap();
assert!(!json.contains("/highly/secret"));
assert!(!json.contains("secret-token"));
assert!(!json.contains("secret-target"));
assert!(!json.contains("secret.registry"));
assert!(!json.contains("native-secret-id"));
assert!(json.contains("\"has_error\":true"));
}
#[test]
fn target_snapshot_uses_each_raw_host_project_history_and_leaves_managed_empty() {
let (mut config, mut state) = sample_config_state();
config
.targets
.insert("raw-local".into(), TargetTemplate::LocalBare);
config.targets.insert(
"raw-builder".into(),
TargetTemplate::SshBare {
ssh: SshConnection {
host: "builder-a".into(),
user: None,
identity_file: None,
extra_args: Vec::new(),
},
permissions: PermissionMode::Guardian,
workspace_prefix: "workspaces".into(),
},
);
state.remember_project_directory("local", Path::new("/work/local"));
state.remember_project_directory("builder-a", Path::new("/srv/builder"));
state.remember_project_directory("other-host", Path::new("/not-published"));
let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
let target = |id: &str| {
snapshot
.targets
.iter()
.find(|target| target.id == id)
.unwrap()
};
assert_eq!(
target("raw-local").recent_project_directories,
vec!["/work/local"]
);
assert_eq!(
target("raw-builder").recent_project_directories,
vec!["/srv/builder"]
);
assert!(target("podman").recent_project_directories.is_empty());
}
#[test]
fn public_snapshot_exposes_only_review_status_configuration() {
let (mut config, state) = sample_config_state();
config.review = hel::hel_config::ReviewConfig {
enabled: true,
tier: hel::hel_review::lanes::ReviewTier::Extended,
profile: Some("reviewer-1".into()),
model: Some("private-review-model".into()),
effort: Some("private-review-effort".into()),
};
let value =
serde_json::to_value(ViewerSnapshot::from_config_state(&config, &state, 9)).unwrap();
assert_eq!(
value.get("review_config"),
Some(&serde_json::json!({
"enabled": true,
"tier": "extended",
"profile": "reviewer-1",
}))
);
let json = value.to_string();
assert!(!json.contains("private-review-model"));
assert!(!json.contains("private-review-effort"));
}
fn sample_elicitation() -> ElicitationRequest {
ElicitationRequest::from_acp_params(
"elicitation-1",
serde_json::json!({
"sessionId": "session-1",
"mode": "form",
"message": "Which CI architecture should the workflow use?",
"requestedSchema": {
"type": "object",
"required": ["question_0"],
"properties": {
"question_0": {
"type": "string",
"title": "CI architecture",
"oneOf": [
{"const": "reusable", "title": "Reusable workflow"},
{"const": "matrix", "title": "Matrix job"}
]
},
"question_0_custom": {
"type": "string",
"title": "Other",
"_meta": {"_askUserQuestionCustomAnswer": {
"questionId": "question_0",
"isCustomAnswer": true
}}
}
}
}
}),
)
.expect("sample elicitation parses")
}
fn accept(pairs: &[(&str, &str)]) -> ElicitationResponse {
ElicitationResponse::Accept {
content: pairs
.iter()
.map(|(id, value)| {
(
(*id).to_owned(),
hel::hel_elicitation::ElicitationValue::String((*value).to_owned()),
)
})
.collect(),
}
}
fn pending_elicitation_snapshot(snapshot: &mut ViewerSnapshot) {
snapshot.sessions[0].pending_elicitations = vec![sample_elicitation()];
}
#[tokio::test]
async fn elicitation_answer_is_typed_and_forwarded() {
let (app, mut actions, _, _, _) = app_with_snapshot(pending_elicitation_snapshot);
let cookie = login_cookie(&app).await;
let response = tokio::spawn(
app.oneshot(
Request::post("/api/actions")
.header(COOKIE, cookie)
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
r#"{"action":"respond-elicitation","session_id":"session-1","elicitation_id":"elicitation-1","response":{"action":"accept","content":{"question_0":"reusable"}}}"#,
))
.unwrap(),
),
);
let action = actions.recv().await.unwrap();
assert_eq!(
action.action,
ControllerAction::RespondElicitation {
session_id: "session-1".into(),
elicitation_id: "elicitation-1".into(),
response: accept(&[("question_0", "reusable")]),
}
);
action.reply.send(ActionOutcome::Accepted).unwrap();
assert_eq!(
response.await.unwrap().unwrap().status(),
StatusCode::ACCEPTED
);
}
#[tokio::test]
async fn elicitation_answer_for_an_unknown_request_is_refused_without_reaching_the_controller()
{
let (app, mut actions, _, _, _) = app_with_snapshot(pending_elicitation_snapshot);
let cookie = login_cookie(&app).await;
let response = app
.oneshot(
Request::post("/api/actions")
.header(COOKIE, cookie)
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
r#"{"action":"respond-elicitation","session_id":"session-1","elicitation_id":"elicitation-9","response":{"action":"cancel"}}"#,
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
assert!(actions.try_recv().is_err());
}
#[test]
fn elicitation_answers_are_checked_against_the_request_the_agent_asked() {
let (config, state) = sample_config_state();
let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
pending_elicitation_snapshot(&mut snapshot);
let respond = |response: ElicitationResponse| ControllerAction::RespondElicitation {
session_id: "session-1".into(),
elicitation_id: "elicitation-1".into(),
response,
};
assert!(validate_action(&respond(accept(&[("question_0", "matrix")])), &snapshot).is_ok());
assert!(validate_action(&respond(ElicitationResponse::Decline), &snapshot).is_ok());
assert!(validate_action(&respond(accept(&[("question_0", "cron")])), &snapshot).is_err());
assert!(validate_action(&respond(accept(&[("smuggled", "yes")])), &snapshot).is_err());
assert!(validate_action(&respond(accept(&[])), &snapshot).is_err());
assert!(
validate_action(
&respond(accept(&[("question_0_custom", "a monorepo pipeline")])),
&snapshot,
)
.is_ok()
);
}
#[test]
fn oversized_elicitation_answers_are_refused() {
let (config, state) = sample_config_state();
let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
pending_elicitation_snapshot(&mut snapshot);
let long = "x".repeat(MAX_ELICITATION_BYTES);
assert!(
validate_action(
&ControllerAction::RespondElicitation {
session_id: "session-1".into(),
elicitation_id: "elicitation-1".into(),
response: accept(&[("question_0_custom", long.as_str())]),
},
&snapshot,
)
.is_err()
);
}
fn viewer_source(from: &str, to: &str) -> &'static str {
let start = VIEWER_JS
.find(from)
.unwrap_or_else(|| panic!("src/web/viewer.js no longer contains {from:?}"));
let end = VIEWER_JS[start..]
.find(to)
.map(|offset| start + offset)
.unwrap_or_else(|| {
panic!("src/web/viewer.js no longer contains {to:?} after {from:?}")
});
&VIEWER_JS[start..end]
}
fn run_web_check(name: &str, check: &str) {
let directory = tempfile::tempdir().expect("temporary directory for a web check");
for (file, source) in [
("test-dom.js", TEST_DOM_JS),
("markdown.js", MARKDOWN_JS),
("tool-output.js", TOOL_OUTPUT_JS),
] {
std::fs::write(directory.path().join(file), source).expect("write a web module");
}
let path = directory.path().join(format!("{name}.mjs"));
std::fs::write(&path, check).expect("write the web check");
let output = std::process::Command::new("node")
.arg(&path)
.output()
.expect("Node.js is required to exercise the web viewer");
assert!(
output.status.success(),
"{name} failed:\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
}
fn run_viewer_script(name: &str, script: &str) {
run_web_check(name, script);
}
#[test]
fn embedded_viewer_lists_current_workspace_histories_and_retained_move_recovery() {
let source = viewer_source("function isResumeSession(", "const resumeDrafts =");
let setup = r#"
const snapshot = {
sessions: [
{ id: "history-a", workspace_id: "workspace-a", capabilities: { resume: true } },
{ id: "history-b", workspace_id: "workspace-b", capabilities: { resume: true } },
{ id: "running-a", workspace_id: "workspace-a", lifecycle: "live", has_error: true, capabilities: { resume: false, open: false } },
{ id: "move-a", workspace_id: "workspace-a", capabilities: { resume: false }, move_recovery: { checkpoint_retained: true, phase: "failed" } },
{ id: "moving-a", workspace_id: "workspace-a", capabilities: { resume: false }, move_recovery: { checkpoint_retained: true, phase: "starting_queue" } },
],
};
function selectedWorkspaceId() { return "workspace-a"; }
function sessionActivityMs() { return 0; }
function epochMs() { return null; }
"#;
let checks = r#"
const ids = workspace => resumeSessions(workspace).map(session => session.id).sort();
if (JSON.stringify(ids("workspace-a")) !== JSON.stringify(["history-a", "move-a"])) {
throw new Error(`workspace A histories or recoveries were wrong: ${JSON.stringify(ids("workspace-a"))}`);
}
if (JSON.stringify(ids("workspace-b")) !== JSON.stringify(["history-b"])) {
throw new Error(`workspace B histories were wrong: ${JSON.stringify(ids("workspace-b"))}`);
}
if (ids("missing-workspace").length !== 0) throw new Error("unknown workspace exposed sessions");
"#;
run_viewer_script(
"workspace-resume-history",
&format!("{setup}\n{source}\n{checks}"),
);
}
#[test]
fn embedded_viewer_sends_the_selected_resume_workspace() {
let source = viewer_source("async function runSessionAction", "sessions.onclick =");
let setup = r#"
const pendingActions = new Set();
const snapshot = { sessions: [] };
let sent = null;
function selectedWorkspaceId() { return "workspace-b"; }
function navigate() {}
function renderRoute() {}
async function refresh() {}
async function request(path, options) {
sent = { path, body: JSON.parse(options.body) };
}
"#;
let checks = r#"
const errorNode = { textContent: "" };
await runSessionAction(
{ action: "resume", id: "history-a", profile: "codex-1", target: "podman" },
errorNode,
{ queue: "start" },
);
if (sent.path !== "/api/actions" || sent.body.workspace_id !== "workspace-b") {
throw new Error(`resume did not carry its destination: ${JSON.stringify(sent)}`);
}
"#;
run_viewer_script(
"resume-workspace-destination",
&format!("{setup}\n{source}\n{checks}"),
);
}
#[test]
fn embedded_viewer_warns_before_stopping_an_active_session() {
let source = viewer_source("async function runSessionAction", "sessions.onclick =");
let setup = r#"
const pendingActions = new Set();
const snapshot = {
sessions: [
{ id: "active", chat_phase: "running" },
{ id: "idle", chat_phase: "idle" },
],
};
const questions = [];
function confirm(question) { questions.push(question); return false; }
function navigate() {}
"#;
let checks = r#"
const errorNode = { textContent: "" };
await runSessionAction({ action: "close", id: "active" }, errorNode);
await runSessionAction({ action: "close", id: "idle" }, errorNode);
if (!questions[0].startsWith("Stop active session?\n\n")) {
throw new Error(`active close warning was ${JSON.stringify(questions[0])}`);
}
if (!questions[0].includes("current turn will be interrupted")) {
throw new Error(`active close omitted interruption: ${JSON.stringify(questions[0])}`);
}
if (!questions[1].startsWith("Stop session?\n\n")) {
throw new Error(`idle close warning was ${JSON.stringify(questions[1])}`);
}
"#;
run_viewer_script(
"active-session-stop-confirmation",
&format!("{setup}\n{source}\n{checks}"),
);
}
#[test]
fn the_project_key_groups_without_naming_a_path() {
let (config, mut state) = sample_config_state();
let first = state.sessions["session-1"].clone();
let mut second = first.clone();
second.id = "session-2".into();
state.sessions.insert(second.id.clone(), second);
let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
let keys = snapshot
.sessions
.iter()
.map(|session| session.project_key.as_str())
.collect::<std::collections::BTreeSet<_>>();
assert_eq!(keys.len(), 1, "two sessions in one project did not group");
let key = keys.into_iter().next().expect("one key");
assert!(!key.is_empty(), "the project key is empty");
assert!(
!key.contains('/') && !key.contains("hel"),
"the project key leaks its identity: {key}"
);
assert_eq!(
snapshot.sessions[0].project_label, "hel",
"the project label should be a name a person recognises"
);
}
#[test]
fn web_project_keys_follow_the_complete_repository_set() {
let (mut config, mut state) = sample_config_state();
let shared_bundle = config.bundles["hel"].clone();
config.bundles.insert("other".into(), shared_bundle);
let mut other = state.sessions["session-1"].clone();
other.id = "session-2".into();
other.bundle_id = "other".into();
state.sessions.insert(other.id.clone(), other);
assert_eq!(
config.bundles["hel"].primary_repo,
config.bundles["other"].primary_repo
);
let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
let first = snapshot
.sessions
.iter()
.find(|session| session.id == "session-1")
.expect("first session");
let second = snapshot
.sessions
.iter()
.find(|session| session.id == "session-2")
.expect("second session");
assert_eq!(first.project_label, "hel");
assert_eq!(second.project_label, "hel");
assert_eq!(first.project_key, second.project_key);
let secondary = ProjectRepository {
id: "secondary".into(),
github: Some("owner/secondary".into()),
local: None,
destination: "secondary".into(),
git_ref: None,
};
config
.bundles
.get_mut("other")
.unwrap()
.repositories
.push(secondary.clone());
let project_keys = |config: &HelConfig| {
ViewerSnapshot::from_config_state(config, &state, 1)
.sessions
.into_iter()
.map(|session| session.project_key)
.collect::<Vec<_>>()
};
let keys = project_keys(&config);
assert_ne!(
keys[0], keys[1],
"an added repository must change the bundle identity"
);
let first_bundle = config.bundles.get_mut("hel").unwrap();
first_bundle.repositories.insert(0, secondary);
first_bundle.primary_repo = "secondary".into();
let keys = project_keys(&config);
assert_eq!(
keys[0], keys[1],
"the same repository set must group together despite order or primary choice"
);
}
#[test]
fn viewer_session_applies_a_resolved_source_without_publishing_it() {
let (config, state) = sample_config_state();
let mut viewer = ViewerSnapshot::from_config_state(&config, &state, 1)
.sessions
.into_iter()
.next()
.expect("session");
let source = ProjectSourceIdentity::git_remote("git@github.com:BrokkAi/bifrost-dev.git")
.expect("GitHub source");
viewer.set_project_source(&source);
assert_eq!(viewer.project_label, "bifrost-dev");
assert_eq!(viewer.project_key, project_key(&source.key));
let json = serde_json::to_string(&viewer).expect("serialize viewer session");
assert!(!json.contains("BrokkAi"));
assert!(!json.contains("github.com"));
}
#[test]
fn lifecycle_categories_decide_what_the_dashboard_shows() {
use ViewerLifecycleCategory::{Failed, Live, Starting, Stopped, Stopping};
for (state, expected, on_dashboard) in [
(SessionState::Provisioning, Starting, true),
(SessionState::Running, Live, true),
(SessionState::Disconnected, Live, true),
(SessionState::Checkpointing, Live, true),
(SessionState::Closing, Stopping, true),
(SessionState::Destroying, Stopping, true),
(SessionState::Stopped, Stopped, false),
(SessionState::Lost, Failed, false),
(SessionState::Error, Failed, false),
(SessionState::DestroyedWithDataLoss, Failed, false),
] {
let category = ViewerLifecycleCategory::of(state);
assert_eq!(category, expected, "{state:?}");
assert_eq!(
category.is_dashboard_visible(),
on_dashboard,
"{state:?} belongs on the dashboard? "
);
}
}
#[test]
fn compatible_resume_targets_are_the_complement_of_the_incompatible_ones() {
let (config, state) = sample_config_state();
let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
let session = &snapshot.sessions[0];
let all = config.targets.keys().cloned().collect::<Vec<_>>();
for target in &all {
assert_ne!(
session.compatible_resume_targets.contains(target),
session.incompatible_resume_targets.contains(target),
"target {target} is in both lists or neither"
);
}
assert_eq!(
session.compatible_resume_targets.len() + session.incompatible_resume_targets.len(),
all.len(),
"the two lists do not cover every target"
);
}
#[tokio::test]
async fn actions_are_refused_when_their_capability_is_false() {
for (body, capability) in [
(
r#"{"action":"cancel-turn","session_id":"session-1"}"#,
"cancel_turn",
),
(
r#"{"action":"set-plan-mode","session_id":"session-1","active":true}"#,
"set_plan_mode",
),
(
r#"{"action":"set-config","session_id":"session-1","key":"model","value":"x"}"#,
"set_config",
),
] {
let (app, mut actions, _, _, _) = app();
let response = post_action(app, cookie(), body.to_owned()).await;
assert!(
response.status().is_client_error(),
"{capability} was accepted while false: {}",
response.status()
);
assert!(
actions.try_recv().is_err(),
"{capability} reached the controller while false"
);
}
}
#[tokio::test]
async fn a_config_key_the_harness_never_advertised_is_refused() {
let capable = |snapshot: &mut ViewerSnapshot| {
snapshot.sessions[0].capabilities.set_config = true;
snapshot.sessions[0].config_options = vec![ViewerConfigOption {
key: "model".into(),
label: "model".into(),
current: None,
choices: vec![ViewerConfigChoice {
value: "sonnet".into(),
name: "Sonnet".into(),
description: None,
}],
}];
};
for (body, why) in [
(
r#"{"action":"set-config","session_id":"session-1","key":"effort","value":"high"}"#,
"an unadvertised key",
),
(
r#"{"action":"set-config","session_id":"session-1","key":"model","value":"gpt-9"}"#,
"an unoffered value",
),
] {
let (app, mut actions, _, _, _) = app_with_snapshot(capable);
let response = post_action(app, cookie(), body.to_owned()).await;
assert_eq!(
response.status(),
StatusCode::BAD_REQUEST,
"{why} was accepted"
);
assert!(actions.try_recv().is_err(), "{why} reached the controller");
}
let (app, mut actions, _, _, _) = app_with_snapshot(capable);
let response = tokio::spawn(post_action(
app,
cookie(),
r#"{"action":"set-config","session_id":"session-1","key":"model","value":"sonnet"}"#
.to_owned(),
));
let action = actions
.recv()
.await
.expect("the action reached the controller");
assert!(
matches!(
action.action,
ControllerAction::SetConfig { ref key, ref value, .. }
if key == "model" && value == "sonnet"
),
"the advertised value was not forwarded unchanged"
);
action.reply.send(ActionOutcome::Accepted).unwrap();
assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
}
#[tokio::test]
async fn a_dirty_acknowledgement_is_bounded_and_names_repositories() {
let oversized = (0..40)
.map(|index| format!(r#""repo-{index}""#))
.collect::<Vec<_>>()
.join(",");
for (ack, why) in [
(oversized.as_str(), "an unbounded acknowledgement"),
(r#""""#, "an empty repository name"),
] {
let (app, mut actions, _, _, _) = app();
let body = format!(
r#"{{"action":"new","workspace_id":"default","profile_id":"codex-1","bundle_id":"hel","target_id":"podman","dirty_ack":[{ack}]}}"#
);
let response = post_action(app, cookie(), body).await;
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{why}");
assert!(actions.try_recv().is_err(), "{why} reached the controller");
}
}
#[tokio::test]
async fn a_new_session_without_a_title_is_accepted() {
let (app, mut actions, _, _, _) = app();
let response = tokio::spawn(post_action(
app,
cookie(),
r#"{"action":"new","workspace_id":"default","profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#
.to_owned(),
));
let action = actions
.recv()
.await
.expect("the action reached the controller");
assert!(
matches!(
action.action,
ControllerAction::New { title: None, ref workspace_id, .. }
if workspace_id == "default"
),
"the workspace or the absent title did not survive the boundary"
);
action.reply.send(ActionOutcome::Accepted).unwrap();
assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
}
#[test]
fn a_cookie_names_one_viewer_and_two_cookies_never_collide() {
let key = b"01234567890123456789012345678901";
let expiry = now_unix().saturating_add(3600);
let first = signed_cookie_value(key, "viewer-a", expiry);
let second = signed_cookie_value(key, "viewer-b", expiry);
assert_ne!(
first, second,
"two viewers unlocking in the same second share a cookie"
);
assert_eq!(
cookie_viewer(key, &first, now_unix()),
Some(Some("viewer-a".to_owned()))
);
assert_eq!(
cookie_viewer(key, &second, now_unix()),
Some(Some("viewer-b".to_owned()))
);
}
#[test]
fn a_legacy_cookie_still_authenticates_and_stores_nothing() {
let key = b"01234567890123456789012345678901";
let expiry = now_unix().saturating_add(3600);
let legacy = legacy_signed_cookie_value(key, expiry);
assert_eq!(cookie_viewer(key, &legacy, now_unix()), Some(None));
assert!(session_cookie_valid(key, &legacy, now_unix()));
assert!(
!session_cookie_valid(key, &legacy, expiry),
"an expired legacy cookie still authenticated"
);
}
#[test]
fn a_tampered_cookie_is_refused() {
let key = b"01234567890123456789012345678901";
let expiry = now_unix().saturating_add(3600);
let honest = signed_cookie_value(key, "viewer-a", expiry);
let swapped = honest.replacen("viewer-a", "viewer-b", 1);
assert_eq!(cookie_viewer(key, &swapped, now_unix()), None);
assert_eq!(cookie_viewer(key, "nonsense", now_unix()), None);
assert_eq!(cookie_viewer(key, &format!("{expiry}."), now_unix()), None);
}
#[tokio::test]
async fn an_oversized_draft_is_refused_with_a_stable_code() {
let (app, _, _, _, mut stored) = app();
let draft = "x".repeat(64 * 1024 + 1);
let response = app
.oneshot(
Request::put("/api/sessions/session-1/draft")
.header(COOKIE, cookie())
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
serde_json::json!({ "draft": draft }).to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
assert!(stored.try_recv().is_err(), "an oversized draft was stored");
}
#[tokio::test]
async fn a_legacy_viewer_reads_empty_state_and_cannot_store_a_draft() {
let key = b"01234567890123456789012345678901";
let legacy = format!(
"{COOKIE_NAME}={}",
legacy_signed_cookie_value(key, now_unix().saturating_add(3600))
);
let (reader, _, _, _, mut stored) = app();
let response = reader
.oneshot(
Request::get("/api/sessions/session-1/client-state")
.header(COOKIE, legacy.clone())
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let state: ViewerClientState = serde_json::from_slice(&body).unwrap();
assert_eq!(state, ViewerClientState::default());
assert!(
stored.try_recv().is_err(),
"a legacy viewer read stored state"
);
let (writer, _, _, _, mut stored) = app();
let response = writer
.oneshot(
Request::put("/api/sessions/session-1/draft")
.header(COOKIE, legacy)
.header(CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"draft":"text"}"#))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CONFLICT);
assert!(stored.try_recv().is_err(), "a legacy viewer stored a draft");
}
#[tokio::test]
async fn prompt_history_refuses_an_unknown_scope() {
let (app, _, _, _, mut stored) = app();
let response = app
.oneshot(
Request::get("/api/sessions/session-1/history?q=ship&scope=everything")
.header(COOKIE, cookie())
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
assert!(
stored.try_recv().is_err(),
"the search reached the controller"
);
}
#[tokio::test]
async fn a_preflight_validates_before_it_reaches_the_controller() {
for (body, why) in [
(
r#"{"profile_id":"nope","bundle_id":"hel","target_id":"podman"}"#,
"an unknown profile",
),
(
r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw"}"#,
"a bare target with no directory",
),
] {
let (app, _, _, mut preflights, _) = app();
let response = app
.oneshot(
Request::post("/api/preflight/new")
.header(COOKIE, cookie())
.header(CONTENT_TYPE, "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{why}");
assert!(
preflights.try_recv().is_err(),
"{why} reached the controller"
);
}
}
#[tokio::test]
async fn a_bare_preflight_forwards_directory_validation_to_the_controller() {
let (app, _, _, mut preflights, _) = app();
let response = tokio::spawn(app.oneshot(
Request::post("/api/preflight/new")
.header(COOKIE, cookie())
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw","project_directory":"/work/project"}"#,
))
.unwrap(),
));
let request = preflights.recv().await.expect("the controller was asked");
assert_eq!(request.bundle_id, "hel");
assert_eq!(request.target_id, "raw");
assert_eq!(
request.project_directory,
Some(PathBuf::from("/work/project"))
);
request
.reply
.send(Ok(PreflightNew {
dirty_repositories: Vec::new(),
}))
.unwrap();
let response = response.await.unwrap().unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let answer: PreflightNew = serde_json::from_slice(&body).unwrap();
assert!(answer.dirty_repositories.is_empty());
}
#[tokio::test]
async fn a_bare_preflight_validation_failure_is_actionable_without_its_details() {
let (app, _, _, mut preflights, _) = app();
let response = tokio::spawn(app.oneshot(
Request::post("/api/preflight/new")
.header(COOKIE, cookie())
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"raw","project_directory":"/private/project"}"#,
))
.unwrap(),
));
let request = preflights.recv().await.expect("the controller was asked");
request
.reply
.send(Err(PreflightFailure::Validation))
.unwrap();
let response = response.await.unwrap().unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
serde_json::json!({
"error": "project validation failed; check that the directory exists, is accessible, and contains a Git repository with a valid HEAD"
})
);
assert!(!String::from_utf8_lossy(&body).contains("/private/project"));
}
#[tokio::test]
async fn a_bundle_preflight_controller_failure_keeps_the_generic_service_error() {
let (app, _, _, mut preflights, _) = app();
let response = tokio::spawn(
app.oneshot(
Request::post("/api/preflight/new")
.header(COOKIE, cookie())
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#,
))
.unwrap(),
),
);
let request = preflights.recv().await.expect("the controller was asked");
request
.reply
.send(Err(PreflightFailure::Controller(
"private /source/hel details".into(),
)))
.unwrap();
let response = response.await.unwrap().unwrap();
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&body).unwrap(),
serde_json::json!({"error": "the controller could not check this project"})
);
assert!(!String::from_utf8_lossy(&body).contains("/source/hel"));
}
#[tokio::test]
async fn a_bundle_preflight_reports_the_repositories_by_leaf_name() {
let (app, _, _, mut preflights, _) = app();
let response = tokio::spawn(
app.oneshot(
Request::post("/api/preflight/new")
.header(COOKIE, cookie())
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
r#"{"profile_id":"codex-1","bundle_id":"hel","target_id":"podman"}"#,
))
.unwrap(),
),
);
let request = preflights.recv().await.expect("the controller was asked");
assert_eq!(request.bundle_id, "hel");
assert_eq!(request.target_id, "podman");
assert_eq!(request.project_directory, None);
request
.reply
.send(Ok(PreflightNew {
dirty_repositories: vec!["hel".into()],
}))
.unwrap();
let response = response.await.unwrap().unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let answer: PreflightNew = serde_json::from_slice(&body).unwrap();
assert_eq!(answer.dirty_repositories, vec!["hel".to_owned()]);
assert!(
!String::from_utf8_lossy(&body).contains('/'),
"the preflight published a path: {}",
String::from_utf8_lossy(&body)
);
}
#[test]
fn the_markdown_renderer_builds_structure_and_refuses_injection() {
run_web_check(
"markdown",
r#"import { installDocument, elements, only, check, checkEqual } from './test-dom.js';
installDocument();
const { renderMarkdown, renderDiffSummary, safeHref } = await import('./markdown.js');
const render = source => {
const host = document.createElement('section');
host.append(renderMarkdown(source));
return host;
};
// Headings
checkEqual(only(render('# Title'), 'h1').textContent, 'Title', 'h1');
checkEqual(only(render('### Deep'), 'h3').textContent, 'Deep', 'h3');
// Nested lists
const nested = render('- one\n - inner\n- two');
check(elements(nested, 'ul').length === 2, 'nested list produced ' + elements(nested, 'ul').length + ' lists');
check(elements(elements(nested, 'ul')[0], 'li').length >= 2, 'outer list lost items');
// Ordered lists
checkEqual(elements(render('1. a\n2. b'), 'ol').length, 1, 'ordered list');
// Fenced code stays unparsed
const fenced = render('```rust\nlet x = *y*;\n```');
checkEqual(only(fenced, 'code').textContent, 'let x = *y*;', 'fenced code');
check(elements(fenced, 'span').some(s => s.className === 'tok-kw'), 'fenced rust untinted');
checkEqual(elements(fenced, 'em').length, 0, 'fence emphasised its contents');
checkEqual(only(fenced, 'pre').dataset.lang, 'rust', 'fence language');
// Inline code beats emphasis
checkEqual(only(render('`*not em*`'), 'code').textContent, '*not em*', 'inline code');
checkEqual(elements(render('`*not em*`'), 'em').length, 0, 'inline code emphasised');
// Emphasis
checkEqual(only(render('**bold**'), 'strong').textContent, 'bold', 'strong');
checkEqual(only(render('*it*'), 'em').textContent, 'it', 'em');
checkEqual(only(render('~~gone~~'), 'del').textContent, 'gone', 'del');
// Tables
const table = render('| a | b |\n| --- | ---: |\n| 1 | 2 |');
checkEqual(elements(table, 'table').length, 1, 'table');
checkEqual(elements(table, 'th').length, 2, 'table header cells');
checkEqual(elements(table, 'td').length, 2, 'table body cells');
checkEqual(elements(table, 'th')[1].className, 'align-right', 'table alignment class');
checkEqual(only(table, 'div').className, 'scroll-x', 'table scroll wrapper');
// Blockquote and rule
checkEqual(elements(render('> quoted'), 'blockquote').length, 1, 'blockquote');
checkEqual(elements(render('---'), 'hr').length, 1, 'rule');
// XSS: markup is text, never elements
const injected = render('<img src=x onerror=alert(1)>');
checkEqual(elements(injected, 'img').length, 0, 'raw HTML became an element');
check(injected.textContent.includes('<img src=x onerror=alert(1)>'), 'raw HTML lost its text');
// XSS: refused link schemes
for (const target of ['javascript:alert(1)', 'JaVaScRiPt:alert(1)', 'java\tscript:alert(1)', 'data:text/html,<script>', 'vbscript:x']) {
const out = render(`[click](${target})`);
checkEqual(elements(out, 'a').length, 0, `link scheme ${JSON.stringify(target)} was allowed`);
check(out.textContent.includes('click'), `link scheme ${JSON.stringify(target)} lost its label`);
}
// Accepted schemes keep their href and carry safe rel/target
for (const target of ['https://example.com', 'http://example.com/a', 'mailto:someone@example.com']) {
const anchor = only(render(`[click](${target})`), 'a');
checkEqual(anchor.getAttribute('href'), target, 'href');
checkEqual(anchor.getAttribute('rel'), 'noreferrer noopener', 'rel');
checkEqual(anchor.getAttribute('target'), '_blank', 'target');
}
// safeHref directly
checkEqual(safeHref('javascript:alert(1)'), null, 'safeHref allowed javascript:');
checkEqual(safeHref(' https://x.test '), 'https://x.test', 'safeHref cleaned value');
// Inline markup inside a link label
checkEqual(only(render('[**bold link**](https://x.test)'), 'strong').textContent, 'bold link', 'link label markup');
// An unclosed delimiter is literal, not markup
checkEqual(render('a * b').textContent, 'a * b', 'unclosed emphasis');
checkEqual(elements(render('a * b'), 'em').length, 0, 'unclosed emphasis made an element');
// Diff summaries: the real format from format_diffstat, two spaces and U+2212
const diff = renderDiffSummary(['src/main.rs +12 −3', 'unparseable line']);
const items = elements(diff, 'li');
checkEqual(items.length, 2, 'diffstat rows');
checkEqual(elements(items[0], 'span')[0].textContent, 'src/main.rs', 'diffstat path');
checkEqual(elements(items[0], 'span')[1].textContent, '+12', 'diffstat additions');
checkEqual(elements(items[0], 'span')[2].textContent, '−3', 'diffstat deletions');
checkEqual(elements(items[1], 'span').length, 1, 'unparseable diffstat produced counts');
checkEqual(elements(items[1], 'span')[0].textContent, 'unparseable line', 'unparseable diffstat lost its text');
console.log('all markdown checks passed');
"#,
);
}
#[test]
fn tool_output_is_tinted_folded_and_never_read_as_markdown() {
run_web_check(
"tool-output",
r#"import { installDocument, elements, only, check, checkEqual, openFold } from './test-dom.js';
installDocument();
const { renderToolOutput, codeBlock, detectLang, appendCommandTokens, isPathLike } = await import(
'./tool-output.js'
);
const classes = root => elements(root, 'span').map(s => s.className);
// A shell command is told apart into program, subcommand, flag and path.
const line = document.createElement('pre');
appendCommandTokens(line, 'cargo test --workspace src/lib.rs');
const seen = classes(line);
check(seen.includes('cmd-program'), 'no program: ' + seen);
check(seen.includes('cmd-subcommand'), 'no subcommand: ' + seen);
check(seen.includes('cmd-flag'), 'no flag: ' + seen);
check(seen.includes('cmd-path'), 'no path: ' + seen);
checkEqual(line.textContent, 'cargo test --workspace src/lib.rs', 'command text changed');
// An operator starts the program count again, so both programs are found.
const piped = document.createElement('pre');
appendCommandTokens(piped, 'git status && cargo build');
checkEqual(classes(piped).filter(c => c === 'cmd-program').length, 2, 'pipeline reset');
// Prose with a slash is not a path; a real path is.
check(!isPathLike('and/or'), '"and/or" read as a path');
check(isPathLike('src/lib/thing.rs'), 'a real path did not');
check(isPathLike('./x'), 'a relative path did not');
check(isPathLike('Cargo.toml'), 'a file with an extension did not');
// JSON is pretty-printed and tinted, keys apart from values.
const json = renderToolOutput('{"name":"hel","count":3,"ok":true}');
const jsonClasses = classes(json);
check(jsonClasses.includes('tok-key'), 'no JSON key: ' + jsonClasses);
check(jsonClasses.includes('tok-str'), 'no JSON string: ' + jsonClasses);
check(jsonClasses.includes('tok-num'), 'no JSON number: ' + jsonClasses);
check(jsonClasses.includes('tok-kw'), 'no JSON keyword: ' + jsonClasses);
check(json.textContent.includes('"name"'), 'JSON lost its content');
// Rust is tinted; an unknown language is not.
const rust = codeBlock('pub fn main() {\n let x = 1;\n}', 'rust');
check(classes(rust).includes('tok-kw'), 'rust keywords untinted');
checkEqual(only(rust, 'pre').dataset.lang, 'rust', 'rust data-lang');
const plain = codeBlock('nothing in particular here', 'brainfuck');
checkEqual(classes(plain).length, 0, 'unknown language was tinted');
// Sniffing is conservative: a log stays plain, real code does not.
checkEqual(detectLang('12:03 INFO started\n12:04 INFO done\n12:05 INFO stopped'), '', 'a log was sniffed');
checkEqual(
detectLang('fn a() {}\nfn b() {}\nlet mut x = 1;\nuse std::fmt;\nimpl Foo {}\nlet y = x.unwrap();'),
'rust',
'rust was not sniffed',
);
checkEqual(detectLang('--- a/x\n+++ b/x\n@@ -1 +1 @@\n-old\n+new'), 'diff', 'diff was not sniffed');
// A long dump is one closed fold that has built nothing yet.
const long = Array.from({ length: 400 }, (_, i) => `line ${i}`).join('\n');
const folded = renderToolOutput(long);
checkEqual(folded.nodeName, 'DETAILS', 'a 400-line dump was not folded');
checkEqual(elements(folded, 'pre').length, 0, 'a closed fold built its content anyway');
check(only(folded, 'summary').textContent.includes('400 lines'), 'fold summary: ' + only(folded, 'summary').textContent);
openFold(folded);
checkEqual(elements(folded, 'pre').length, 1, 'an opened fold built nothing');
check(elements(folded, 'pre')[0].textContent.includes('line 399'), 'the fold lost its content');
// Opening twice builds once.
openFold(folded);
checkEqual(elements(folded, 'pre').length, 1, 'reopening rebuilt the content');
// A short dump is not folded.
checkEqual(renderToolOutput('one\ntwo').nodeName, 'PRE', 'a short dump was folded');
// Tool output is never parsed as Markdown, so an underscore is an underscore.
const literal = renderToolOutput('a _b_ c <img src=x>');
checkEqual(elements(literal, 'em').length, 0, 'tool output was emphasised');
checkEqual(elements(literal, 'img').length, 0, 'tool output produced an element');
check(literal.textContent.includes('<img src=x>'), 'tool output lost its text');
console.log('all tool-output checks passed');
"#,
);
}
#[test]
fn no_web_module_builds_markup_from_a_string() {
const SINKS: [&str; 5] = [
"innerHTML",
"outerHTML",
"insertAdjacentHTML",
"document.write",
"new Function",
];
const ALLOWED: [(&str, &str); 0] = [];
for (name, source) in [
("viewer.js", VIEWER_JS),
("markdown.js", MARKDOWN_JS),
("tool-output.js", TOOL_OUTPUT_JS),
] {
for (number, line) in source.lines().enumerate() {
let trimmed = line.trim();
if trimmed.starts_with("//") || trimmed.starts_with("///") {
continue;
}
for sink in SINKS {
if !trimmed.contains(sink) {
continue;
}
assert!(
ALLOWED
.iter()
.any(|(file, allowed)| *file == name && trimmed == *allowed),
"{name}:{} builds markup from a string: {trimmed}",
number + 1
);
}
}
}
}
#[test]
fn embedded_viewer_keeps_elicitation_answers_across_snapshot_polls() {
let source = viewer_source(
"const elicitationCards = new Map()",
"async function submitElicitation",
);
let dom = r#"
let replaceCalls = 0;
function makeEl(tag) {
return {
tagName: tag.toUpperCase(),
children: [],
options: [],
selectedOptions: [],
className: "",
textContent: "",
disabled: false,
required: false,
value: "",
appendChild(child) {
this.children.push(child);
if (this.tagName === "SELECT") this.options.push(child);
return child;
},
append(...kids) {
this.children.push(...kids);
},
replaceChildren(...kids) {
replaceCalls += 1;
this.children = kids;
},
addEventListener() {},
querySelectorAll(selector) {
const found = [];
const visit = node => {
for (const child of node.children) {
if (child.tagName === "INPUT" && (selector === "input" || child.checked)) found.push(child);
visit(child);
}
};
visit(this);
return found;
},
querySelector(selector) { return this.querySelectorAll(selector)[0] || null; },
setCustomValidity() {},
reportValidity() {
return true;
},
};
}
const created = [];
const document = {
createElement(tag) {
const el = makeEl(tag);
created.push(el);
return el;
},
};
const elicitations = makeEl("div");
function el(tag, className, text) {
const node = document.createElement(tag);
node.className = className || "";
node.textContent = text || "";
return node;
}
async function submitElicitation() {}
"#;
let checks = r#"
const request = {
id: "elicitation-1",
message: "Which CI architecture?",
title: "CI",
fields: [
{
id: "question_0",
title: "CI architecture",
required: false,
kind: "single_select",
options: [{ value: "reusable", title: "Reusable" }, { value: "matrix", title: "Matrix" }],
},
{ id: "question_0_custom", title: "Other", required: false, kind: "text" },
],
};
const session = { id: "session-1", pending_elicitations: [request] };
renderElicitations(session);
const card = elicitations.children[0];
const radio = created.find((el) => el.tagName === "INPUT" && el.value === "reusable");
const text = created.find((el) => el.tagName === "INPUT" && el.type === "text");
radio.checked = true;
text.value = "keep me";
const attachments = replaceCalls;
renderElicitations(session);
if (elicitations.children[0] !== card) {
throw new Error("a snapshot rebuilt the pending card");
}
if (!radio.checked || text.value !== "keep me") {
throw new Error("a snapshot wiped the half-filled answer");
}
if (replaceCalls !== attachments) {
throw new Error("a snapshot re-attached an unchanged card and dropped focus");
}
sentElicitations.add(elicitationKey("session-1", request.id));
renderElicitations(session);
if (elicitations.children[0] !== card) {
throw new Error("a sent answer rebuilt the card");
}
if (!radio.disabled || !text.disabled) {
throw new Error("a sent answer left the controls live");
}
if (!radio.checked) {
throw new Error("a sent answer wiped the reply");
}
renderElicitations({ id: "session-1", pending_elicitations: [] });
if (elicitations.children.length !== 0 || elicitationCards.size !== 0) {
throw new Error("an answered request stayed rendered");
}
if (sentElicitations.size !== 0) {
throw new Error("a resolved request kept its sent marker");
}
"#;
run_viewer_script(
"elicitation-rendering",
&format!("{dom}\n{source}\n{checks}"),
);
}
fn sample_image(pixels: usize) -> ViewerPromptImage {
ViewerPromptImage {
data_base64: base64::engine::general_purpose::STANDARD.encode(vec![7_u8; pixels]),
mime_type: "image/png".into(),
width: 32,
height: 24,
attachment: None,
}
}
fn sample_valid_image() -> ViewerPromptImage {
ViewerPromptImage {
data_base64: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="
.into(),
mime_type: "image/png".into(),
width: 1,
height: 1,
attachment: None,
}
}
fn image_capable(snapshot: &mut ViewerSnapshot) {
snapshot.sessions[0].prompt_images_supported = true;
}
async fn post_action(app: Router, cookie: String, body: String) -> Response<Body> {
app.oneshot(
Request::post("/api/actions")
.header(COOKIE, cookie)
.header(CONTENT_TYPE, "application/json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap()
}
#[tokio::test]
async fn image_prompt_reaches_the_controller_with_its_images() {
let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
let cookie = login_cookie(&app).await;
let image = sample_valid_image();
let body = serde_json::to_string(&ControllerAction::Prompt {
session_id: "session-1".into(),
text: String::new(),
images: vec![image.clone(), image.clone()],
})
.unwrap();
let response = tokio::spawn(post_action(app, cookie, body));
let request = actions.recv().await.unwrap();
let ControllerRequest { action, reply } = request;
let ControllerAction::Prompt {
session_id,
text,
images,
} = action
else {
panic!("expected a prompt action")
};
assert_eq!(session_id, "session-1");
assert!(text.is_empty());
assert_eq!(images.len(), 2);
assert!(
images
.iter()
.all(|image| { image.data_base64.is_empty() && image.attachment.is_some() })
);
reply.send(ActionOutcome::Accepted).unwrap();
assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
}
#[tokio::test]
async fn browser_attachment_upload_returns_a_stored_reference_without_inline_bytes() {
let (app, _, _, _, _) = app_with_snapshot(image_capable);
let cookie = login_cookie(&app).await;
let bytes = base64::engine::general_purpose::STANDARD
.decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")
.unwrap();
let response = app
.oneshot(
Request::post("/api/sessions/session-1/attachments")
.header(COOKIE, cookie)
.header(CONTENT_TYPE, "image/png")
.body(Body::from(bytes))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let image: ViewerPromptImage = serde_json::from_slice(&body).unwrap();
assert!(image.data_base64.is_empty());
let reference = image.attachment.expect("upload should return a reference");
assert_eq!(reference.mime_type, "image/png");
assert_eq!(reference.width, 1);
assert_eq!(reference.height, 1);
assert!(reference.size <= 700 * 1024);
}
#[tokio::test]
async fn multi_image_prompts_are_accepted_over_the_general_body_limit() {
let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
let cookie = login_cookie(&app).await;
let image = sample_valid_image();
let mut body = serde_json::to_string(&ControllerAction::Prompt {
session_id: "session-1".into(),
text: "look at these".into(),
images: vec![image.clone(), image],
})
.unwrap();
body.push_str(&" ".repeat(MAX_BODY_BYTES));
assert!(body.len() > MAX_BODY_BYTES);
assert!(body.len() < MAX_PROMPT_BODY_BYTES);
let response = tokio::spawn(post_action(app, cookie, body));
let action = actions.recv().await.unwrap();
action.reply.send(ActionOutcome::Accepted).unwrap();
assert_eq!(response.await.unwrap().status(), StatusCode::ACCEPTED);
}
#[tokio::test]
async fn a_body_over_the_prompt_limit_is_still_refused() {
let (app, _actions, _, _, _) = app_with_snapshot(image_capable);
let cookie = login_cookie(&app).await;
let image = sample_image(MAX_PROMPT_BODY_BYTES);
let body = serde_json::to_string(&ControllerAction::Prompt {
session_id: "session-1".into(),
text: String::new(),
images: vec![image],
})
.unwrap();
assert!(body.len() > MAX_PROMPT_BODY_BYTES);
let response = post_action(app, cookie, body).await;
assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test]
async fn malformed_image_payloads_never_reach_the_controller() {
let cases = [
("aW1hZ2U=", "text/plain", 32, 24),
("aW1hZ2U=", "image/png", 0, 24),
("not base64!", "image/png", 32, 24),
("", "image/png", 32, 24),
];
for (data, mime, width, height) in cases {
let (app, mut actions, _, _, _) = app_with_snapshot(image_capable);
let cookie = login_cookie(&app).await;
let body = serde_json::to_string(&ControllerAction::Prompt {
session_id: "session-1".into(),
text: String::new(),
images: vec![ViewerPromptImage {
data_base64: data.into(),
mime_type: mime.into(),
width,
height,
attachment: None,
}],
})
.unwrap();
let response = post_action(app, cookie, body).await;
assert_eq!(
response.status(),
StatusCode::BAD_REQUEST,
"expected {data:?}/{mime} {width}x{height} to be refused"
);
assert!(actions.try_recv().is_err());
}
}
#[test]
fn image_prompts_need_text_or_an_image_and_an_agent_that_takes_them() {
let (config, state) = sample_config_state();
let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
let prompt = |text: &str, images: Vec<ViewerPromptImage>| ControllerAction::Prompt {
session_id: "session-1".into(),
text: text.into(),
images,
};
assert!(validate_action(&prompt("ship it", Vec::new()), &snapshot).is_ok());
assert!(validate_action(&prompt("", vec![sample_image(8)]), &snapshot).is_err());
image_capable(&mut snapshot);
assert!(validate_action(&prompt("", vec![sample_image(8)]), &snapshot).is_ok());
assert!(
validate_action(
&prompt("", vec![sample_image(8); MAX_PROMPT_IMAGES + 1]),
&snapshot,
)
.is_err()
);
assert!(validate_action(&prompt(" ", Vec::new()), &snapshot).is_err());
assert!(validate_action(&prompt("", Vec::new()), &snapshot).is_err());
assert!(validate_action(&prompt("!ls", vec![sample_image(8)]), &snapshot).is_err());
}
#[test]
fn embedded_viewer_reads_multiline_composer_text_out_of_its_dom() {
let source = viewer_source("function composerText()", "function setComposerText(");
let harness = r##"
const Node = { TEXT_NODE: 3 };
function textNode(value) {
return { nodeType: 3, nodeValue: value, nodeName: "#text", childNodes: [], dataset: {} };
}
function element(name, children = [], dataset = {}) {
const node = { nodeType: 1, nodeName: name, dataset, childNodes: children };
children.forEach((child, index) => {
child.nextSibling = children[index + 1] || null;
});
return node;
}
let promptText = null;
function read(children) {
promptText = element("DIV", children);
return composerText();
}
"##;
let checks = r#"
const plain = read([textNode("ship it")]);
if (plain !== "ship it") throw new Error(`plain text became ${JSON.stringify(plain)}`);
const broken = read([textNode("first"), element("BR"), textNode("second")]);
if (broken !== "first\nsecond") throw new Error(`line break became ${JSON.stringify(broken)}`);
// The trailing break a browser leaves behind to keep the caret on a new line
// is scaffolding, not a line the user typed.
const filler = read([
textNode("first"),
element("BR"),
element("BR", [], { composerFiller: "true" }),
]);
if (filler !== "first\n") throw new Error(`filler break became ${JSON.stringify(filler)}`);
const blocks = read([
textNode("first"),
element("DIV", [textNode("second")]),
element("DIV", [textNode("third")]),
]);
if (blocks !== "first\nsecond\nthird") throw new Error(`blocks became ${JSON.stringify(blocks)}`);
const carriage = read([textNode("first\r\nsecond")]);
if (carriage !== "first\nsecond") throw new Error(`CRLF became ${JSON.stringify(carriage)}`);
"#;
run_viewer_script("composer-reader", &format!("{harness}\n{source}\n{checks}"));
}
#[tokio::test]
async fn viewer_declares_the_icon_route_instead_of_requesting_a_missing_favicon() {
let (app, _, _, _, _) = app();
let page = fetch_text(app.clone(), "/").await;
assert!(page.contains(r#"rel="icon""#), "the page declares no icon");
assert!(page.contains("/icon.svg"), "the page names no icon route");
let icon = app
.oneshot(Request::get("/icon.svg").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(icon.status(), StatusCode::OK);
assert_eq!(
icon.headers().get(CONTENT_TYPE).unwrap(),
"image/svg+xml",
"the icon route does not serve an SVG"
);
}
#[tokio::test]
async fn valid_action_is_typed_and_forwarded() {
let (app, mut actions, _, _, _) = app();
let cookie = login_cookie(&app).await;
let response = tokio::spawn(
app.oneshot(
Request::post("/api/actions")
.header(COOKIE, cookie)
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
r#"{"action":"prompt","session_id":"session-1","text":"ship it"}"#,
))
.unwrap(),
),
);
let action = actions.recv().await.unwrap();
assert_eq!(
action.action,
ControllerAction::Prompt {
session_id: "session-1".into(),
text: "ship it".into(),
images: Vec::new(),
}
);
action.reply.send(ActionOutcome::Accepted).unwrap();
let response = response.await.unwrap().unwrap();
assert_eq!(response.status(), StatusCode::ACCEPTED);
}
#[tokio::test]
async fn move_preparation_is_read_only_and_returns_the_daemon_fingerprint() {
let (app, mut preparations) = app_with_move_receiver();
let cookie = login_cookie(&app).await;
let response = tokio::spawn(
app.oneshot(
Request::post("/api/moves/prepare")
.header(COOKIE, cookie)
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
r#"{"session_id":"session-1","profile_id":"codex-1","target_template_id":"podman","clear_resource_allocation":false,"additional_mounts":null,"resource_allocation":null}"#,
))
.unwrap(),
),
);
let request = preparations
.recv()
.await
.expect("preparation reached daemon");
assert_eq!(request.selection.session_id, "session-1");
assert_eq!(request.selection.profile_id.as_deref(), Some("codex-1"));
assert_eq!(
request.selection.target_template_id.as_deref(),
Some("podman")
);
request
.reply
.send(Ok(MovePreparation {
selection: request.selection,
source_profile_id: "codex-1".into(),
source_target_template_id: "podman".into(),
cross_harness: false,
active: true,
queued_commands: vec![hel::hel_state::MaterializedQueuedPrompt {
command_id: "queued-1".into(),
kind: hel::hel_state::QueuedCommandKind::Prompt,
content: vec![serde_json::json!({
"type": "image",
"mimeType": "image/png",
"data": "secret-image-bytes"
})],
queued_at_ms: 1,
}],
fingerprint: "fingerprint".into(),
operation_id: "move-1".into(),
}))
.unwrap();
let response = response.await.unwrap().unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(body["operation_id"], "move-1");
assert_eq!(body["active"], true);
assert_eq!(
body["queued_commands"][0]["content"][0]["text"],
"[Image attachment: image/png]"
);
assert!(body.to_string().contains("[Image attachment: image/png]"));
assert!(!body.to_string().contains("secret-image-bytes"));
}
#[tokio::test]
async fn confirmed_move_action_forwards_the_fingerprinted_request() {
let (app, mut actions, _, _, _) = app_with_snapshot(|snapshot| {
snapshot.sessions[0].capabilities.move_session = true;
});
let cookie = login_cookie(&app).await;
let response = tokio::spawn(
app.oneshot(
Request::post("/api/actions")
.header(COOKIE, cookie)
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
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}}"#,
))
.unwrap(),
),
);
let action = actions.recv().await.expect("move action reached daemon");
assert!(matches!(action.action, ControllerAction::Move { .. }));
action.reply.send(ActionOutcome::Accepted).unwrap();
assert_eq!(
response.await.unwrap().unwrap().status(),
StatusCode::ACCEPTED
);
}
#[tokio::test]
async fn shell_action_is_typed_and_forwarded() {
let (app, mut actions, _, _, _) = app();
let cookie = login_cookie(&app).await;
let response = tokio::spawn(
app.oneshot(
Request::post("/api/actions")
.header(COOKIE, cookie)
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
r#"{"action":"run-shell","session_id":"session-1","command":"cargo test"}"#,
))
.unwrap(),
),
);
let action = actions.recv().await.unwrap();
assert_eq!(
action.action,
ControllerAction::RunShell {
session_id: "session-1".into(),
command: "cargo test".into(),
}
);
action.reply.send(ActionOutcome::Accepted).unwrap();
assert_eq!(
response.await.unwrap().unwrap().status(),
StatusCode::ACCEPTED
);
}
#[test]
fn shell_action_validation_reserves_bang_prompts_and_checks_cancellation_ids() {
let (config, state) = sample_config_state();
let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
assert!(
validate_action(
&ControllerAction::Prompt {
session_id: "session-1".into(),
text: "!cargo test".into(),
images: Vec::new(),
},
&snapshot,
)
.is_err()
);
assert!(
validate_action(
&ControllerAction::RunShell {
session_id: "session-1".into(),
command: "cargo test".into(),
},
&snapshot,
)
.is_ok()
);
assert!(
validate_action(
&ControllerAction::CancelShell {
session_id: "session-1".into(),
shell_command_id: "shell-1".into(),
},
&snapshot,
)
.is_err()
);
snapshot.sessions[0]
.active_user_shells
.push(ViewerUserShell {
id: "shell-1".into(),
command: "cargo test".into(),
started_at_ms: Some(10),
});
assert!(
validate_action(
&ControllerAction::CancelShell {
session_id: "session-1".into(),
shell_command_id: "shell-1".into(),
},
&snapshot,
)
.is_ok()
);
}
#[tokio::test]
async fn bare_new_action_forwards_an_explicit_safe_project_directory() {
let (app, mut actions, _, _, _) = app();
let cookie = login_cookie(&app).await;
let response = tokio::spawn(
app.oneshot(
Request::post("/api/actions")
.header(COOKIE, cookie)
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
r#"{"action":"new","profile_id":"codex-1","bundle_id":"hel","target_id":"raw","title":"Raw work","project_directory":"/work/project"}"#,
))
.unwrap(),
),
);
let action = actions.recv().await.unwrap();
assert_eq!(
action.action,
ControllerAction::New {
workspace_id: String::new(),
profile_id: "codex-1".into(),
bundle_id: "hel".into(),
target_id: "raw".into(),
title: Some("Raw work".into()),
project_directory: Some(PathBuf::from("/work/project")),
dirty_ack: Vec::new(),
}
);
action.reply.send(ActionOutcome::Accepted).unwrap();
assert_eq!(
response.await.unwrap().unwrap().status(),
StatusCode::ACCEPTED
);
}
#[test]
fn new_action_requires_project_directory_exactly_for_bare_targets() {
let (config, state) = sample_config_state();
let snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
let action = |target_id: &str, project_directory: Option<PathBuf>| ControllerAction::New {
workspace_id: String::new(),
profile_id: "codex-1".into(),
bundle_id: "hel".into(),
target_id: target_id.into(),
title: Some("New work".into()),
project_directory,
dirty_ack: Vec::new(),
};
assert!(validate_action(&action("podman", None), &snapshot).is_ok());
assert_eq!(
validate_action(&action("podman", Some("/work".into())), &snapshot)
.unwrap_err()
.status,
StatusCode::BAD_REQUEST
);
assert_eq!(
validate_action(&action("raw", None), &snapshot)
.unwrap_err()
.status,
StatusCode::BAD_REQUEST
);
assert_eq!(
validate_action(&action("raw", Some("relative".into())), &snapshot)
.unwrap_err()
.status,
StatusCode::BAD_REQUEST
);
assert_eq!(
validate_action(&action("raw", Some("/work/../secret".into())), &snapshot)
.unwrap_err()
.status,
StatusCode::BAD_REQUEST
);
assert!(validate_action(&action("raw", Some("/work/project".into())), &snapshot).is_ok());
}
#[tokio::test]
async fn cancel_action_is_typed_and_forwarded() {
let (app, mut actions, _, _, _) = app();
let cookie = login_cookie(&app).await;
let response = tokio::spawn(
app.oneshot(
Request::post("/api/actions")
.header(COOKIE, cookie)
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
r#"{"action":"cancel","session_id":"session-1"}"#,
))
.unwrap(),
),
);
let action = actions.recv().await.unwrap();
assert_eq!(
action.action,
ControllerAction::Cancel {
session_id: "session-1".into(),
}
);
action.reply.send(ActionOutcome::Accepted).unwrap();
assert_eq!(
response.await.unwrap().unwrap().status(),
StatusCode::ACCEPTED
);
}
#[tokio::test]
async fn action_validation_accepts_cross_harness_resume_and_rejects_unknown() {
let (mut config, state) = sample_config_state();
config.profiles.insert(
"claude-1".into(),
HarnessProfile {
context_window_bytes: None,
kind: HarnessKind::Claude,
home: "/secret/claude".into(),
environment: BTreeMap::new(),
},
);
let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
snapshot.workspaces.push(ViewerWorkspace {
id: "workspace-1".into(),
name: "One".into(),
});
validate_action(
&ControllerAction::Resume {
session_id: "session-1".into(),
workspace_id: "workspace-1".into(),
profile_id: "claude-1".into(),
target_id: "podman".into(),
queue: ResumeQueueDisposition::Start,
additional_mounts: None,
resource_allocation: None,
},
&snapshot,
)
.unwrap();
let error = validate_action(
&ControllerAction::Resume {
session_id: "session-1".into(),
workspace_id: "missing".into(),
profile_id: "claude-1".into(),
target_id: "podman".into(),
queue: ResumeQueueDisposition::Start,
additional_mounts: None,
resource_allocation: None,
},
&snapshot,
)
.unwrap_err();
assert_eq!(error.status, StatusCode::BAD_REQUEST);
let error = validate_action(
&ControllerAction::Close {
session_id: "not-managed".into(),
},
&snapshot,
)
.unwrap_err();
assert_eq!(error.status, StatusCode::NOT_FOUND);
}
#[test]
fn a_running_review_projects_to_the_phone() {
use crate::hel_review_host::{RuntimeReviewView, VerdictKind, VerdictView};
use hel::hel_review::driver::{Resolution, RoleState, RoleStatus, TurnReviewPhase};
let review = RuntimeReviewView {
session_id: "session-1".into(),
tier: hel::hel_review::lanes::ReviewTier::Extended,
phase: TurnReviewPhase::Verdict(hel::hel_review::verdict::ReviewVerdict::Findings {
synthesis: "[P1] src/lib.rs:1 -- unbounded retry".into(),
evidence: Default::default(),
}),
roles: vec![
RoleStatus {
role: "supervisor".into(),
label: "Supervisor".into(),
state: RoleState::Clean,
},
RoleStatus {
role: "tests".into(),
label: "Tests".into(),
state: RoleState::Findings,
},
],
status: "Enter to act".into(),
verdict: Some(VerdictView {
kind: VerdictKind::Findings,
text: "[P1] src/lib.rs:1 -- unbounded retry".into(),
allowed: vec![
Resolution::Forwarded,
Resolution::Dismissed,
Resolution::Cancelled,
],
}),
};
let projected = ViewerTurnReview::from_runtime(&review);
assert_eq!(projected.tier, "extended");
assert_eq!(
projected
.roles
.iter()
.map(|role| (role.label.as_str(), role.state.as_str()))
.collect::<Vec<_>>(),
vec![("Supervisor", "done"), ("Tests", "findings")]
);
let verdict = projected.verdict.expect("a findings verdict travels");
assert_eq!(verdict.kind, "findings");
assert!(verdict.text.contains("unbounded retry"));
assert_eq!(verdict.allowed, vec!["forward", "dismiss", "cancel"]);
}
#[test]
fn resolving_a_review_is_gated_on_what_the_daemon_published() {
let (config, state) = sample_config_state();
let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
let resolve = |resolution: &str| ControllerAction::ResolveReview {
session_id: "session-1".into(),
resolution: resolution.into(),
};
let error = validate_action(&resolve("cancel"), &snapshot).unwrap_err();
assert_eq!(error.status, StatusCode::BAD_REQUEST);
snapshot.sessions[0].turn_review = Some(ViewerTurnReview {
tier: "quick".into(),
status: "the reviewer is reading the change…".into(),
roles: Vec::new(),
verdict: None,
});
validate_action(&resolve("cancel"), &snapshot).unwrap();
assert_eq!(
validate_action(&resolve("forward"), &snapshot)
.unwrap_err()
.status,
StatusCode::BAD_REQUEST
);
snapshot.sessions[0].turn_review = Some(ViewerTurnReview {
tier: "quick".into(),
status: "the review failed".into(),
roles: Vec::new(),
verdict: Some(ViewerReviewVerdict {
kind: "failed".into(),
text: "bifrost exited with 1".into(),
allowed: vec!["dismiss".into(), "cancel".into()],
}),
});
validate_action(&resolve("dismiss"), &snapshot).unwrap();
assert_eq!(
validate_action(&resolve("forward"), &snapshot)
.unwrap_err()
.status,
StatusCode::BAD_REQUEST
);
assert_eq!(
validate_action(&resolve("approve"), &snapshot)
.unwrap_err()
.status,
StatusCode::BAD_REQUEST
);
validate_action(
&ControllerAction::StartReview {
session_id: "session-1".into(),
},
&snapshot,
)
.unwrap();
assert_eq!(
validate_action(
&ControllerAction::StartReview {
session_id: "not-managed".into(),
},
&snapshot,
)
.unwrap_err()
.status,
StatusCode::NOT_FOUND
);
}
#[test]
fn resume_action_refuses_a_target_the_session_cannot_use() {
let (mut config, state) = sample_config_state();
config.bundles.get_mut("hel").unwrap().repositories[0].local = None;
let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
snapshot.workspaces.push(ViewerWorkspace {
id: "workspace-1".into(),
name: "One".into(),
});
assert_eq!(
snapshot.sessions[0].incompatible_resume_targets,
vec!["raw".to_owned()]
);
let error = validate_action(
&ControllerAction::Resume {
session_id: "session-1".into(),
workspace_id: "workspace-1".into(),
profile_id: "codex-1".into(),
target_id: "raw".into(),
queue: ResumeQueueDisposition::Start,
additional_mounts: None,
resource_allocation: None,
},
&snapshot,
)
.unwrap_err();
assert_eq!(error.status, StatusCode::BAD_REQUEST);
}
#[test]
fn move_confirmation_requires_interruption_ack_and_an_explicit_queue_choice() {
let (config, state) = sample_config_state();
let mut snapshot = ViewerSnapshot::from_config_state(&config, &state, 1);
snapshot.sessions[0].capabilities.move_session = true;
let selection = MoveSelection {
clear_resource_allocation: false,
session_id: "session-1".into(),
profile_id: Some("codex-1".into()),
target_template_id: Some("podman".into()),
additional_mounts: None,
resource_allocation: None,
};
let preparation = MovePreparation {
selection,
source_profile_id: "codex-1".into(),
source_target_template_id: "podman".into(),
cross_harness: false,
active: true,
queued_commands: vec![hel::hel_state::MaterializedQueuedPrompt {
command_id: "command-1".into(),
kind: hel::hel_state::QueuedCommandKind::Prompt,
content: vec![serde_json::json!({"type": "text", "text": "continue"})],
queued_at_ms: 1,
}],
fingerprint: "fingerprint".into(),
operation_id: "move-1".into(),
};
let request = |queue, acknowledge_interruption| MoveSessionRequest {
preparation: preparation.clone(),
queue,
acknowledge_interruption,
};
assert_eq!(
validate_action(
&ControllerAction::Move {
request: request(Some(ResumeQueueDisposition::Discard), false),
},
&snapshot,
)
.unwrap_err()
.status,
StatusCode::CONFLICT
);
assert_eq!(
validate_action(
&ControllerAction::Move {
request: request(None, true),
},
&snapshot,
)
.unwrap_err()
.status,
StatusCode::BAD_REQUEST
);
validate_action(
&ControllerAction::Move {
request: request(Some(ResumeQueueDisposition::Discard), true),
},
&snapshot,
)
.unwrap();
}
#[tokio::test]
async fn snapshot_endpoint_returns_only_public_projection() {
let (app, _, _, _, _) = app();
let cookie = login_cookie(&app).await;
let response = app
.oneshot(
Request::get("/api/snapshot")
.header(COOKIE, cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = response.into_body().collect().await.unwrap().to_bytes();
let body = String::from_utf8(body.to_vec()).unwrap();
assert!(body.contains("session-1"));
assert!(!body.contains("secret-token"));
assert!(!body.contains("native-secret-id"));
assert!(!body.contains("/private/source/hel"));
let snapshot: serde_json::Value = serde_json::from_str(&body).unwrap();
let repository = &snapshot["bundles"][0]["repositories"][0];
assert_eq!(repository["id"], "hel");
assert_eq!(repository["github"], "owner/hel");
assert_eq!(repository["destination"], "hel");
assert!(repository.get("local").is_none());
}
#[tokio::test]
async fn snapshot_clock_anchor_is_fresh_even_when_the_projection_has_not_changed() {
let (app, _, _, _, _) = app_with_snapshot(|snapshot| snapshot.server_time_ms = 1);
let cookie = login_cookie(&app).await;
for _ in 0..2 {
let before = hel::clock::epoch_millis();
let response = app
.clone()
.oneshot(
Request::get("/api/snapshot")
.header(COOKIE, &cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let body = response.into_body().collect().await.unwrap().to_bytes();
let snapshot: ViewerSnapshot = serde_json::from_slice(&body).unwrap();
assert!(snapshot.server_time_ms >= before);
assert!(snapshot.server_time_ms <= hel::clock::epoch_millis());
}
}
#[tokio::test]
async fn conversation_endpoint_returns_authenticated_bounded_deltas() {
let transcript = BrowserTranscript {
latest_seq: 8,
window_start_seq: 3,
reset: false,
entries: vec![
BrowserTranscriptEntry {
id: 3,
updated_seq: 3,
role: "user",
label: "You".into(),
recorded_at_ms: None,
lines: vec!["begin".into()],
glyph: "\u{276f}",
tone: "user",
tool_status: None,
diffstats: Vec::new(),
},
BrowserTranscriptEntry {
id: 7,
updated_seq: 8,
role: "agent",
label: "Agent".into(),
recorded_at_ms: None,
lines: vec!["live".into()],
glyph: "\u{25cf}",
tone: "agent",
tool_status: None,
diffstats: Vec::new(),
},
],
};
let (app, _, _, _, _) =
app_with_conversations(BTreeMap::from([("session-1".into(), transcript)]));
let cookie = login_cookie(&app).await;
let response = app
.oneshot(
Request::get("/api/conversations/session-1?after_seq=3")
.header(COOKIE, cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = response.into_body().collect().await.unwrap().to_bytes();
let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
assert_eq!(body["latest_seq"], 8);
assert_eq!(body["reset"], false);
assert_eq!(body["entries"].as_array().unwrap().len(), 1);
assert_eq!(body["entries"][0]["lines"][0], "live");
}
#[tokio::test]
async fn conversation_endpoint_rejects_cached_transcript_during_transition() {
let transcript = BrowserTranscript {
latest_seq: 1,
window_start_seq: 1,
reset: false,
entries: vec![BrowserTranscriptEntry {
id: 1,
updated_seq: 1,
role: "agent",
label: "Agent".into(),
recorded_at_ms: None,
lines: vec!["stale".into()],
glyph: "●",
tone: "agent",
tool_status: None,
diffstats: Vec::new(),
}],
};
let (app, _, _, _, _) = app_with(
BTreeMap::from([("session-1".into(), transcript)]),
|snapshot| snapshot.sessions[0].transitioning = true,
);
let cookie = login_cookie(&app).await;
let response = app
.oneshot(
Request::get("/api/conversations/session-1")
.header(COOKIE, cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn conversation_read_receipt_never_contends_with_a_running_action() {
let (app, mut actions, mut receipts, _, _) = app();
let cookie = login_cookie(&app).await;
let prompt = tokio::spawn(
app.clone().oneshot(
Request::post("/api/actions")
.header(COOKIE, cookie.clone())
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
r#"{"action":"prompt","session_id":"session-1","text":"ship it"}"#,
))
.unwrap(),
),
);
let action = actions.recv().await.unwrap();
let response = tokio::spawn(
app.oneshot(
Request::post("/api/conversations/session-1/read")
.header(COOKIE, cookie)
.header(CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"through":42}"#))
.unwrap(),
),
);
let receipt = receipts.recv().await.unwrap();
assert_eq!(receipt.session_id, "session-1");
assert_eq!(receipt.through, 42);
receipt.reply.send(Ok(())).unwrap();
assert_eq!(
response.await.unwrap().unwrap().status(),
StatusCode::NO_CONTENT
);
assert!(
actions.try_recv().is_err(),
"a read receipt must not queue a controller action"
);
action.reply.send(ActionOutcome::Accepted).unwrap();
assert_eq!(
prompt.await.unwrap().unwrap().status(),
StatusCode::ACCEPTED
);
}
#[tokio::test]
async fn each_rejected_action_keeps_its_own_status_and_guidance() {
for (outcome, status, guidance) in [
(
ActionOutcome::Busy,
StatusCode::TOO_MANY_REQUESTS,
"concurrent action limit",
),
(
ActionOutcome::SessionBusy,
StatusCode::CONFLICT,
"another operation is already running",
),
(
ActionOutcome::NotCancellable,
StatusCode::CONFLICT,
"no cancellable operation",
),
(
ActionOutcome::Failed,
StatusCode::INTERNAL_SERVER_ERROR,
"could not start this action",
),
] {
let (app, mut actions, _, _, _) = app();
let cookie = login_cookie(&app).await;
let response = tokio::spawn(
app.oneshot(
Request::post("/api/actions")
.header(COOKIE, cookie)
.header(CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"action":"close","session_id":"session-1"}"#))
.unwrap(),
),
);
let request = actions.recv().await.unwrap();
request.reply.send(outcome).unwrap();
let response = response.await.unwrap().unwrap();
assert_eq!(response.status(), status, "{outcome:?}");
let body = response.into_body().collect().await.unwrap().to_bytes();
let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
let error = body["error"].as_str().unwrap();
assert!(error.contains(guidance), "{outcome:?} answered {error:?}");
}
}
#[tokio::test]
async fn the_viewer_shows_a_session_whose_action_failed_after_it_was_accepted() {
let (app, _, _, _, _) = app();
let script = fetch_text(app, "/viewer.js").await;
assert!(script.contains("has_error"), "viewer ignores has_error");
}
#[tokio::test]
async fn every_response_carries_the_security_headers() {
for path in [
"/",
"/viewer.js",
"/voice-worklet.js",
"/voice-worker.js",
"/viewer.css",
"/manifest.webmanifest",
"/api/snapshot",
] {
let (app, _, _, _, _) = app();
let response = app
.oneshot(Request::get(path).body(Body::empty()).unwrap())
.await
.unwrap();
let headers = response.headers();
let policy = headers
.get(CONTENT_SECURITY_POLICY_HEADER)
.unwrap_or_else(|| panic!("{path} carries no content-security policy"))
.to_str()
.unwrap();
assert!(
policy.starts_with("default-src 'none';"),
"{path} does not refuse unlisted sources: {policy}"
);
assert!(
policy.contains("script-src 'self'") && !policy.contains("unsafe-inline"),
"{path} permits inline script: {policy}"
);
assert!(
policy.contains("frame-ancestors 'none'"),
"{path} can be framed: {policy}"
);
assert_eq!(
headers.get(X_CONTENT_TYPE_OPTIONS).unwrap(),
"nosniff",
"{path} permits content sniffing"
);
assert_eq!(
headers.get(REFERRER_POLICY).unwrap(),
"no-referrer",
"{path} leaks a referrer"
);
}
}
#[tokio::test]
async fn the_page_carries_no_inline_script_or_style() {
let (app, _, _, _, _) = app();
let page = fetch_text(app, "/").await;
assert!(
!page.contains("<script>") && !page.contains("<style>"),
"the page inlines script or style, which the policy blocks"
);
assert!(
page.contains(r#"src="/viewer.js""#) && page.contains(r#"href="/viewer.css""#),
"the page does not load its script and style as separate assets"
);
}
#[tokio::test]
async fn live_state_and_the_service_worker_are_never_stored() {
for path in ["/", "/service-worker.js", "/api/snapshot"] {
let (app, _, _, _, _) = app();
let response = app
.oneshot(Request::get(path).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(
response.headers().get(CACHE_CONTROL).unwrap(),
"no-store",
"{path} may be stored"
);
}
}
#[test]
fn the_service_worker_declines_to_handle_live_state() {
assert!(
SERVICE_WORKER.contains("url.pathname.startsWith('/api/')"),
"the service worker does not exclude the API"
);
assert!(
SERVICE_WORKER.contains("url.pathname.startsWith('/auth/')"),
"the service worker does not exclude authentication"
);
assert!(
SERVICE_WORKER.contains("caches.delete"),
"the service worker never deletes a superseded cache"
);
}
#[tokio::test]
async fn the_installable_assets_are_served() {
for (path, content_type) in [
("/icon-192.png", "image/png"),
("/icon-512.png", "image/png"),
("/maskable-512.png", "image/png"),
("/apple-touch-icon.png", "image/png"),
("/fonts/jetbrains-mono.woff2", "font/woff2"),
] {
let (app, _, _, _, _) = app();
let response = app
.oneshot(Request::get(path).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK, "{path} is not served");
assert_eq!(
response.headers().get(CONTENT_TYPE).unwrap(),
content_type,
"{path} is served as the wrong type"
);
}
}
async fn fetch_text(app: Router, path: &str) -> String {
let response = app
.oneshot(Request::get(path).body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK, "{path} is not served");
let body = response.into_body().collect().await.unwrap().to_bytes();
String::from_utf8(body.to_vec()).expect("assets are UTF-8")
}
#[tokio::test]
async fn repeated_wrong_codes_lock_the_login_endpoint() {
let (app, _, _, _, _) = app();
let attempt = |code: &'static str| {
let app = app.clone();
async move {
app.oneshot(
Request::post("/auth/session")
.header(CONTENT_TYPE, "application/json")
.body(Body::from(format!(r#"{{"code":"{code}"}}"#)))
.unwrap(),
)
.await
.unwrap()
.status()
}
};
for _ in 0..MAX_CODE_FAILURES {
assert_eq!(attempt("000000").await, StatusCode::UNAUTHORIZED);
}
assert_eq!(attempt("000000").await, StatusCode::TOO_MANY_REQUESTS);
assert_eq!(attempt("123456").await, StatusCode::TOO_MANY_REQUESTS);
}
#[test]
fn viewer_code_lockouts_lengthen_instead_of_resetting_after_every_wait() {
let serve_one_lockout = |guard: &mut CodeGuard, now: Instant| {
for _ in 0..MAX_CODE_FAILURES {
assert!(!guard.locked_at(now));
guard.record_failure_at(now);
}
assert!(guard.locked_at(now));
guard.locked_until.expect("the guard is locked") - now
};
let start = Instant::now();
let mut guard = CodeGuard::default();
let first = serve_one_lockout(&mut guard, start);
assert_eq!(first, CODE_LOCKOUT_BASE);
let second_round = start + first;
let second = serve_one_lockout(&mut guard, second_round);
assert_eq!(second, CODE_LOCKOUT_BASE * 2);
let third = serve_one_lockout(&mut guard, second_round + second);
assert_eq!(third, CODE_LOCKOUT_BASE * 4);
assert_eq!(code_lockout(u32::MAX), CODE_LOCKOUT_CAP);
let mut recovered = CodeGuard::default();
assert_eq!(serve_one_lockout(&mut recovered, start), CODE_LOCKOUT_BASE);
}
#[test]
fn persisted_cookie_key_survives_a_restart_and_stays_owner_only() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("phone-cookie-key");
let first = load_or_create_cookie_key(&path).unwrap();
assert!(first.len() >= COOKIE_KEY_BYTES);
assert_eq!(std::fs::read(&path).unwrap(), first);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
let mode = std::fs::metadata(&path).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o600);
}
let mut restarted = detached_options();
restarted
.set_cookie_key(load_or_create_cookie_key(&path).unwrap())
.unwrap();
let mut original = detached_options();
original.set_cookie_key(first.clone()).unwrap();
let cookie = signed_cookie_value(&original.cookie_key, "test-viewer", 200);
assert!(session_cookie_valid(&restarted.cookie_key, &cookie, 100));
assert!(!session_cookie_valid(
&detached_options().cookie_key,
&cookie,
100
));
std::fs::remove_file(&path).unwrap();
let rotated = load_or_create_cookie_key(&path).unwrap();
assert_ne!(rotated, first);
assert!(!session_cookie_valid(&rotated, &cookie, 100));
}
#[test]
fn corrupt_cookie_key_is_regenerated_instead_of_blocking_startup() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("phone-cookie-key");
std::fs::write(&path, b"short").unwrap();
let key = load_or_create_cookie_key(&path).unwrap();
assert!(key.len() >= COOKIE_KEY_BYTES);
assert_eq!(std::fs::read(&path).unwrap(), key);
assert_eq!(load_or_create_cookie_key(&path).unwrap(), key);
}
}