Files
IdeA/crates/web-server/src/lib.rs
Blomios 9ee06c7ec9 fix(domain,application,infrastructure,backend,web-server,app-tauri,frontend): statut/LED projet indexé par projectId, source LiveSessions/LiveStateStore/ReconcileLiveState — la fin d'un agent dans un projet non actif repasse désormais la LED du projet au repos — domain: DomainEvent::AgentBusyChanged porte project_id ; infrastructure input: busy émis au démarrage du tour et idle au mark_idle avec le projet possesseur ; backend: DTO agentBusyChanged sérialise project_id (busy_changed_keeps_owning_project_on_the_wire) ; application workstate: ReconcileLiveState exécuté aussi avant les lectures de projection (plus seulement à l'ouverture) et liveness évaluée par (projectId, agentId) — un même agent_id vivant dans un autre projet n'épargne plus un orphelin ; web-server: ws_send_chat libère le busy avant le Final visible et la route work-state projet reste au contrat tauri ; app-tauri: commandes work-state/busy alignées sur le projectId possesseur ; frontend: useProjectActivity agrège l'activité par projectId (busy_changed filtré par projet possesseur, plus le seul projet courant), wsLiveClient/wsLiveClientReconnect propagant project_id, AnnouncementsProvider/CustomAgentChatView/WebWorkspaceLive/LayoutGrid.cellControlsLayering alignés — #373 (QA verte: cargo check --tests -p domain -p application -p infrastructure -p backend -p app-tauri -p web-server, cargo test -p application workstate::reconcile::tests, cargo test -p infrastructure input::tests::busy_event_fires_on_turn_start_and_idle_on_mark_idle, cargo test -p backend events::tests::busy_changed_keeps_owning_project_on_the_wire, cargo test -p web-server websocket_chat_send_releases_busy_before_visible_final + authorized_get_project_work_state_returns_tauri_contract, npm test -- useProjectActivity wsLiveClient wsLiveClientReconnect workstate AnnouncementsProvider CustomAgentChatView WebWorkspaceLive LayoutGrid.cellControlsLayering, npm run typecheck, cargo fmt --all -- --check, git diff --check ; fichiers .ideai/* et scripts/ hors périmètre ; aucun push)
2026-09-17 20:23:36 +02:00

12007 lines
410 KiB
Rust

//! Secure HTTP driving adapter for `idea --serve`.
//!
//! The shared backend core stays unaware of HTTP, cookies, origins and
//! WebSocket framing; this module owns the secure web driving adapter.
use std::collections::HashMap;
use std::env;
use std::net::{IpAddr, SocketAddr};
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use backend::stream::{OutputBridge, OutputSink, OutputSinkError};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use bytes::Bytes;
use cookie::{Cookie, SameSite};
use domain::events::DomainEvent;
use domain::ports::BackgroundTaskPortError;
use http::header::{HeaderValue, CONTENT_DISPOSITION, CONTENT_TYPE, COOKIE, ORIGIN, SET_COOKIE};
#[cfg(test)]
use http::Request;
use http::{HeaderMap, Method, Response, StatusCode, Uri};
#[cfg(test)]
use http_body_util::BodyExt;
#[cfg(test)]
use http_body_util::Full;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use sha2::{Digest, Sha256};
use subtle::ConstantTimeEq;
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener;
use tokio::sync::{mpsc, oneshot};
use tokio::task::JoinHandle;
use uuid::Uuid;
use zip::write::SimpleFileOptions;
use application::{
AddIssueAttachmentInput, AppError, AssignIssueAgentInput, AssignSkillToAgentInput,
AssignTicketToSprintInput, AttachLiveAgentInput, AuthenticateSessionInput,
ChangeAgentProfileInput, CloseTerminalInput, CreateAgentInput, CreateLayoutInput,
CreateMemoryInput, CreateSkillInput, CreateSprintInput, DeleteAgentInput,
DeleteEmbedderProfileInput, DeleteIssueInput, DeleteLayoutInput, DeleteMemoryInput,
DeleteSkillInput, DeleteSprintInput, DeleteTemplateInput, DetectAgentDriftInput,
GetMemoryInput, GetProjectSystemPermissionsInput, GetProjectWorkStateInput, GitBranchesInput,
GitCheckoutInput, GitCommitInput, GitGraphInput, GitInitInput, GitLogInput, GitStagePathInput,
GitStatusInput, InspectConversationInput, LaunchAgentInput, LinkIssuesInput, ListAgentsInput,
ListDevicesInput, ListIssuesInput, ListLayoutsInput, ListMemoriesInput, ListProjectFilesInput,
ListResumableAgentsInput, ListSkillsInput, ListSprintsInput, LiveSessions, LoadLayoutInput,
MarkIssueAttachmentSummarizedInput, McpRuntime, MutateLayoutInput, OpenProjectInput,
PairAttemptDecision, PairDeviceInput, RateLimitKey, ReadAgentContextInput,
ReadConversationPageInput, ReadIssueAttachmentInput, ReadIssueCarnetInput, ReadIssueInput,
ReadMcpToolPermissionsInput, ReadMemoryIndexInput, ReadProjectContextInput, RecallMemoryInput,
ReconcileLiveStateInput, RenameDeviceInput, RenameLayoutInput, RenameSprintInput,
ReorderSprintsInput, ResizeTerminalInput, ResolveAgentPermissionsInput,
ResolveAgentSystemPermissionsInput, ResolveMemoryLinksInput, RevokeDeviceInput,
RotateConversationLogInput, SetActiveLayoutInput, SetAgentMcpToolAllowedInput,
StopLiveAgentInput, SyncAgentWithTemplateInput, TouchDeviceInput, UnassignSkillFromAgentInput,
UnassignTicketFromSprintInput, UnlinkIssuesInput, UpdateAgentContactableInput,
UpdateAgentContextInput, UpdateAgentEffortInput, UpdateAgentMcpToolPermissionsInput,
UpdateAgentPermissionsInput, UpdateAgentSystemPermissionsInput, UpdateIssueCarnetInput,
UpdateMemoryInput, UpdateProjectContextInput, UpdateProjectMcpToolPermissionsInput,
UpdateProjectPermissionsInput, UpdateProjectSystemPermissionsInput, UpdateSkillInput,
WriteToTerminalInput,
};
use domain::ports::{
PtyHandle, ReplyEvent, ReplyProgress, ReplyProgressKind, ReplyProgressSource,
ReplyProgressStage,
};
use domain::IssueActor;
use domain::{
AuthenticatedDevice, DeviceId, DeviceName, Project, SessionId, SessionTokenHash, SkillRef,
SkillScope,
};
use backend::dto::{
create_input, paginate, parse_agent_id, parse_agent_id_dto, parse_attachment_id_dto,
parse_layout_id, parse_link_kind_dto, parse_node_id, parse_profile_id, parse_project_id,
parse_ref_dto, parse_scheduled_task_id, parse_session_id, parse_skill_id, parse_sprint_id_dto,
parse_sprint_status_dto, parse_task_id, parse_template_id, sort_ticket_rows,
sprint_version_dto, update_input, version_dto, AgentDriftListDto, AgentDto, AgentListDto,
AssignSkillRequestDto, AttachLiveAgentRequestDto, AttachLiveAgentResponseDto,
BackgroundTaskDto, ChangeAgentProfileDto, ChangeAgentProfileRequestDto,
CloneOpenCodeProfileFromSeedRequestDto, ConfigureProfilesRequestDto, ConversationDetailsDto,
ConversationTranscriptUnavailableReasonDto, CreateAgentFromTemplateRequestDto,
CreateAgentRequestDto, CreateLayoutRequestDto, CreateLayoutResultDto, CreateMemoryRequestDto,
CreateScheduledProjectTaskRequestDto, CreateSkillRequestDto, CreateTemplateRequestDto,
CustomConversationListDto, CustomConversationSummaryDto, CustomProjectIconAssetDto,
CustomProjectIconUsageDto, DeleteLayoutRequestDto, DeleteLayoutResultDto,
DetectProfilesRequestDto, DetectProfilesResponseDto, EmbedderEnginesDto, EmbedderProfileDto,
EmbedderProfileListDto, ErrorDto, FirstRunStateDto, GitBranchesDto, GitCheckoutRequestDto,
GitCommitDto, GitCommitListDto, GitCommitRequestDto, GitStageRequestDto, GitStatusListDto,
GraphCommitListDto, HealthRequestDto, HealthResponseDto,
ImportCustomProjectIconAssetRequestDto, InspectConversationRequestDto, LaunchAgentRequestDto,
LayoutDto, LayoutOperationDto, ListCustomConversationsRequestDto, ListLayoutsDto,
LiveAgentListDto, MemoryDto, MemoryIndexDto, MemoryLinksDto, MemoryListDto,
OpenCodeProviderListDto, OpenProjectRestoreStateDto, OpenTerminalRequestDto, ProfileDto,
ProfileListDto, ProfileModelCatalogDto, ProjectDto, ProjectListDto,
ProjectMcpToolPermissionsDto, ProjectPermissionsDto, ProjectSettingsDto,
ProjectSystemPermissionsDto, ProjectWorkStateDto, ReadAgentContextResponseDto,
ReadConversationPageRequestDto, RecallMemoryRequestDto,
RemoveOpenProjectRestoreEntryRequestDto, RemoveProjectRequestDto, RemoveProjectResponseDto,
RenameLayoutRequestDto, ResolveAgentPermissionsRequestDto, ResolveAgentPermissionsResponseDto,
ResolveAgentSystemPermissionsRequestDto, ResolvedAgentSystemPermissionsDto,
RestartCellAgentRuntimeRequestDto, RestartCellAgentRuntimeResponseDto,
RestoreOpenProjectsOutputDto, ResumableAgentListDto, RetryOpenProjectRestoreRequestDto,
SaveEmbedderProfileRequestDto, SaveOpenCodeProviderProfileRequestDto,
SaveOpenProjectRestoreOrderRequestDto, SaveProfileRequestDto, SetActiveLayoutRequestDto,
SetActiveLayoutResultDto, SetAgentMcpToolAllowedRequestDto, SkillDto, SkillListDto,
SprintCreateRequestDto, SprintDeleteRequestDto, SprintDto, SprintListDto, SprintListRequestDto,
SprintRenameRequestDto, SprintReorderRequestDto, StopLiveAgentRequestDto,
StopLiveAgentResponseDto, SyncAgentWithTemplateRequestDto, SyncResultDto, TemplateDto,
TemplateListDto, TerminalSessionDto, TicketAssignRequestDto, TicketAttachmentAddRequestDto,
TicketAttachmentContentDto, TicketAttachmentMarkSummarizedRequestDto,
TicketAttachmentReadRequestDto, TicketCarnetDto, TicketCreateRequestDto,
TicketDeleteRequestDto, TicketDto, TicketLinkCommandRequestDto, TicketListPageInput,
TicketListRequestDto, TicketReadRequestDto, TicketSprintAssignRequestDto,
TicketSprintUnassignRequestDto, TicketUnlinkCommandRequestDto, TicketUpdateCarnetRequestDto,
TicketUpdateRequestDto, TurnPageDto, UnassignSkillRequestDto, UpdateAgentContactableRequestDto,
UpdateAgentContextRequestDto, UpdateAgentEffortRequestDto,
UpdateAgentMcpToolPermissionsRequestDto, UpdateAgentPermissionsRequestDto,
UpdateAgentSystemPermissionsRequestDto, UpdateMemoryRequestDto, UpdateProjectContextRequestDto,
UpdateProjectMcpToolPermissionsRequestDto, UpdateProjectPermissionsRequestDto,
UpdateProjectSettingsRequestDto, UpdateProjectSystemPermissionsRequestDto,
UpdateScheduledProjectTaskRequestDto, UpdateSkillRequestDto, UpdateTemplateRequestDto,
UserSettingsDto,
};
use backend::events::DomainEventDto;
type PtyChunk = Vec<u8>;
type ChatChunk = Value;
use backend::BackendCore;
const DEFAULT_LISTEN: &str = "127.0.0.1:17373";
const SESSION_COOKIE: &str = "idea_session";
const SESSION_COOKIE_MAX_AGE_SECONDS: i64 = 34_560_000;
const PAIRING_CODE_TTL_SECONDS: i64 = 600;
const PAIRING_CODE_TTL_MS: i64 = PAIRING_CODE_TTL_SECONDS * 1000;
const PAIRING_CODE_HASH_CONTEXT: &[u8] = b"idea-pairing-code-v1\0";
const WS_PATH: &str = "/api/ws";
const WS_MAGIC: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
const WS_MAX_PAYLOAD: usize = 64 * 1024;
const WS_OUTPUT_BUFFER: usize = 512;
const HTTP_MAX_REQUEST_BODY_BYTES: usize = 3 * 1024 * 1024;
#[cfg(test)]
type RequestBody = Full<Bytes>;
#[derive(Debug)]
enum ResponseBody {
Bytes(Bytes),
File(PathBuf),
TempFile(PathBuf),
}
impl ResponseBody {
fn empty() -> Self {
Self::Bytes(Bytes::new())
}
fn bytes(bytes: impl Into<Bytes>) -> Self {
Self::Bytes(bytes.into())
}
#[cfg(test)]
async fn into_bytes(self) -> Bytes {
match self {
Self::Bytes(bytes) => bytes,
Self::File(path) => Bytes::from(tokio::fs::read(&path).await.unwrap_or_default()),
Self::TempFile(path) => {
let bytes = tokio::fs::read(&path).await.unwrap_or_default();
let _ = tokio::fs::remove_file(path).await;
Bytes::from(bytes)
}
}
}
}
static NEXT_PAIRING_GENERATION_ID: AtomicU64 = AtomicU64::new(1);
/// Runs the `idea --serve` subcommand from already-split CLI arguments.
pub fn run_from_args(args: Vec<String>) -> ExitCode {
let config = match ServerConfig::from_args(args).and_then(|config| {
config.validate()?;
Ok(config)
}) {
Ok(config) => config,
Err(err) => {
eprintln!("idea --serve: {err}");
return ExitCode::from(2);
}
};
let _app_data_lock = match backend::acquire_app_data_dir_lock(&config.app_data_dir) {
Ok(lock) => lock,
Err(err) => {
eprintln!("idea --serve: {}", serve_app_data_lock_message(&err));
return ExitCode::from(1);
}
};
match tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
{
Ok(runtime) => match runtime.block_on(run_server(config)) {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("idea --serve: {err}");
ExitCode::from(1)
}
},
Err(err) => {
eprintln!("idea --serve: failed to build runtime: {err}");
ExitCode::from(1)
}
}
}
fn serve_app_data_lock_message(err: &backend::AppDataDirLockError) -> String {
match err {
backend::AppDataDirLockError::Contended { lock_path } => format!(
"app-data dir is already locked at {}; another IdeA instance is using this app-data-dir",
lock_path.display()
),
backend::AppDataDirLockError::Io { lock_path, source } => format!(
"failed to access app-data lock at {}: {source}",
lock_path.display()
),
}
}
/// Configuration shared by the standalone and embedded web-server entry points.
#[derive(Clone, Debug)]
pub struct ServerConfig {
/// Address to bind.
pub listen: SocketAddr,
/// Exact public origin allowed by CORS/origin checks.
pub public_origin: Option<String>,
/// Whether non-loopback binds are allowed.
pub allow_remote: bool,
/// Whether a trusted HTTPS reverse proxy terminates remote access.
pub trust_reverse_proxy: bool,
/// Authorized reverse proxy peer IPs or CIDR ranges.
pub trusted_proxies: Vec<TrustedProxy>,
/// IdeA application data directory.
pub app_data_dir: PathBuf,
/// Built frontend assets root.
pub web_root: PathBuf,
/// Generate and print one ephemeral pairing code after startup.
pub new_code: bool,
}
/// Authorized reverse proxy peer IP or CIDR range.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct TrustedProxy {
addr: IpAddr,
prefix: u8,
}
impl TrustedProxy {
/// Parses an IP address or CIDR range accepted by `--trusted-proxy`.
pub fn parse(value: &str) -> Result<Self, String> {
let (addr, prefix) = match value.split_once('/') {
Some((addr, prefix)) => {
let addr = addr
.parse::<IpAddr>()
.map_err(|_| format!("invalid --trusted-proxy address: {value}"))?;
let prefix = prefix
.parse::<u8>()
.map_err(|_| format!("invalid --trusted-proxy CIDR prefix: {value}"))?;
(addr, prefix)
}
None => {
let addr = value
.parse::<IpAddr>()
.map_err(|_| format!("invalid --trusted-proxy address: {value}"))?;
let prefix = match addr {
IpAddr::V4(_) => 32,
IpAddr::V6(_) => 128,
};
(addr, prefix)
}
};
let max = match addr {
IpAddr::V4(_) => 32,
IpAddr::V6(_) => 128,
};
if prefix > max {
return Err(format!("invalid --trusted-proxy CIDR prefix: {value}"));
}
Ok(Self { addr, prefix })
}
fn contains(&self, ip: IpAddr) -> bool {
match (self.addr, ip) {
(IpAddr::V4(net), IpAddr::V4(ip)) => {
let mask = if self.prefix == 0 {
0
} else {
u32::MAX << (32 - self.prefix)
};
u32::from(net) & mask == u32::from(ip) & mask
}
(IpAddr::V6(net), IpAddr::V6(ip)) => {
let mask = if self.prefix == 0 {
0
} else {
u128::MAX << (128 - self.prefix)
};
u128::from(net) & mask == u128::from(ip) & mask
}
_ => false,
}
}
}
impl ServerConfig {
/// Parses the web-server CLI flags.
pub fn from_args(args: Vec<String>) -> Result<Self, String> {
let mut listen = DEFAULT_LISTEN
.parse::<SocketAddr>()
.expect("default listen address is valid");
let mut public_origin = None;
let mut allow_remote = false;
let mut trust_reverse_proxy = false;
let mut trusted_proxies = Vec::new();
let mut app_data_dir = default_app_data_dir();
let mut web_root = None;
let mut new_code = false;
let mut it = args.into_iter();
while let Some(arg) = it.next() {
match arg.as_str() {
"--listen" => {
let value = it
.next()
.ok_or_else(|| "--listen requires an address".to_owned())?;
listen = value
.parse()
.map_err(|_| format!("invalid --listen address: {value}"))?;
}
"--public-origin" => {
let value = it
.next()
.ok_or_else(|| "--public-origin requires an origin".to_owned())?;
public_origin = Some(value);
}
"--allow-remote" => allow_remote = true,
"--trust-reverse-proxy" => trust_reverse_proxy = true,
"--trusted-proxy" => {
let value = it
.next()
.ok_or_else(|| "--trusted-proxy requires an IP or CIDR".to_owned())?;
trusted_proxies.push(TrustedProxy::parse(&value)?);
}
"--app-data-dir" => {
let value = it
.next()
.ok_or_else(|| "--app-data-dir requires a path".to_owned())?;
app_data_dir = PathBuf::from(value);
}
"--web-root" => {
let value = it
.next()
.ok_or_else(|| "--web-root requires a path".to_owned())?;
web_root = Some(PathBuf::from(value));
}
"--new-code" => new_code = true,
"--help" | "-h" => return Err(Self::usage()),
other => return Err(format!("unknown --serve argument: {other}")),
}
}
let web_root = resolve_web_root(web_root)?;
Ok(Self {
listen,
public_origin,
allow_remote,
trust_reverse_proxy,
trusted_proxies,
app_data_dir,
web_root,
new_code,
})
}
/// Validates security-sensitive configuration.
pub fn validate(&self) -> Result<(), String> {
if let Some(origin) = &self.public_origin {
validate_origin(origin)?;
}
if !self.listen.ip().is_loopback() && !self.allow_remote {
return Err(
"refusing non-loopback bind without --allow-remote and HTTPS proxy config"
.to_owned(),
);
}
if self.allow_remote {
let Some(origin) = &self.public_origin else {
return Err("--allow-remote requires --public-origin https://...".to_owned());
};
if !origin.starts_with("https://") {
return Err("--allow-remote requires an HTTPS public origin".to_owned());
}
if !self.trust_reverse_proxy {
return Err("--allow-remote requires --trust-reverse-proxy".to_owned());
}
if !self.listen.ip().is_loopback() && self.trusted_proxies.is_empty() {
return Err("non-loopback remote bind requires --trusted-proxy <proxy-ip-or-cidr>; add the IP address or CIDR of the reverse proxy that connects to this server".to_owned());
}
}
Ok(())
}
fn secure_cookie(&self) -> bool {
self.allow_remote
|| self
.public_origin
.as_deref()
.is_some_and(|o| o.starts_with("https://"))
}
/// Human-readable CLI usage.
#[must_use]
pub fn usage() -> String {
"usage: idea-serve [--listen IP:PORT] [--app-data-dir PATH] [--web-root PATH] [--new-code] [--allow-remote --public-origin https://host --trust-reverse-proxy [--trusted-proxy IP_OR_CIDR]...]".to_owned()
}
}
/// Current lifecycle state of an embedded web server.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EmbeddedServerState {
/// The accept loop has been spawned.
Running,
/// A shutdown signal was sent.
Stopping,
}
/// Handle returned by [`run_embedded`] and [`run_embedded_with_core`].
pub struct EmbeddedServerHandle {
url: String,
state_ref: Arc<ServerState>,
shutdown: Option<oneshot::Sender<()>>,
task: JoinHandle<Result<(), String>>,
state: EmbeddedServerState,
}
impl EmbeddedServerHandle {
/// Effective base URL after binding. Includes the OS-assigned port when
/// `listen` used port `0`.
#[must_use]
pub fn url(&self) -> &str {
&self.url
}
/// Generates a new ephemeral pairing code on the embedded server.
pub fn generate_pairing_code(&self) -> PairingCodeDto {
self.state_ref.generate_pairing_code()
}
/// Current lifecycle state.
#[must_use]
pub fn state(&self) -> EmbeddedServerState {
self.state
}
/// Requests shutdown and waits for the accept loop to finish.
pub async fn stop(mut self) -> Result<(), String> {
self.state = EmbeddedServerState::Stopping;
if let Some(shutdown) = self.shutdown.take() {
let _ = shutdown.send(());
}
self.task
.await
.map_err(|err| format!("embedded web server task failed: {err}"))?
}
}
/// Starts the web server as an embedded library component.
///
/// This compatibility entry point builds its own backend composition root. The
/// desktop app should use [`run_embedded_with_core`] so HTTP handlers share the
/// already-running backend core.
pub async fn run_embedded(config: ServerConfig) -> Result<EmbeddedServerHandle, String> {
let core = Arc::new(BackendCore::build(config.app_data_dir.clone()));
run_embedded_with_core(config, core).await
}
/// Starts the web server with an already-built backend core.
///
/// This is the integration point for the desktop-hosted server: the HTTP
/// driving adapter receives the same composition root as the Tauri adapter
/// instead of constructing a second backend core in the same process.
pub async fn run_embedded_with_core(
config: ServerConfig,
core: Arc<BackendCore>,
) -> Result<EmbeddedServerHandle, String> {
config.validate()?;
let listener = TcpListener::bind(config.listen)
.await
.map_err(|err| format!("failed to bind {}: {err}", config.listen))?;
let local_addr = listener
.local_addr()
.map_err(|err| format!("failed to read listener address: {err}"))?;
let effective_config = config_with_effective_listen(config.clone(), local_addr);
let state = Arc::new(ServerState::with_core(effective_config, core));
let (shutdown_tx, shutdown_rx) = oneshot::channel();
let task = tokio::spawn(run_listener(listener, Arc::clone(&state), shutdown_rx));
Ok(EmbeddedServerHandle {
url: format!("http://{local_addr}"),
state_ref: state,
shutdown: Some(shutdown_tx),
task,
state: EmbeddedServerState::Running,
})
}
fn config_with_effective_listen(mut config: ServerConfig, local_addr: SocketAddr) -> ServerConfig {
config.listen = local_addr;
config
}
fn resolve_web_root(explicit: Option<PathBuf>) -> Result<PathBuf, String> {
for (candidate, source) in web_root_candidates(explicit) {
if candidate.join("index.html").is_file() {
return Ok(candidate);
}
if matches!(source, "--web-root" | "IDEA_WEB_ROOT") {
return validate_web_root(candidate, source);
}
}
Err("web assets not found: build frontend/dist or pass --web-root PATH".to_owned())
}
fn web_root_candidates(explicit: Option<PathBuf>) -> Vec<(PathBuf, &'static str)> {
let mut candidates = Vec::new();
if let Some(path) = explicit {
candidates.push((path, "--web-root"));
}
if let Some(path) = env::var_os("IDEA_WEB_ROOT").map(PathBuf::from) {
candidates.push((path, "IDEA_WEB_ROOT"));
}
if let Some(appdir) = env::var_os("APPDIR").map(PathBuf::from) {
candidates.extend(
web_root_candidates_from_appdir(&appdir)
.into_iter()
.map(|candidate| (candidate, "APPDIR")),
);
}
if let Ok(exe) = env::current_exe() {
if let Some(dir) = exe.parent() {
candidates.push((dir.join("web"), "current_exe"));
candidates.push((dir.join("frontend").join("dist"), "current_exe"));
if let Some(prefix) = dir.parent() {
candidates.push((prefix.join("lib").join("IdeA").join("web"), "current_exe"));
}
}
}
if let Ok(cwd) = env::current_dir() {
candidates.push((cwd.join("frontend").join("dist"), "cwd"));
}
candidates
}
fn web_root_candidates_from_appdir(appdir: &Path) -> Vec<PathBuf> {
vec![
appdir.join("usr").join("lib").join("IdeA").join("web"),
appdir.join("web"),
]
}
fn validate_web_root(path: PathBuf, source: &str) -> Result<PathBuf, String> {
if path.join("index.html").is_file() {
Ok(path)
} else {
Err(format!(
"{source} does not contain an index.html: {}",
path.display()
))
}
}
struct ServerState {
config: ServerConfig,
app: Arc<BackendCore>,
pairing_code: Mutex<Option<PairingCodeState>>,
ws_pty_bridge: Arc<OutputBridge<SessionId, PtyChunk>>,
ws_chat_bridge: Arc<OutputBridge<SessionId, ChatChunk>>,
active_connections: Arc<ActiveConnectionRegistry>,
_revocation_observer: Option<JoinHandle<()>>,
security_logger: Arc<dyn SecurityLogger>,
}
struct PairingCodeState {
code_hash: [u8; 32],
expires_at_ms: i64,
generation_id: u64,
used: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PairingCodeDto {
/// One-time pairing code, returned only at generation time.
pub code: String,
/// Expiration timestamp as epoch milliseconds.
pub expires_at_ms: u64,
/// Time-to-live in seconds.
pub ttl_seconds: i64,
}
impl ServerState {
fn new(config: ServerConfig) -> Self {
let core = Arc::new(BackendCore::build(config.app_data_dir.clone()));
Self::with_core(config, core)
}
fn with_core(config: ServerConfig, core: Arc<BackendCore>) -> Self {
let active_connections = Arc::new(ActiveConnectionRegistry::default());
let revocation_observer = spawn_device_revocation_observer(
core.event_bus.raw_receiver(),
Arc::clone(&active_connections),
);
Self {
app: core,
config,
pairing_code: Mutex::new(None),
ws_pty_bridge: Arc::new(OutputBridge::new()),
ws_chat_bridge: Arc::new(OutputBridge::new()),
active_connections,
_revocation_observer: revocation_observer,
security_logger: Arc::new(StderrSecurityLogger),
}
}
#[cfg(test)]
fn new_for_test(config: ServerConfig, pairing_code: impl Into<String>) -> Self {
Self::new_for_test_with_logger(config, pairing_code, Arc::new(NoopSecurityLogger))
}
#[cfg(test)]
fn new_for_test_with_logger(
config: ServerConfig,
pairing_code: impl Into<String>,
security_logger: Arc<dyn SecurityLogger>,
) -> Self {
let core = Arc::new(BackendCore::build(config.app_data_dir.clone()));
let active_connections = Arc::new(ActiveConnectionRegistry::default());
let revocation_observer = spawn_device_revocation_observer(
core.event_bus.raw_receiver(),
Arc::clone(&active_connections),
);
let state = Self {
app: core,
config,
pairing_code: Mutex::new(None),
ws_pty_bridge: Arc::new(OutputBridge::new()),
ws_chat_bridge: Arc::new(OutputBridge::new()),
active_connections,
_revocation_observer: revocation_observer,
security_logger,
};
state.set_pairing_code_for_test(pairing_code.into());
state
}
fn generate_pairing_code(&self) -> PairingCodeDto {
let code = new_pairing_code();
let now = current_time_millis();
let expires_at_ms = now.saturating_add(PAIRING_CODE_TTL_MS);
let generation_id = NEXT_PAIRING_GENERATION_ID.fetch_add(1, Ordering::Relaxed);
let mut guard = self
.pairing_code
.lock()
.expect("pairing code mutex poisoned");
*guard = Some(PairingCodeState {
code_hash: pairing_code_hash(&code),
expires_at_ms,
generation_id,
used: false,
});
PairingCodeDto {
code,
expires_at_ms: expires_at_ms.max(0) as u64,
ttl_seconds: PAIRING_CODE_TTL_SECONDS,
}
}
#[cfg(test)]
fn set_pairing_code_for_test(&self, code: String) {
let expires_at_ms = current_time_millis().saturating_add(PAIRING_CODE_TTL_MS);
let generation_id = NEXT_PAIRING_GENERATION_ID.fetch_add(1, Ordering::Relaxed);
let mut guard = self
.pairing_code
.lock()
.expect("pairing code mutex poisoned");
*guard = Some(PairingCodeState {
code_hash: pairing_code_hash(&code),
expires_at_ms,
generation_id,
used: false,
});
}
#[cfg(test)]
fn expire_pairing_code_for_test(&self) {
if let Some(code) = self
.pairing_code
.lock()
.expect("pairing code mutex poisoned")
.as_mut()
{
code.expires_at_ms = current_time_millis().saturating_sub(1);
}
}
fn consume_pairing_code(&self, code: &str) -> PairingCodeConsumeResult {
let now = current_time_millis();
let candidate_hash = pairing_code_hash(code);
let mut guard = self
.pairing_code
.lock()
.expect("pairing code mutex poisoned");
let Some(current) = guard.as_mut() else {
return PairingCodeConsumeResult::InvalidOrExpired;
};
if current.used || now > current.expires_at_ms {
return PairingCodeConsumeResult::InvalidOrExpired;
}
let matches: bool = current.code_hash.ct_eq(&candidate_hash).into();
if !matches {
return PairingCodeConsumeResult::InvalidOrExpired;
}
let _generation_id = current.generation_id;
current.used = true;
PairingCodeConsumeResult::Valid
}
async fn authenticate_session(&self, token: &str) -> Option<AuthenticatedDevice> {
let token_bytes = decode_session_token(token).ok()?;
self.app
.authenticate_session
.execute(AuthenticateSessionInput { token_bytes })
.await
.unwrap_or_default()
}
async fn touch_session(&self, device: &AuthenticatedDevice) {
let _ = self
.app
.touch_device
.execute(TouchDeviceInput {
device_id: device.device_id,
})
.await;
}
async fn revoke_session(&self, token: &str) -> bool {
let Some(device) = self.authenticate_session(token).await else {
return false;
};
self.app
.revoke_device
.execute(RevokeDeviceInput {
device_id: device.device_id,
})
.await
.is_ok()
}
fn log_security(&self, event: SecurityLogEvent) {
self.security_logger.log(event);
}
}
fn spawn_device_revocation_observer(
mut rx: tokio::sync::broadcast::Receiver<DomainEvent>,
active_connections: Arc<ActiveConnectionRegistry>,
) -> Option<JoinHandle<()>> {
let Ok(handle) = tokio::runtime::Handle::try_current() else {
return None;
};
Some(handle.spawn(async move {
loop {
match rx.recv().await {
Ok(DomainEvent::DeviceRevoked { device_id }) => {
active_connections.close_device(device_id);
}
Ok(DomainEvent::AllDevicesRevoked) => {
active_connections.close_all();
}
Ok(_) => {}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
}
}
}))
}
#[derive(Default)]
struct ActiveConnectionRegistry {
next_id: AtomicU64,
connections: Mutex<HashMap<DeviceId, Vec<ActiveConnectionHandle>>>,
}
struct ActiveConnectionHandle {
id: u64,
shutdown: oneshot::Sender<()>,
}
struct ActiveConnectionRegistration {
device_id: DeviceId,
id: u64,
shutdown: oneshot::Receiver<()>,
}
impl ActiveConnectionRegistry {
fn register(&self, device_id: DeviceId) -> ActiveConnectionRegistration {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let (shutdown, rx) = oneshot::channel();
self.connections
.lock()
.expect("active connection registry mutex poisoned")
.entry(device_id)
.or_default()
.push(ActiveConnectionHandle { id, shutdown });
ActiveConnectionRegistration {
device_id,
id,
shutdown: rx,
}
}
fn unregister(&self, device_id: DeviceId, id: u64) {
let mut connections = self
.connections
.lock()
.expect("active connection registry mutex poisoned");
let Some(handles) = connections.get_mut(&device_id) else {
return;
};
handles.retain(|handle| handle.id != id);
if handles.is_empty() {
connections.remove(&device_id);
}
}
fn close_device(&self, device_id: DeviceId) {
let handles = self
.connections
.lock()
.expect("active connection registry mutex poisoned")
.remove(&device_id)
.unwrap_or_default();
for handle in handles {
let _ = handle.shutdown.send(());
}
}
fn close_all(&self) {
let handles: Vec<_> = self
.connections
.lock()
.expect("active connection registry mutex poisoned")
.drain()
.flat_map(|(_, handles)| handles)
.collect();
for handle in handles {
let _ = handle.shutdown.send(());
}
}
#[cfg(test)]
fn active_count(&self) -> usize {
self.connections
.lock()
.expect("active connection registry mutex poisoned")
.values()
.map(Vec::len)
.sum()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum SecurityLogEvent {
PairingSucceeded {
origin: Option<String>,
},
PairingFailed {
origin: Option<String>,
reason: &'static str,
},
OriginRejected {
origin: Option<String>,
route: String,
},
UntrustedProxyPeer {
peer: IpAddr,
forwarded_for: Option<String>,
},
ForwardedProtoRejected {
peer: IpAddr,
proto: Option<String>,
},
ForwardedHostMismatch {
peer: IpAddr,
host: Option<String>,
expected: String,
},
WsUpgradeRejected {
origin: Option<String>,
reason: &'static str,
},
SessionRevoked {
origin: Option<String>,
},
}
trait SecurityLogger: Send + Sync {
fn log(&self, event: SecurityLogEvent);
}
struct StderrSecurityLogger;
impl SecurityLogger for StderrSecurityLogger {
fn log(&self, event: SecurityLogEvent) {
eprintln!("idea --serve security: {}", security_log_line(&event));
}
}
#[cfg(test)]
struct NoopSecurityLogger;
#[cfg(test)]
impl SecurityLogger for NoopSecurityLogger {
fn log(&self, _event: SecurityLogEvent) {}
}
fn security_log_line(event: &SecurityLogEvent) -> String {
match event {
SecurityLogEvent::PairingSucceeded { origin } => {
format!("pairing succeeded origin={}", origin_label(origin))
}
SecurityLogEvent::PairingFailed { origin, reason } => {
format!(
"pairing failed reason={reason} origin={}",
origin_label(origin)
)
}
SecurityLogEvent::OriginRejected { origin, route } => {
format!(
"origin rejected route={} origin={}",
route,
origin_label(origin)
)
}
SecurityLogEvent::UntrustedProxyPeer {
peer,
forwarded_for,
} => {
format!(
"untrustedProxyPeer peer={peer} forwardedFor={}",
origin_label(forwarded_for)
)
}
SecurityLogEvent::ForwardedProtoRejected { peer, proto } => {
format!(
"forwardedProtoRejected peer={peer} proto={}",
origin_label(proto)
)
}
SecurityLogEvent::ForwardedHostMismatch {
peer,
host,
expected,
} => {
format!(
"forwardedHostMismatch peer={peer} host={} expected={expected}",
origin_label(host)
)
}
SecurityLogEvent::WsUpgradeRejected { origin, reason } => {
format!(
"websocket upgrade rejected reason={reason} origin={}",
origin_label(origin)
)
}
SecurityLogEvent::SessionRevoked { origin } => {
format!("session revoked origin={}", origin_label(origin))
}
}
}
fn origin_label(origin: &Option<String>) -> &str {
origin.as_deref().unwrap_or("<missing>")
}
async fn run_server(config: ServerConfig) -> Result<(), String> {
let print_new_code = config.new_code;
let listener = TcpListener::bind(config.listen)
.await
.map_err(|err| format!("failed to bind {}: {err}", config.listen))?;
let local_addr = listener
.local_addr()
.map_err(|err| format!("failed to read listener address: {err}"))?;
let effective_config = config_with_effective_listen(config.clone(), local_addr);
let state = Arc::new(ServerState::new(effective_config));
if print_new_code {
let pairing = state.generate_pairing_code();
eprintln!("IdeA pairing code: {}", pairing.code);
eprintln!("IdeA pairing code expires at: {}", pairing.expires_at_ms);
}
eprintln!(
"idea --serve: app data dir = {}",
config.app_data_dir.display()
);
eprintln!("IdeA server listening on {local_addr}");
let (_shutdown_tx, shutdown_rx) = oneshot::channel();
run_listener(listener, state, shutdown_rx).await
}
async fn run_listener(
listener: TcpListener,
state: Arc<ServerState>,
mut shutdown: oneshot::Receiver<()>,
) -> Result<(), String> {
loop {
let accepted = tokio::select! {
result = listener.accept() => result,
_ = &mut shutdown => return Ok(()),
};
let (stream, peer_addr) =
accepted.map_err(|err| format!("failed to accept connection: {err}"))?;
let state = Arc::clone(&state);
tokio::spawn(async move {
if let Err(err) = handle_tcp_connection(stream, peer_addr, state).await {
eprintln!("idea --serve: connection error: {err}");
}
});
}
}
async fn handle_tcp_connection(
mut stream: tokio::net::TcpStream,
peer_addr: SocketAddr,
state: Arc<ServerState>,
) -> Result<(), String> {
let mut buffer = Vec::with_capacity(8192);
let mut chunk = [0_u8; 2048];
let header_end = loop {
let read = stream
.read(&mut chunk)
.await
.map_err(|err| format!("failed to read request: {err}"))?;
if read == 0 {
return Ok(());
}
buffer.extend_from_slice(&chunk[..read]);
if buffer.len() > 1024 * 1024 {
return Err("request too large".to_owned());
}
if let Some(pos) = find_header_end(&buffer) {
break pos;
}
};
let (method, uri, headers, content_length) = parse_http_request_head(&buffer[..header_end])?;
if method == Method::GET && uri.path() == WS_PATH {
return handle_ws_upgrade(stream, peer_addr.ip(), headers, state).await;
}
let body_start = header_end + 4;
while buffer.len() < body_start + content_length {
let read = stream
.read(&mut chunk)
.await
.map_err(|err| format!("failed to read request body: {err}"))?;
if read == 0 {
break;
}
buffer.extend_from_slice(&chunk[..read]);
if buffer.len() > HTTP_MAX_REQUEST_BODY_BYTES {
return Err("request too large".to_owned());
}
}
if buffer.len() < body_start + content_length {
return Err("truncated request body".to_owned());
}
let response = dispatch_http(
method,
uri,
headers,
Bytes::copy_from_slice(&buffer[body_start..body_start + content_length]),
Some(peer_addr.ip()),
state,
)
.await;
write_http_response(&mut stream, response).await
}
#[cfg(test)]
async fn handle_request(
req: Request<RequestBody>,
state: Arc<ServerState>,
) -> Response<ResponseBody> {
handle_request_from_peer(req, Arc::clone(&state), Some(state.config.listen.ip())).await
}
#[cfg(test)]
async fn handle_request_from_peer(
req: Request<RequestBody>,
state: Arc<ServerState>,
peer_ip: Option<IpAddr>,
) -> Response<ResponseBody> {
let (parts, body) = req.into_parts();
let body = match body.collect().await {
Ok(body) => body.to_bytes(),
Err(err) => {
return error_response(
StatusCode::BAD_REQUEST,
"INVALID",
format!("invalid request body: {err}"),
None,
);
}
};
dispatch_http(
parts.method,
parts.uri,
parts.headers,
body,
peer_ip,
Arc::clone(&state),
)
.await
}
fn find_header_end(buffer: &[u8]) -> Option<usize> {
buffer.windows(4).position(|window| window == b"\r\n\r\n")
}
fn parse_http_request_head(head: &[u8]) -> Result<(Method, Uri, HeaderMap, usize), String> {
let text = std::str::from_utf8(head).map_err(|_| "request head is not UTF-8".to_owned())?;
let mut lines = text.split("\r\n");
let request_line = lines
.next()
.ok_or_else(|| "missing request line".to_owned())?;
let mut request_parts = request_line.split_whitespace();
let method = request_parts
.next()
.ok_or_else(|| "missing method".to_owned())?
.parse::<Method>()
.map_err(|_| "invalid method".to_owned())?;
let uri = request_parts
.next()
.ok_or_else(|| "missing uri".to_owned())?
.parse::<Uri>()
.map_err(|_| "invalid uri".to_owned())?;
let mut headers = HeaderMap::new();
let mut content_length = 0;
for line in lines {
if line.is_empty() {
continue;
}
let Some((name, value)) = line.split_once(':') else {
return Err("invalid header line".to_owned());
};
let name = http::header::HeaderName::from_bytes(name.trim().as_bytes())
.map_err(|_| "invalid header name".to_owned())?;
let value =
HeaderValue::from_str(value.trim()).map_err(|_| "invalid header value".to_owned())?;
if name == http::header::CONTENT_LENGTH {
content_length = value
.to_str()
.ok()
.and_then(|raw| raw.parse::<usize>().ok())
.ok_or_else(|| "invalid content-length".to_owned())?;
}
headers.insert(name, value);
}
Ok((method, uri, headers, content_length))
}
async fn write_http_response(
stream: &mut tokio::net::TcpStream,
response: Response<ResponseBody>,
) -> Result<(), String> {
let status = response.status();
let headers = response.headers().clone();
let body = response.into_body();
let reason = status.canonical_reason().unwrap_or("Unknown");
let mut bytes = format!("HTTP/1.1 {} {}\r\n", status.as_u16(), reason).into_bytes();
for (name, value) in &headers {
bytes.extend_from_slice(name.as_str().as_bytes());
bytes.extend_from_slice(b": ");
bytes.extend_from_slice(value.as_bytes());
bytes.extend_from_slice(b"\r\n");
}
match &body {
ResponseBody::Bytes(body) => {
bytes.extend_from_slice(format!("content-length: {}\r\n", body.len()).as_bytes());
}
ResponseBody::File(path) | ResponseBody::TempFile(path) => {
if let Ok(meta) = tokio::fs::metadata(path).await {
bytes.extend_from_slice(format!("content-length: {}\r\n", meta.len()).as_bytes());
}
}
}
bytes.extend_from_slice(b"connection: close\r\n\r\n");
stream
.write_all(&bytes)
.await
.map_err(|err| format!("failed to write response head: {err}"))?;
match body {
ResponseBody::Bytes(body) => stream
.write_all(&body)
.await
.map_err(|err| format!("failed to write response body: {err}")),
ResponseBody::File(path) => stream_file_body(stream, &path).await,
ResponseBody::TempFile(path) => {
let result = stream_file_body(stream, &path).await;
let _ = tokio::fs::remove_file(path).await;
result
}
}
}
async fn stream_file_body(stream: &mut tokio::net::TcpStream, path: &Path) -> Result<(), String> {
let mut file = tokio::fs::File::open(path)
.await
.map_err(|err| format!("failed to open response file {}: {err}", path.display()))?;
let mut buffer = [0_u8; 64 * 1024];
loop {
let read = file
.read(&mut buffer)
.await
.map_err(|err| format!("failed to read response file {}: {err}", path.display()))?;
if read == 0 {
return Ok(());
}
stream
.write_all(&buffer[..read])
.await
.map_err(|err| format!("failed to write response file: {err}"))?;
}
}
async fn handle_ws_upgrade(
mut stream: tokio::net::TcpStream,
peer_ip: IpAddr,
headers: HeaderMap,
state: Arc<ServerState>,
) -> Result<(), String> {
let (accept, device) = match validate_ws_upgrade(&headers, Some(peer_ip), &state).await {
Ok(result) => result,
Err(response) => return write_http_response(&mut stream, *response).await,
};
let response = format!(
"HTTP/1.1 101 Switching Protocols\r\n\
upgrade: websocket\r\n\
connection: Upgrade\r\n\
sec-websocket-accept: {accept}\r\n\r\n"
);
stream
.write_all(response.as_bytes())
.await
.map_err(|err| format!("failed to write websocket upgrade: {err}"))?;
run_ws_connection(stream, state, device).await
}
async fn validate_ws_upgrade(
headers: &HeaderMap,
peer_ip: Option<IpAddr>,
state: &ServerState,
) -> Result<(String, AuthenticatedDevice), Box<Response<ResponseBody>>> {
validate_reverse_proxy(headers, peer_ip, state, WS_PATH)?;
let origin = match validate_request_origin(headers, &state.config, false) {
Ok(origin) => origin,
Err(response) => {
state.log_security(SecurityLogEvent::OriginRejected {
origin: request_origin(headers),
route: WS_PATH.to_owned(),
});
return Err(response);
}
};
let Some(token) = session_cookie(headers) else {
state.log_security(SecurityLogEvent::WsUpgradeRejected {
origin: origin.clone(),
reason: "missing_session",
});
return Err(Box::new(error_response(
StatusCode::UNAUTHORIZED,
"UNAUTHORIZED",
"missing session cookie",
origin.as_deref(),
)));
};
let Some(device) = state.authenticate_session(&token).await else {
state.log_security(SecurityLogEvent::WsUpgradeRejected {
origin: origin.clone(),
reason: "invalid_session",
});
return Err(Box::new(error_response(
StatusCode::UNAUTHORIZED,
"UNAUTHORIZED",
"invalid session cookie",
origin.as_deref(),
)));
};
state.touch_session(&device).await;
if !header_contains_token(headers, "upgrade", "websocket")
|| !header_contains_token(headers, "connection", "upgrade")
{
state.log_security(SecurityLogEvent::WsUpgradeRejected {
origin: origin.clone(),
reason: "invalid_upgrade",
});
return Err(Box::new(error_response(
StatusCode::BAD_REQUEST,
"INVALID",
"missing websocket upgrade headers",
origin.as_deref(),
)));
}
let Some(key) = headers
.get("sec-websocket-key")
.and_then(|value| value.to_str().ok())
else {
state.log_security(SecurityLogEvent::WsUpgradeRejected {
origin: origin.clone(),
reason: "missing_key",
});
return Err(Box::new(error_response(
StatusCode::BAD_REQUEST,
"INVALID",
"missing Sec-WebSocket-Key",
origin.as_deref(),
)));
};
Ok((websocket_accept(key), device))
}
fn header_contains_token(headers: &HeaderMap, name: &str, needle: &str) -> bool {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.is_some_and(|value| {
value
.split(',')
.any(|token| token.trim().eq_ignore_ascii_case(needle))
})
}
async fn run_ws_connection(
stream: tokio::net::TcpStream,
state: Arc<ServerState>,
device: AuthenticatedDevice,
) -> Result<(), String> {
let (mut reader, mut writer) = stream.into_split();
let (tx, mut rx) = mpsc::channel::<ServerFrame>(WS_OUTPUT_BUFFER);
let owned = Arc::new(Mutex::new(Vec::<WsAttachment>::new()));
let registration = state.active_connections.register(device.device_id);
let mut shutdown = registration.shutdown;
let event_relay_task =
spawn_ws_domain_event_relay(state.app.event_bus.raw_receiver(), tx.clone());
let writer_task = tokio::spawn(async move {
while let Some(frame) = rx.recv().await {
let text = serde_json::to_vec(&frame)
.map_err(|err| format!("failed to encode server frame: {err}"))?;
let bytes = encode_ws_frame(WsOpcode::Text, &text);
writer
.write_all(&bytes)
.await
.map_err(|err| format!("failed to write websocket frame: {err}"))?;
}
Ok::<(), String>(())
});
loop {
let frame = tokio::select! {
frame = read_ws_frame(&mut reader) => match frame {
Ok(frame) => frame,
Err(err) => {
let _ = tx
.send(ServerFrame::error(
None,
None,
"WS_PROTOCOL",
err.to_string(),
))
.await;
break;
}
},
_ = &mut shutdown => {
break;
}
};
match frame.opcode {
WsOpcode::Text => {
let parsed = serde_json::from_slice::<ClientFrame>(&frame.payload)
.map_err(|err| format!("invalid client frame JSON: {err}"));
match parsed {
Ok(frame) => {
handle_client_frame(frame, &state, &tx, &owned).await;
}
Err(err) => {
let _ = tx
.send(ServerFrame::error(None, None, "INVALID", err))
.await;
}
}
}
WsOpcode::Ping => {
let _ = tx.send(ServerFrame::pong()).await;
}
WsOpcode::Close => break,
WsOpcode::Pong => {}
WsOpcode::Binary => {
let _ = tx
.send(ServerFrame::error(
None,
None,
"UNSUPPORTED",
"binary websocket frames are not supported",
))
.await;
}
}
}
if let Ok(owned) = owned.lock() {
for attachment in owned.iter() {
match *attachment {
WsAttachment::Pty { session, gen } => {
state.ws_pty_bridge.unregister_if(&session, gen);
}
WsAttachment::Chat { session, gen } => {
state.ws_chat_bridge.unregister_if(&session, gen);
}
}
}
}
state
.active_connections
.unregister(registration.device_id, registration.id);
event_relay_task.abort();
drop(tx);
writer_task
.await
.map_err(|err| format!("websocket writer task failed: {err}"))?
}
fn spawn_ws_domain_event_relay(
mut rx: tokio::sync::broadcast::Receiver<DomainEvent>,
tx: mpsc::Sender<ServerFrame>,
) -> tokio::task::JoinHandle<()> {
use tokio::sync::broadcast::error::RecvError;
tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(event) => {
if matches!(event, DomainEvent::PtyOutput { .. }) {
continue;
}
match tx.try_send(ServerFrame::domain_event(&event)) {
Ok(()) | Err(mpsc::error::TrySendError::Full(_)) => {}
Err(mpsc::error::TrySendError::Closed(_)) => break,
}
}
Err(RecvError::Lagged(_)) => continue,
Err(RecvError::Closed) => break,
}
}
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WsAttachment {
Pty { session: SessionId, gen: u64 },
Chat { session: SessionId, gen: u64 },
}
async fn handle_client_frame(
frame: ClientFrame,
state: &Arc<ServerState>,
tx: &mpsc::Sender<ServerFrame>,
owned: &Arc<Mutex<Vec<WsAttachment>>>,
) {
let result = match frame.kind.as_str() {
"terminal.open" | "open_terminal" => ws_open_terminal(&frame, state, tx, owned).await,
"agent.launch" | "launch_agent" => ws_launch_agent(&frame, state, tx, owned).await,
"terminal.attach" | "attach_terminal" => ws_attach_terminal(&frame, state, tx, owned).await,
"terminal.input" | "input" => ws_input(&frame, state),
"terminal.resize" | "resize" => ws_resize(&frame, state),
"terminal.detach" | "detach" => ws_detach(&frame, state, owned),
"chat.attach" => ws_attach_chat(&frame, state, tx, owned).await,
"chat.send" => ws_send_chat(&frame, state, tx).await,
"chat.detach" => ws_detach_chat(&frame, state, owned),
"terminal.close" | "close" => match ws_close(&frame, state, owned).await {
Ok((session_id, exit_code)) => {
let _ = tx
.send(ServerFrame::status(session_id, "exited", exit_code))
.await;
Ok(())
}
Err(err) => Err(err),
},
"ping" => {
let _ = tx.send(ServerFrame::pong()).await;
Ok(())
}
_ => Err(ErrorDto {
code: "UNKNOWN_FRAME".to_owned(),
message: format!("unknown websocket frame kind: {}", frame.kind),
}),
};
if let Err(err) = result {
let _ = tx
.send(ServerFrame::error(
Some(frame.id),
payload_session_id(&frame.payload),
err.code,
err.message,
))
.await;
}
}
async fn ws_open_terminal(
frame: &ClientFrame,
state: &Arc<ServerState>,
tx: &mpsc::Sender<ServerFrame>,
owned: &Arc<Mutex<Vec<WsAttachment>>>,
) -> Result<(), ErrorDto> {
let request_value = frame
.payload
.get("request")
.cloned()
.unwrap_or_else(|| frame.payload.clone());
let request: OpenTerminalRequestDto =
serde_json::from_value(request_value).map_err(invalid_args_error)?;
let output = state
.app
.open_terminal
.execute(request.into())
.await
.map_err(ErrorDto::from)?;
let dto = TerminalSessionDto::from(output);
let sid = parse_session_id(&dto.session_id)?;
let sink = WsPtySink::new(sid, tx.clone(), 0);
tx.send(ServerFrame::attached(&frame.id, &dto, Vec::new(), 0, false))
.await
.map_err(|_| ErrorDto {
code: "WS_CLOSED".to_owned(),
message: "websocket output closed".to_owned(),
})?;
attach_sink_and_pump(state, sid, sink, owned, true)
}
async fn ws_launch_agent(
frame: &ClientFrame,
state: &Arc<ServerState>,
tx: &mpsc::Sender<ServerFrame>,
owned: &Arc<Mutex<Vec<WsAttachment>>>,
) -> Result<(), ErrorDto> {
let request_value = frame
.payload
.get("request")
.cloned()
.unwrap_or_else(|| frame.payload.clone());
let request: LaunchAgentRequestDto =
serde_json::from_value(request_value).map_err(invalid_args_error)?;
let output = execute_launch_agent_for_ws(state, request).await?;
send_launch_agent_attached(frame, state, tx, owned, output).await
}
struct WsLaunchOutput {
launch: application::LaunchAgentOutput,
history: Option<application::TurnPage>,
}
impl From<application::LaunchAgentOutput> for WsLaunchOutput {
fn from(launch: application::LaunchAgentOutput) -> Self {
Self {
launch,
history: None,
}
}
}
async fn send_launch_agent_attached(
frame: &ClientFrame,
state: &Arc<ServerState>,
tx: &mpsc::Sender<ServerFrame>,
owned: &Arc<Mutex<Vec<WsAttachment>>>,
output: impl Into<WsLaunchOutput>,
) -> Result<(), ErrorDto> {
let output = output.into();
let WsLaunchOutput {
launch: output,
history,
} = output;
if let Some(structured) = output.structured.as_ref() {
let transcript_unavailable_reason =
ConversationTranscriptUnavailableReasonDto::for_scrollback(
true,
state
.app
.structured_sessions
.transcript_restore_expected(&structured.session_id),
);
tx.send(ServerFrame::chat_attached(
&frame.id,
structured,
output.assigned_conversation_id.as_deref(),
output.engine_session_id.as_deref(),
Vec::new(),
transcript_unavailable_reason,
history,
))
.await
.map_err(|_| ErrorDto {
code: "WS_CLOSED".to_owned(),
message: "websocket output closed".to_owned(),
})?;
return Ok(());
}
let session_id = output.session.id;
let dto = TerminalSessionDto::from(output);
let sid = parse_session_id(&dto.session_id)?;
let sink = WsPtySink::new(sid, tx.clone(), 0);
tx.send(ServerFrame::attached(&frame.id, &dto, Vec::new(), 0, false))
.await
.map_err(|_| ErrorDto {
code: "WS_CLOSED".to_owned(),
message: "websocket output closed".to_owned(),
})?;
attach_sink_and_pump(state, session_id, sink, owned, true)
}
async fn execute_launch_agent_for_ws(
state: &Arc<ServerState>,
request: LaunchAgentRequestDto,
) -> Result<WsLaunchOutput, ErrorDto> {
let project = resolve_project_readonly(&request.project_id, &state.app).await?;
let project_id = project.id;
let agent_id = parse_agent_id(&request.agent_id)?;
let node_id = request.node_id.as_deref().map(parse_node_id).transpose()?;
let history = if request.cell_kind == Some(backend::dto::CellKind::Chat) {
if let Some(raw) = request.conversation_id.as_deref() {
let conversation = Uuid::parse_str(raw)
.map(domain::ConversationId::from_uuid)
.map_err(|_| {
ErrorDto::from(application::AppError::Invalid(format!(
"invalid conversation id: {raw}"
)))
})?;
state
.app
.custom_conversations
.assert_owned(&project.root, agent_id, conversation)
.await
.map_err(ErrorDto::from)?;
Some(
state
.app
.read_conversation_page
.execute(ReadConversationPageInput {
project_root: project.root.clone(),
conversation,
cursor: domain::PageCursor {
anchor: None,
direction: domain::PageDirection::Backward,
},
limit: 50,
})
.await
.map_err(ErrorDto::from)?,
)
} else {
None
}
} else {
None
};
let mcp_runtime = backend::mcp_endpoint::idea_exe_path().map(|exe| McpRuntime {
exe,
endpoint: backend::mcp_endpoint::mcp_endpoint(&project.id)
.as_cli_arg()
.to_owned(),
project_id: project.id.as_uuid().simple().to_string(),
requester: agent_id.to_string(),
});
state.app.reconcile_claude_run_dirs(&project).await;
let resume_project = project.clone();
let rotation_root = project.root.clone();
let watch_root = project.root.clone();
let output = state
.app
.launch_agent
.execute(LaunchAgentInput {
project: project.clone(),
agent_id,
rows: request.rows,
cols: request.cols,
node_id,
conversation_id: request.conversation_id.clone(),
mcp_runtime,
allow_structured_alongside_pty: false,
require_structured: request.cell_kind == Some(backend::dto::CellKind::Chat),
terminal_interface: request.cell_kind.map(|kind| match kind {
backend::dto::CellKind::Pty => domain::DefaultAgentTerminalInterface::NativeTui,
backend::dto::CellKind::Chat => domain::DefaultAgentTerminalInterface::IdeaCli,
}),
fresh_start: request.fresh_start,
managed_temp_exposure: application::ManagedTempExposureRequest::default(),
})
.await
.map_err(ErrorDto::from)?;
state
.app
.orchestrator_service
.reactivate_agent_execution(&project, agent_id);
if let Ok(mut contexts) = state.app.resume_contexts.lock() {
contexts.insert(
domain::RuntimeAgentKey::new(project_id, agent_id),
backend::ResumeContext {
project: resume_project,
rows: request.rows,
cols: request.cols,
node_id,
conversation_id: request
.conversation_id
.clone()
.or_else(|| output.assigned_conversation_id.clone()),
require_structured: request.cell_kind == Some(backend::dto::CellKind::Chat),
},
);
}
if let Some(profile) = output.profile.as_ref() {
state.app.arm_turn_watch(
project_id,
&watch_root,
agent_id,
profile,
output.assigned_conversation_id.clone(),
);
}
if let Some(conversation) = request
.conversation_id
.as_deref()
.and_then(|raw| uuid::Uuid::parse_str(raw).ok())
.map(domain::ConversationId::from_uuid)
{
let rotate = Arc::clone(&state.app.rotate_conversation_log);
tokio::spawn(async move {
let _ = rotate
.execute(RotateConversationLogInput {
project_root: rotation_root,
conversation,
})
.await;
});
}
Ok(WsLaunchOutput {
launch: output,
history,
})
}
async fn ws_attach_terminal(
frame: &ClientFrame,
state: &Arc<ServerState>,
tx: &mpsc::Sender<ServerFrame>,
owned: &Arc<Mutex<Vec<WsAttachment>>>,
) -> Result<(), ErrorDto> {
let session_id = payload_string(&frame.payload, "sessionId")?;
let sid = parse_session_id(&session_id)?;
let handle = PtyHandle { session_id: sid };
let scrollback = state
.app
.pty_port
.scrollback(&handle)
.map_err(|err| ErrorDto::from(application::AppError::from(err)))?;
let rows = payload_u16(&frame.payload, "rows").unwrap_or(24);
let cols = payload_u16(&frame.payload, "cols").unwrap_or(80);
let dto = TerminalSessionDto {
session_id,
cwd: String::new(),
rows,
cols,
assigned_conversation_id: None,
engine_session_id: None,
cell_kind: backend::dto::CellKind::Pty,
history: Vec::new(),
history_has_more: false,
history_next_anchor: None,
resumed: false,
};
let next_seq = u64::from(!scrollback.is_empty());
tx.send(ServerFrame::attached(
&frame.id,
&dto,
scrollback.clone(),
next_seq,
frame.payload.get("lastSeq").is_some(),
))
.await
.map_err(|_| ErrorDto {
code: "WS_CLOSED".to_owned(),
message: "websocket output closed".to_owned(),
})?;
let sink = WsPtySink::new(sid, tx.clone(), next_seq);
attach_sink_and_pump(state, sid, sink, owned, false)
}
fn attach_sink_and_pump(
state: &Arc<ServerState>,
sid: SessionId,
sink: WsPtySink,
owned: &Arc<Mutex<Vec<WsAttachment>>>,
replay_initial_scrollback: bool,
) -> Result<(), ErrorDto> {
let handle = PtyHandle { session_id: sid };
let (scrollback, stream) = if replay_initial_scrollback {
state.app.pty_port.subscribe_output_with_scrollback(&handle)
} else {
state
.app
.pty_port
.subscribe_output(&handle)
.map(|stream| (Vec::new(), stream))
}
.map_err(|err| ErrorDto::from(application::AppError::from(err)))?;
let gen = state.ws_pty_bridge.register(sid, Arc::new(sink));
if let Ok(mut owned) = owned.lock() {
owned.push(WsAttachment::Pty { session: sid, gen });
}
if !scrollback.is_empty() {
let _ = state.ws_pty_bridge.send_output(&sid, scrollback);
}
let bridge = Arc::clone(&state.ws_pty_bridge);
std::thread::spawn(move || {
for chunk in stream {
if !bridge.send_output(&sid, chunk) {
break;
}
}
bridge.unregister_if(&sid, gen);
});
Ok(())
}
fn ws_input(frame: &ClientFrame, state: &Arc<ServerState>) -> Result<(), ErrorDto> {
let sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?;
let bytes = payload_bytes(&frame.payload)?;
state
.app
.write_terminal
.execute(WriteToTerminalInput {
session_id: sid,
data: bytes,
})
.map_err(ErrorDto::from)
}
fn ws_resize(frame: &ClientFrame, state: &Arc<ServerState>) -> Result<(), ErrorDto> {
let sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?;
let rows = payload_u16(&frame.payload, "rows").ok_or_else(|| ErrorDto {
code: "INVALID".to_owned(),
message: "resize requires rows".to_owned(),
})?;
let cols = payload_u16(&frame.payload, "cols").ok_or_else(|| ErrorDto {
code: "INVALID".to_owned(),
message: "resize requires cols".to_owned(),
})?;
state
.app
.resize_terminal
.execute(ResizeTerminalInput {
session_id: sid,
rows,
cols,
})
.map_err(ErrorDto::from)
}
fn ws_detach(
frame: &ClientFrame,
state: &Arc<ServerState>,
owned: &Arc<Mutex<Vec<WsAttachment>>>,
) -> Result<(), ErrorDto> {
let sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?;
if let Ok(mut owned) = owned.lock() {
if let Some(index) = owned.iter().rposition(
|attachment| matches!(attachment, WsAttachment::Pty { session, .. } if *session == sid),
) {
let WsAttachment::Pty { gen, .. } = owned.remove(index) else {
unreachable!("rposition matched a PTY attachment");
};
state.ws_pty_bridge.unregister_if(&sid, gen);
}
}
Ok(())
}
async fn ws_attach_chat(
frame: &ClientFrame,
state: &Arc<ServerState>,
tx: &mpsc::Sender<ServerFrame>,
owned: &Arc<Mutex<Vec<WsAttachment>>>,
) -> Result<(), ErrorDto> {
let requested_sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?;
let (sid, session) =
if let Some(session) = state.app.structured_sessions.session(&requested_sid) {
(requested_sid, session)
} else {
let fallback = frame
.payload
.get("projectId")
.and_then(Value::as_str)
.zip(frame.payload.get("agentId").and_then(Value::as_str));
let (project_id, agent_id) = fallback.ok_or_else(|| ErrorDto {
code: "NOT_FOUND".to_owned(),
message: "structured chat session not found".to_owned(),
})?;
let project_id = parse_project_id(project_id)?;
let agent_id = parse_agent_id(agent_id)?;
let sid = state
.app
.structured_sessions
.session_id_for_agent_in_project(project_id, &agent_id)
.ok_or_else(|| ErrorDto {
code: "NOT_FOUND".to_owned(),
message: "structured chat session not found".to_owned(),
})?;
let session = state
.app
.structured_sessions
.session(&sid)
.ok_or_else(|| ErrorDto {
code: "NOT_FOUND".to_owned(),
message: "structured chat session not found".to_owned(),
})?;
(sid, session)
};
let transcript_unavailable_reason = ConversationTranscriptUnavailableReasonDto::for_scrollback(
true,
state
.app
.structured_sessions
.transcript_restore_expected(&sid),
);
tx.send(ServerFrame::chat_attached(
&frame.id,
&application::StructuredSessionDescriptor {
session_id: sid,
agent_id: domain::AgentId::from_uuid(Uuid::nil()),
node_id: domain::NodeId::from_uuid(Uuid::nil()),
conversation_id: session.conversation_id(),
},
None,
session.conversation_id().as_deref(),
Vec::new(),
transcript_unavailable_reason,
None,
))
.await
.map_err(|_| ErrorDto {
code: "WS_CLOSED".to_owned(),
message: "websocket output closed".to_owned(),
})?;
let gen = state
.ws_chat_bridge
.register(sid, Arc::new(WsChatSink::new(sid, tx.clone())));
if let Ok(mut owned) = owned.lock() {
owned.push(WsAttachment::Chat { session: sid, gen });
}
Ok(())
}
async fn ws_send_chat(
frame: &ClientFrame,
state: &Arc<ServerState>,
tx: &mpsc::Sender<ServerFrame>,
) -> Result<(), ErrorDto> {
let sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?;
let prompt = payload_string(&frame.payload, "prompt")?;
let session = state
.app
.structured_sessions
.session(&sid)
.ok_or_else(|| ErrorDto {
code: "NOT_FOUND".to_owned(),
message: "structured chat session not found".to_owned(),
})?;
let session_meta = state.app.structured_sessions.meta_for_session(&sid);
let custom_context = match (
session_meta.as_ref(),
state.app.structured_sessions.custom_conversation_id(&sid),
) {
(Some((project_id, agent_id, _, _)), Some(conversation_id)) => {
let project = state
.app
.project_store
.list_projects()
.await
.map_err(|error| ErrorDto::from(application::AppError::Store(error.to_string())))?
.into_iter()
.find(|project| project.id == *project_id)
.ok_or_else(|| {
ErrorDto::from(application::AppError::NotFound(format!(
"project {project_id}"
)))
})?;
Some((project.root, *agent_id, conversation_id))
}
_ => None,
};
let mut provider_prompt = prompt.clone();
if let Some((root, agent_id, conversation_id)) = &custom_context {
state
.app
.custom_conversations
.record_prompt(
root,
*agent_id,
*conversation_id,
u64::try_from(state.app.clock.now_millis()).unwrap_or_default(),
&prompt,
)
.await
.map_err(ErrorDto::from)?;
if let Some(summary) = state
.app
.structured_sessions
.take_pending_resume_preamble(&sid)
{
provider_prompt = format!(
"Contexte borné de la conversation reprise :\n\n{summary}\n\n---\n\nNouveau message utilisateur :\n{prompt}"
);
}
}
let turn_guard = session_meta.as_ref().map(|(project_id, agent_id, _, _)| {
backend::StructuredHumanTurnGuard::start_with_source(
Arc::clone(&state.app.turn_watch_input),
Arc::clone(&state.app.input_mailbox),
Arc::clone(&state.app.restart_rendezvous),
*project_id,
*agent_id,
&prompt,
"websocket_chat_send",
)
});
let (tap_tx, tap_rx) = std::sync::mpsc::channel();
let tap_out = tx.clone();
let tap_clock = Arc::clone(&state.app.clock);
let tap_turn_guard = turn_guard.as_ref().map(|guard| guard.liveness_handle());
let tap_pump = std::thread::spawn(move || {
for event in tap_rx {
if !matches!(event, ReplyEvent::Final { .. }) {
if let Some(guard) = &tap_turn_guard {
guard.mark_alive();
}
}
if matches!(event, ReplyEvent::Final { .. } | ReplyEvent::Error { .. }) {
continue;
}
let chunk = reply_event_chunk(event, u64::try_from(tap_clock.now_millis()).ok());
if tap_out
.blocking_send(ServerFrame::chat_output(None, sid, chunk))
.is_err()
{
break;
}
}
});
let stream_result = session.send_with_tap(&provider_prompt, tap_tx).await;
let _ = tap_pump.join();
let mut turn_guard = turn_guard;
let mut stream = match stream_result {
Ok(stream) => stream,
Err(_err)
if turn_guard
.as_mut()
.is_some_and(|guard| guard.restart_is_active()) =>
{
Box::new(std::iter::empty()) as domain::ports::ReplyStream
}
Err(err) => {
return Err(ErrorDto {
code: "CHAT_SEND_FAILED".to_owned(),
message: err.to_string(),
})
}
};
let mut sent_ack = false;
'logical_turn: loop {
let mut concrete_terminal = false;
let mut resume = false;
let mut resume_prompt = None;
for event in stream {
if let ReplyEvent::Final { content } = &event {
if let Some((root, agent_id, conversation_id)) = &custom_context {
let _ = state
.app
.custom_conversations
.record_response(
root,
*agent_id,
*conversation_id,
u64::try_from(state.app.clock.now_millis()).unwrap_or_default(),
content,
)
.await;
}
}
let provider_terminal =
matches!(event, ReplyEvent::Final { .. } | ReplyEvent::Error { .. });
if provider_terminal {
concrete_terminal = true;
if let Some(guard) = turn_guard.as_mut() {
match guard.transition_after_provider_terminal_async().await {
backend::StructuredHumanTurnTransition::Complete => {
// The websocket frame below is the client-visible terminal
// boundary. Release the exact mediated ticket first so a
// work-state read triggered by Final/Error cannot still see
// this completed turn as Busy.
guard.finish();
}
backend::StructuredHumanTurnTransition::Resume { prompt } => {
resume = true;
resume_prompt = Some(prompt);
break;
}
backend::StructuredHumanTurnTransition::Failed(message) => {
guard.finish();
let chunk = reply_event_chunk(
ReplyEvent::Error { message },
u64::try_from(state.app.clock.now_millis()).ok(),
);
tx.send(ServerFrame::chat_output(
(!sent_ack).then_some(frame.id.as_str()),
sid,
chunk,
))
.await
.map_err(|_| ErrorDto {
code: "WS_CLOSED".to_owned(),
message: "websocket output closed".to_owned(),
})?;
sent_ack = true;
break 'logical_turn;
}
backend::StructuredHumanTurnTransition::Cancelled => {
break 'logical_turn;
}
}
}
} else {
if let Some(guard) = &turn_guard {
guard.mark_alive();
}
}
let chunk = reply_event_chunk(event, u64::try_from(state.app.clock.now_millis()).ok());
if !sent_ack {
tx.send(ServerFrame::chat_output(Some(&frame.id), sid, chunk))
.await
.map_err(|_| ErrorDto {
code: "WS_CLOSED".to_owned(),
message: "websocket output closed".to_owned(),
})?;
sent_ack = true;
} else {
tx.send(ServerFrame::chat_output(None, sid, chunk))
.await
.map_err(|_| ErrorDto {
code: "WS_CLOSED".to_owned(),
message: "websocket output closed".to_owned(),
})?;
}
}
if !resume && !concrete_terminal {
if let Some(guard) = turn_guard.as_mut() {
match guard.transition_after_provider_terminal_async().await {
backend::StructuredHumanTurnTransition::Complete => guard.finish(),
backend::StructuredHumanTurnTransition::Resume { prompt } => {
resume = true;
resume_prompt = Some(prompt);
}
backend::StructuredHumanTurnTransition::Failed(message) => {
guard.finish();
let chunk = reply_event_chunk(
ReplyEvent::Error { message },
u64::try_from(state.app.clock.now_millis()).ok(),
);
tx.send(ServerFrame::chat_output(
(!sent_ack).then_some(frame.id.as_str()),
sid,
chunk,
))
.await
.map_err(|_| ErrorDto {
code: "WS_CLOSED".to_owned(),
message: "websocket output closed".to_owned(),
})?;
sent_ack = true;
}
backend::StructuredHumanTurnTransition::Cancelled => break,
}
}
}
if !resume {
break;
}
let Some((project_id, agent_id, _, _)) = session_meta.as_ref() else {
break;
};
let replacement = state
.app
.structured_sessions
.session_for_agent_in_project(*project_id, agent_id)
.ok_or_else(|| ErrorDto {
code: "CHAT_RESTART_FAILED".to_owned(),
message: "restart completed without a replacement structured session".to_owned(),
})?;
let resume_prompt = resume_prompt
.unwrap_or_else(|| application::SELF_RESTART_CONTINUATION_PROMPT.to_owned());
stream = replacement
.send(&resume_prompt)
.await
.map_err(|err| ErrorDto {
code: "CHAT_RESTART_FAILED".to_owned(),
message: err.to_string(),
})?;
}
if !sent_ack {
if let Some(guard) = &turn_guard {
guard.finish();
}
tx.send(ServerFrame::chat_output(
Some(&frame.id),
sid,
json!({ "kind": "final", "content": "", "atMs": u64::try_from(state.app.clock.now_millis()).ok() }),
))
.await
.map_err(|_| ErrorDto {
code: "WS_CLOSED".to_owned(),
message: "websocket output closed".to_owned(),
})?;
}
if let Some(guard) = &turn_guard {
guard.finish();
}
Ok(())
}
fn ws_detach_chat(
frame: &ClientFrame,
state: &Arc<ServerState>,
owned: &Arc<Mutex<Vec<WsAttachment>>>,
) -> Result<(), ErrorDto> {
let sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?;
if let Ok(mut owned) = owned.lock() {
if let Some(index) = owned
.iter()
.rposition(|attachment| matches!(attachment, WsAttachment::Chat { session, .. } if *session == sid))
{
let WsAttachment::Chat { gen, .. } = owned.remove(index) else {
unreachable!("rposition matched a chat attachment");
};
state.ws_chat_bridge.unregister_if(&sid, gen);
}
}
Ok(())
}
fn reply_event_chunk(event: ReplyEvent, occurred_at_ms: Option<u64>) -> Value {
match event {
ReplyEvent::TextDelta { text } => {
json!({ "kind": "textDelta", "text": text, "atMs": occurred_at_ms })
}
ReplyEvent::ToolActivity { label } => {
json!({ "kind": "toolActivity", "label": label, "atMs": occurred_at_ms })
}
ReplyEvent::Progress { progress } => {
json!({ "kind": "progress", "progress": reply_progress_value(progress, occurred_at_ms) })
}
ReplyEvent::Announcement { text } => {
json!({ "kind": "textDelta", "text": text, "atMs": occurred_at_ms })
}
ReplyEvent::Heartbeat => json!({
"kind": "progress",
"progress": {
"source": "ideaLocal",
"kind": "other",
"stage": "info",
"label": "heartbeat"
,"occurredAtMs": occurred_at_ms
}
}),
ReplyEvent::RateLimited { resets_at_ms } => json!({
"kind": "progress",
"progress": {
"source": "providerNative",
"kind": "turn",
"stage": "info",
"label": "rate_limited",
"occurredAtMs": occurred_at_ms,
"resetsAtMs": resets_at_ms
}
}),
ReplyEvent::Error { message } => {
json!({ "kind": "error", "message": message, "atMs": occurred_at_ms })
}
ReplyEvent::Final { content } => {
json!({ "kind": "final", "content": content, "atMs": occurred_at_ms })
}
}
}
fn reply_progress_value(progress: ReplyProgress, occurred_at_ms: Option<u64>) -> Value {
json!({
"progressId": progress.progress_id,
"source": reply_progress_source(progress.source),
"kind": reply_progress_kind(progress.kind),
"stage": reply_progress_stage(progress.stage),
"label": progress.label,
"text": progress.text,
"provider": progress.provider,
"nativeEvent": progress.native_event,
"toolName": progress.tool_name,
"occurredAtMs": occurred_at_ms,
})
}
fn reply_progress_source(source: ReplyProgressSource) -> &'static str {
match source {
ReplyProgressSource::ProviderNative => "providerNative",
ReplyProgressSource::IdeaLocal => "ideaLocal",
}
}
fn reply_progress_kind(kind: ReplyProgressKind) -> &'static str {
match kind {
ReplyProgressKind::Turn => "turn",
ReplyProgressKind::Message => "message",
ReplyProgressKind::Tool => "tool",
ReplyProgressKind::Mcp => "mcp",
ReplyProgressKind::Other => "other",
}
}
fn reply_progress_stage(stage: ReplyProgressStage) -> &'static str {
match stage {
ReplyProgressStage::Started => "started",
ReplyProgressStage::Delta => "delta",
ReplyProgressStage::Completed => "completed",
ReplyProgressStage::Info => "info",
}
}
async fn ws_close(
frame: &ClientFrame,
state: &Arc<ServerState>,
owned: &Arc<Mutex<Vec<WsAttachment>>>,
) -> Result<(SessionId, Option<i32>), ErrorDto> {
let sid = parse_session_id(&payload_string(&frame.payload, "sessionId")?)?;
ws_detach(frame, state, owned)?;
let output = state
.app
.close_terminal
.execute(CloseTerminalInput { session_id: sid })
.await
.map_err(ErrorDto::from)?;
Ok((sid, output.code))
}
async fn dispatch_http(
method: Method,
uri: Uri,
headers: HeaderMap,
body: Bytes,
peer_ip: Option<IpAddr>,
state: Arc<ServerState>,
) -> Response<ResponseBody> {
if has_forbidden_query_secret(&uri) {
return error_response(
StatusCode::BAD_REQUEST,
"INVALID",
"secrets in URL query strings are forbidden",
None,
);
}
if !is_api_path(uri.path()) {
if let Err(response) = validate_reverse_proxy(&headers, peer_ip, &state, uri.path()) {
return *response;
}
return serve_static(&state.config.web_root, method, uri.path()).await;
}
if let Err(response) = validate_reverse_proxy(&headers, peer_ip, &state, uri.path()) {
return *response;
}
let origin = match validate_request_origin(
&headers,
&state.config,
api_route_allows_missing_origin(&method, uri.path()),
) {
Ok(origin) => origin,
Err(response) => {
state.log_security(SecurityLogEvent::OriginRejected {
origin: request_origin(&headers),
route: uri.path().to_owned(),
});
return *response;
}
};
if method == Method::OPTIONS {
return cors_response(StatusCode::NO_CONTENT, origin.as_deref());
}
match (method.clone(), uri.path()) {
(Method::POST, "/api/pair") => pair(body, state, origin.as_deref()).await,
(Method::POST, "/api/pairing-code") => {
let Some(token) = session_cookie(&headers) else {
return error_response(
StatusCode::UNAUTHORIZED,
"UNAUTHORIZED",
"missing session cookie",
origin.as_deref(),
);
};
let Some(device) = state.authenticate_session(&token).await else {
return error_response(
StatusCode::UNAUTHORIZED,
"UNAUTHORIZED",
"invalid session cookie",
origin.as_deref(),
);
};
state.touch_session(&device).await;
let dto = state.generate_pairing_code();
let body = serde_json::to_value(dto).expect("pairing code dto serializes");
let mut response = json_response(StatusCode::OK, &body, origin.as_deref());
refresh_session_cookie(&mut response, &state, &token);
response
}
(Method::GET, "/api/devices") => {
let (token, device) = match authenticated_device(&headers, &state).await {
Ok(auth) => auth,
Err(response) => return response,
};
let mut response =
list_devices_response(Arc::clone(&state), &device, origin.as_deref()).await;
refresh_session_cookie(&mut response, &state, &token);
response
}
(Method::GET, "/api/project-files") => {
let (token, _device) = match authenticated_device(&headers, &state).await {
Ok(auth) => auth,
Err(response) => return response,
};
let mut response =
project_files_list_response(&uri, Arc::clone(&state), origin.as_deref()).await;
refresh_session_cookie(&mut response, &state, &token);
response
}
(Method::GET, "/api/project-files/download-file") => {
let (token, _device) = match authenticated_device(&headers, &state).await {
Ok(auth) => auth,
Err(response) => return response,
};
let mut response =
project_file_download_response(&uri, Arc::clone(&state), origin.as_deref()).await;
refresh_session_cookie(&mut response, &state, &token);
response
}
(Method::GET, "/api/project-files/download-directory") => {
let (token, _device) = match authenticated_device(&headers, &state).await {
Ok(auth) => auth,
Err(response) => return response,
};
let mut response =
project_directory_download_response(&uri, Arc::clone(&state), origin.as_deref())
.await;
refresh_session_cookie(&mut response, &state, &token);
response
}
(_, path)
if path == "/api/project-icon-assets"
|| path.starts_with("/api/project-icon-assets/") =>
{
let (token, _device) = match authenticated_device(&headers, &state).await {
Ok(auth) => auth,
Err(response) => return response,
};
let mut response = project_icon_asset_response(
&method,
path,
body,
Arc::clone(&state),
origin.as_deref(),
)
.await;
refresh_session_cookie(&mut response, &state, &token);
response
}
(Method::POST, "/api/devices/revoke-all") => {
let (_token, _device) = match authenticated_device(&headers, &state).await {
Ok(auth) => auth,
Err(response) => return response,
};
revoke_all_devices_response(Arc::clone(&state), origin.as_deref()).await
}
(Method::POST, path) if path.starts_with("/api/devices/") && path.ends_with("/rename") => {
let (token, _device) = match authenticated_device(&headers, &state).await {
Ok(auth) => auth,
Err(response) => return response,
};
let raw_id = path
.trim_start_matches("/api/devices/")
.trim_end_matches("/rename")
.trim_end_matches('/');
let device_id = match parse_device_id(raw_id) {
Ok(device_id) => device_id,
Err(err) => {
return error_response(
StatusCode::BAD_REQUEST,
"INVALID",
err,
origin.as_deref(),
);
}
};
let mut response =
rename_device_response(body, Arc::clone(&state), device_id, origin.as_deref())
.await;
refresh_session_cookie(&mut response, &state, &token);
response
}
(Method::POST, path) if path.starts_with("/api/devices/") && path.ends_with("/revoke") => {
let (_token, _device) = match authenticated_device(&headers, &state).await {
Ok(auth) => auth,
Err(response) => return response,
};
let raw_id = path
.trim_start_matches("/api/devices/")
.trim_end_matches("/revoke")
.trim_end_matches('/');
let device_id = match parse_device_id(raw_id) {
Ok(device_id) => device_id,
Err(err) => {
return error_response(
StatusCode::BAD_REQUEST,
"INVALID",
err,
origin.as_deref(),
);
}
};
revoke_device_response(Arc::clone(&state), device_id, origin.as_deref()).await
}
(Method::POST, "/api/logout") => {
let Some(token) = session_cookie(&headers) else {
return error_response(
StatusCode::UNAUTHORIZED,
"UNAUTHORIZED",
"missing session cookie",
origin.as_deref(),
);
};
if !state.revoke_session(&token).await {
return error_response(
StatusCode::UNAUTHORIZED,
"UNAUTHORIZED",
"invalid session cookie",
origin.as_deref(),
);
}
state.log_security(SecurityLogEvent::SessionRevoked {
origin: origin.clone(),
});
logout_response(&state, origin.as_deref())
}
(Method::POST, "/api/invoke") => {
let Some(token) = session_cookie(&headers) else {
return error_response(
StatusCode::UNAUTHORIZED,
"UNAUTHORIZED",
"missing session cookie",
origin.as_deref(),
);
};
let Some(device) = state.authenticate_session(&token).await else {
return error_response(
StatusCode::UNAUTHORIZED,
"UNAUTHORIZED",
"invalid session cookie",
origin.as_deref(),
);
};
state.touch_session(&device).await;
let mut response = invoke(body, Arc::clone(&state), origin.as_deref()).await;
refresh_session_cookie(&mut response, &state, &token);
response
}
(
_,
"/api/pair"
| "/api/pairing-code"
| "/api/invoke"
| "/api/logout"
| "/api/devices"
| "/api/project-files"
| "/api/project-files/download-file"
| "/api/project-files/download-directory"
| "/api/devices/revoke-all",
) => error_response(
StatusCode::METHOD_NOT_ALLOWED,
"METHOD_NOT_ALLOWED",
"method not allowed",
origin.as_deref(),
),
(_, path) if path.starts_with("/api/devices/") => error_response(
StatusCode::METHOD_NOT_ALLOWED,
"METHOD_NOT_ALLOWED",
"method not allowed",
origin.as_deref(),
),
_ if is_api_path(uri.path()) => error_response(
StatusCode::NOT_FOUND,
"NOT_FOUND",
"route not found",
origin.as_deref(),
),
_ => error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None),
}
}
async fn project_icon_asset_response(
method: &Method,
path: &str,
body: Bytes,
state: Arc<ServerState>,
origin: Option<&str>,
) -> Response<ResponseBody> {
if method == Method::GET && path == "/api/project-icon-assets" {
return match state.app.list_custom_project_icon_assets.execute().await {
Ok(assets) => json_response(
StatusCode::OK,
&serde_json::to_value(
assets
.into_iter()
.map(CustomProjectIconAssetDto::from)
.collect::<Vec<_>>(),
)
.unwrap(),
origin,
),
Err(error) => app_error_response(error, origin),
};
}
if method == Method::POST && path == "/api/project-icon-assets" {
let request = match serde_json::from_slice::<ImportCustomProjectIconAssetRequestDto>(&body)
{
Ok(request) => request,
Err(error) => {
return error_response(
StatusCode::BAD_REQUEST,
"INVALID",
format!("invalid import request: {error}"),
origin,
)
}
};
let bytes = match base64_decode(&request.data_base64) {
Ok(bytes) => bytes,
Err(error) => return error_dto_response(StatusCode::BAD_REQUEST, error, origin),
};
return match state
.app
.import_custom_project_icon_asset
.execute(request.original_filename, request.media_type, bytes)
.await
{
Ok(asset) => json_response(
StatusCode::CREATED,
&serde_json::to_value(CustomProjectIconAssetDto::from(asset)).unwrap(),
origin,
),
Err(error) => app_error_response(error, origin),
};
}
let Some(asset_path) = path.strip_prefix("/api/project-icon-assets/") else {
return error_response(
StatusCode::METHOD_NOT_ALLOWED,
"METHOD_NOT_ALLOWED",
"method not allowed",
origin,
);
};
if method == Method::GET && asset_path.ends_with("/usage") {
let asset_id = asset_path.trim_end_matches("/usage").trim_end_matches('/');
return match state
.app
.get_custom_project_icon_asset_usage
.execute(asset_id)
.await
{
Ok(usage) => json_response(
StatusCode::OK,
&serde_json::to_value(CustomProjectIconUsageDto::from(usage)).unwrap(),
origin,
),
Err(error) => app_error_response(error, origin),
};
}
if method == Method::GET {
return match state
.app
.read_custom_project_icon_asset
.execute(asset_path)
.await
{
Ok(output) => {
let mut response = Response::new(ResponseBody::bytes(Bytes::from(output.bytes)));
*response.status_mut() = StatusCode::OK;
if let Ok(value) = HeaderValue::from_str(&output.metadata.asset.media_type) {
response.headers_mut().insert(CONTENT_TYPE, value);
}
response.headers_mut().insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
add_cors_headers(&mut response, origin);
response
}
Err(error) => app_error_response(error, origin),
};
}
if method == Method::DELETE {
return match state
.app
.delete_custom_project_icon_asset
.execute(asset_path)
.await
{
Ok(usage) => json_response(
StatusCode::OK,
&serde_json::to_value(CustomProjectIconUsageDto::from(usage)).unwrap(),
origin,
),
Err(error) => app_error_response(error, origin),
};
}
error_response(
StatusCode::METHOD_NOT_ALLOWED,
"METHOD_NOT_ALLOWED",
"method not allowed",
origin,
)
}
fn app_error_response(error: AppError, origin: Option<&str>) -> Response<ResponseBody> {
let error = ErrorDto::from(error);
error_dto_response(error_status(&error), error, origin)
}
fn is_api_path(path: &str) -> bool {
path == "/api" || path.starts_with("/api/")
}
async fn serve_static(web_root: &Path, method: Method, path: &str) -> Response<ResponseBody> {
if !matches!(method, Method::GET | Method::HEAD) {
return error_response(
StatusCode::METHOD_NOT_ALLOWED,
"METHOD_NOT_ALLOWED",
"method not allowed",
None,
);
}
let Some(route) = static_route(web_root, path) else {
return error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None);
};
let file = if route.candidate.is_file() {
route.candidate
} else if route.spa_fallback {
web_root.join("index.html")
} else {
return error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None);
};
let bytes = match tokio::fs::read(&file).await {
Ok(bytes) => bytes,
Err(_) => {
return error_response(StatusCode::NOT_FOUND, "NOT_FOUND", "route not found", None);
}
};
static_file_response(&file, bytes, method == Method::HEAD)
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct StaticRoute {
candidate: PathBuf,
spa_fallback: bool,
}
fn static_route(web_root: &Path, request_path: &str) -> Option<StaticRoute> {
if !request_path.starts_with('/') || request_path.contains('\\') {
return None;
}
let lower = request_path.to_ascii_lowercase();
if lower.contains("%2e") || lower.contains("%2f") || lower.contains("%5c") {
return None;
}
let mut candidate = web_root.to_path_buf();
let mut last = "";
for segment in request_path
.split('/')
.filter(|segment| !segment.is_empty())
{
if segment == "." || segment == ".." || segment.starts_with('.') || segment.contains('%') {
return None;
}
candidate.push(segment);
last = segment;
}
if request_path == "/" {
return Some(StaticRoute {
candidate: web_root.join("index.html"),
spa_fallback: false,
});
}
Some(StaticRoute {
candidate,
spa_fallback: !last.contains('.'),
})
}
fn static_file_response(path: &Path, bytes: Vec<u8>, head_only: bool) -> Response<ResponseBody> {
let is_index = path
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name == "index.html");
let body = if head_only { Vec::new() } else { bytes };
let mut response = Response::builder()
.status(StatusCode::OK)
.header(CONTENT_TYPE, content_type(path))
.header("x-content-type-options", "nosniff")
.body(ResponseBody::bytes(Bytes::from(body)))
.expect("static response is valid");
if is_index {
response.headers_mut().insert(
"content-security-policy",
HeaderValue::from_static(
"default-src 'self'; connect-src 'self' ws: wss:; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; base-uri 'self'; frame-ancestors 'none'",
),
);
}
response
}
fn content_type(path: &Path) -> &'static str {
match path.extension().and_then(|ext| ext.to_str()).unwrap_or("") {
"html" => "text/html; charset=utf-8",
"js" | "mjs" => "text/javascript; charset=utf-8",
"css" => "text/css; charset=utf-8",
"json" => "application/json; charset=utf-8",
"svg" => "image/svg+xml",
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"ico" => "image/x-icon",
"wasm" => "application/wasm",
"txt" => "text/plain; charset=utf-8",
_ => "application/octet-stream",
}
}
#[derive(Deserialize)]
struct PairRequest {
code: String,
name: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct DeviceDto {
device_id: String,
name: String,
paired_at_ms: u64,
last_seen_at_ms: u64,
is_current_device: bool,
}
#[derive(Deserialize)]
struct RenameDeviceRequest {
name: String,
}
fn parse_device_id(raw: &str) -> Result<DeviceId, String> {
Uuid::parse_str(raw)
.map(DeviceId::from_uuid)
.map_err(|_| "invalid device id".to_owned())
}
async fn authenticated_device(
headers: &HeaderMap,
state: &ServerState,
) -> Result<(String, AuthenticatedDevice), Response<ResponseBody>> {
let origin = request_origin(headers);
let Some(token) = session_cookie(headers) else {
return Err(error_response(
StatusCode::UNAUTHORIZED,
"UNAUTHORIZED",
"missing session cookie",
origin.as_deref(),
));
};
let Some(device) = state.authenticate_session(&token).await else {
return Err(error_response(
StatusCode::UNAUTHORIZED,
"UNAUTHORIZED",
"invalid session cookie",
origin.as_deref(),
));
};
state.touch_session(&device).await;
Ok((token, device))
}
async fn project_files_list_response(
uri: &Uri,
state: Arc<ServerState>,
origin: Option<&str>,
) -> Response<ResponseBody> {
let params = match query_params(uri) {
Ok(params) => params,
Err(err) => return error_response(StatusCode::BAD_REQUEST, "INVALID", err, origin),
};
let project_id = match required_query(&params, "projectId") {
Ok(value) => value,
Err(err) => return error_response(StatusCode::BAD_REQUEST, "INVALID", err, origin),
};
let path = params.get("path").cloned().unwrap_or_default();
let project = match resolve_project_readonly(&project_id, &state.app).await {
Ok(project) => project,
Err(err) => return error_dto_response(error_status(&err), err, origin),
};
let listing = match state
.app
.list_project_files
.execute(ListProjectFilesInput { project, path })
.await
{
Ok(listing) => listing,
Err(err) => {
let err = ErrorDto::from(err);
return error_dto_response(error_status(&err), err, origin);
}
};
let value = serde_json::to_value(listing).expect("project file listing serializes");
json_response(StatusCode::OK, &value, origin)
}
async fn project_file_download_response(
uri: &Uri,
state: Arc<ServerState>,
origin: Option<&str>,
) -> Response<ResponseBody> {
let resolved = match resolve_project_download_path(uri, &state).await {
Ok(resolved) => resolved,
Err(response) => return response.with_origin(origin),
};
let meta = match tokio::fs::metadata(&resolved.full_path).await {
Ok(meta) => meta,
Err(err) => {
return error_response(
StatusCode::NOT_FOUND,
"NOT_FOUND",
format!("project file not found: {err}"),
origin,
);
}
};
if !meta.is_file() {
return error_response(
StatusCode::BAD_REQUEST,
"INVALID",
"project file path is not a file",
origin,
);
}
let filename = download_name(&resolved.relative, "project-file");
binary_file_response(
ResponseBody::File(resolved.full_path),
content_type(Path::new(&filename)),
&filename,
origin,
)
}
async fn project_directory_download_response(
uri: &Uri,
state: Arc<ServerState>,
origin: Option<&str>,
) -> Response<ResponseBody> {
let resolved = match resolve_project_download_path(uri, &state).await {
Ok(resolved) => resolved,
Err(response) => return response.with_origin(origin),
};
let meta = match tokio::fs::metadata(&resolved.full_path).await {
Ok(meta) => meta,
Err(err) => {
return error_response(
StatusCode::NOT_FOUND,
"NOT_FOUND",
format!("project directory not found: {err}"),
origin,
);
}
};
if !meta.is_dir() {
return error_response(
StatusCode::BAD_REQUEST,
"INVALID",
"project file path is not a directory",
origin,
);
}
let archive_name = format!(
"{}.zip",
download_name(&resolved.relative, &resolved.project_name)
);
let zip_path = match create_project_directory_zip(resolved).await {
Ok(path) => path,
Err(err) => {
return error_response(StatusCode::BAD_REQUEST, "INVALID", err.to_string(), origin);
}
};
binary_file_response(
ResponseBody::TempFile(zip_path),
"application/zip",
&archive_name,
origin,
)
}
struct ResolvedProjectPath {
root: PathBuf,
full_path: PathBuf,
relative: String,
project_name: String,
}
async fn resolve_project_download_path(
uri: &Uri,
state: &ServerState,
) -> Result<ResolvedProjectPath, ProjectPathResponseError> {
let params = query_params(uri).map_err(ProjectPathResponseError::bad_request)?;
let project_id =
required_query(&params, "projectId").map_err(ProjectPathResponseError::bad_request)?;
let relative = params.get("path").cloned().unwrap_or_default();
let project = resolve_project_readonly(&project_id, &state.app)
.await
.map_err(ProjectPathResponseError::dto)?;
let root = canonical_project_root_for_http(&project)
.await
.map_err(ProjectPathResponseError::app)?;
let relative = application::project::validate_project_relative_path(&relative)
.map_err(ProjectPathResponseError::app)?;
let full_path = confined_project_path_for_http(&root, &relative)
.await
.map_err(ProjectPathResponseError::app)?;
Ok(ResolvedProjectPath {
root,
full_path,
relative,
project_name: project.name,
})
}
async fn canonical_project_root_for_http(project: &Project) -> Result<PathBuf, AppError> {
tokio::fs::canonicalize(project.root.as_str())
.await
.map_err(|err| AppError::FileSystem(format!("{}: {err}", project.root.as_str())))
}
async fn confined_project_path_for_http(root: &Path, relative: &str) -> Result<PathBuf, AppError> {
let relative = application::project::validate_project_relative_path(relative)?;
if relative.split('/').any(|segment| segment == ".ideai") {
return Err(AppError::Invalid("project file path is hidden".to_owned()));
}
let candidate = if relative.is_empty() {
root.to_path_buf()
} else {
root.join(&relative)
};
let canonical = tokio::fs::canonicalize(&candidate)
.await
.map_err(|err| AppError::FileSystem(format!("{}: {err}", candidate.display())))?;
if !canonical.starts_with(root) {
return Err(AppError::Invalid(
"project file path escapes project root".to_owned(),
));
}
Ok(canonical)
}
enum ProjectPathResponseError {
BadRequest(String),
Dto(ErrorDto),
App(AppError),
}
impl ProjectPathResponseError {
fn bad_request(err: String) -> Self {
Self::BadRequest(err)
}
fn dto(err: ErrorDto) -> Self {
Self::Dto(err)
}
fn app(err: AppError) -> Self {
Self::App(err)
}
fn with_origin(self, origin: Option<&str>) -> Response<ResponseBody> {
match self {
Self::BadRequest(err) => {
error_response(StatusCode::BAD_REQUEST, "INVALID", err, origin)
}
Self::Dto(err) => error_dto_response(error_status(&err), err, origin),
Self::App(err) => {
let err = ErrorDto::from(err);
error_dto_response(error_status(&err), err, origin)
}
}
}
}
async fn create_project_directory_zip(resolved: ResolvedProjectPath) -> Result<PathBuf, AppError> {
let zip_path = std::env::temp_dir().join(format!("idea-project-files-{}.zip", Uuid::new_v4()));
let archive_root = download_name(&resolved.relative, &resolved.project_name);
let root = resolved.root;
let source = resolved.full_path;
let zip_target = zip_path.clone();
tokio::task::spawn_blocking(move || {
let file = std::fs::File::create(&zip_target)
.map_err(|err| AppError::FileSystem(format!("{}: {err}", zip_target.display())))?;
let mut zip = zip::ZipWriter::new(file);
zip_directory(&mut zip, &root, &source, &archive_root)?;
zip.finish()
.map_err(|err| AppError::FileSystem(format!("zip finish failed: {err}")))?;
Ok::<PathBuf, AppError>(zip_target)
})
.await
.map_err(|err| AppError::Process(format!("zip task failed: {err}")))?
}
fn zip_directory(
zip: &mut zip::ZipWriter<std::fs::File>,
root: &Path,
directory: &Path,
archive_root: &str,
) -> Result<(), AppError> {
let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
let mut stack = vec![directory.to_path_buf()];
while let Some(current) = stack.pop() {
let entries = std::fs::read_dir(&current)
.map_err(|err| AppError::FileSystem(format!("{}: {err}", current.display())))?;
for entry in entries {
let entry = entry
.map_err(|err| AppError::FileSystem(format!("{}: {err}", current.display())))?;
let name = entry.file_name().to_string_lossy().into_owned();
if name == ".ideai" {
continue;
}
let path = entry.path();
let symlink_meta = std::fs::symlink_metadata(&path)
.map_err(|err| AppError::FileSystem(format!("{}: {err}", path.display())))?;
if symlink_meta.file_type().is_symlink() {
let canonical = std::fs::canonicalize(&path)
.map_err(|err| AppError::FileSystem(format!("{}: {err}", path.display())))?;
if !canonical.starts_with(root) {
return Err(AppError::Invalid(
"project directory contains a symlink escaping project root".to_owned(),
));
}
return Err(AppError::Invalid(
"project directory symlinks are not downloadable".to_owned(),
));
}
let meta = std::fs::metadata(&path)
.map_err(|err| AppError::FileSystem(format!("{}: {err}", path.display())))?;
let canonical = std::fs::canonicalize(&path)
.map_err(|err| AppError::FileSystem(format!("{}: {err}", path.display())))?;
if !canonical.starts_with(root) {
return Err(AppError::Invalid(
"project directory entry escapes project root".to_owned(),
));
}
let rel = path
.strip_prefix(directory)
.map_err(|_| AppError::Invalid("project directory entry is invalid".to_owned()))?;
let archive_name = archive_path(archive_root, rel)?;
if meta.is_dir() {
zip.add_directory(format!("{archive_name}/"), options)
.map_err(|err| {
AppError::FileSystem(format!("zip add directory failed: {err}"))
})?;
stack.push(path);
} else if meta.is_file() {
zip.start_file(archive_name, options)
.map_err(|err| AppError::FileSystem(format!("zip add file failed: {err}")))?;
let mut file = std::fs::File::open(&path)
.map_err(|err| AppError::FileSystem(format!("{}: {err}", path.display())))?;
std::io::copy(&mut file, zip)
.map_err(|err| AppError::FileSystem(format!("zip write failed: {err}")))?;
}
}
}
Ok(())
}
fn archive_path(prefix: &str, relative: &Path) -> Result<String, AppError> {
let mut parts = vec![sanitize_download_component(prefix)];
for component in relative.components() {
match component {
std::path::Component::Normal(part) => {
let part = part.to_string_lossy();
if part == ".ideai" {
return Err(AppError::Invalid("project file path is hidden".to_owned()));
}
parts.push(part.into_owned());
}
_ => {
return Err(AppError::Invalid(
"project archive path is invalid".to_owned(),
))
}
}
}
Ok(parts.join("/"))
}
fn binary_file_response(
body: ResponseBody,
content_type: &'static str,
filename: &str,
origin: Option<&str>,
) -> Response<ResponseBody> {
let mut response = Response::new(body);
*response.status_mut() = StatusCode::OK;
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static(content_type));
response.headers_mut().insert(
CONTENT_DISPOSITION,
HeaderValue::from_str(&format!(
"attachment; filename=\"{}\"",
filename.replace('"', "")
))
.unwrap_or_else(|_| HeaderValue::from_static("attachment")),
);
add_cors_headers(&mut response, origin);
response
}
fn query_params(uri: &Uri) -> Result<HashMap<String, String>, String> {
let mut params = HashMap::new();
let Some(query) = uri.query() else {
return Ok(params);
};
for pair in query.split('&').filter(|pair| !pair.is_empty()) {
let (key, value) = pair.split_once('=').unwrap_or((pair, ""));
params.insert(percent_decode(key)?, percent_decode(value)?);
}
Ok(params)
}
fn required_query(params: &HashMap<String, String>, key: &str) -> Result<String, String> {
params
.get(key)
.filter(|value| !value.is_empty())
.cloned()
.ok_or_else(|| format!("missing query parameter: {key}"))
}
fn percent_decode(raw: &str) -> Result<String, String> {
let mut bytes = Vec::with_capacity(raw.len());
let mut chars = raw.as_bytes().iter().copied();
while let Some(byte) = chars.next() {
if byte == b'%' {
let hi = chars
.next()
.ok_or_else(|| "invalid percent encoding".to_owned())?;
let lo = chars
.next()
.ok_or_else(|| "invalid percent encoding".to_owned())?;
bytes.push((hex_value(hi)? << 4) | hex_value(lo)?);
} else if byte == b'+' {
bytes.push(b' ');
} else {
bytes.push(byte);
}
}
String::from_utf8(bytes).map_err(|_| "query parameter is not UTF-8".to_owned())
}
fn hex_value(byte: u8) -> Result<u8, String> {
match byte {
b'0'..=b'9' => Ok(byte - b'0'),
b'a'..=b'f' => Ok(byte - b'a' + 10),
b'A'..=b'F' => Ok(byte - b'A' + 10),
_ => Err("invalid percent encoding".to_owned()),
}
}
fn download_name(relative: &str, fallback: &str) -> String {
relative
.rsplit('/')
.find(|part| !part.is_empty())
.map(sanitize_download_component)
.filter(|value| !value.is_empty())
.unwrap_or_else(|| sanitize_download_component(fallback))
}
fn sanitize_download_component(raw: &str) -> String {
let sanitized: String = raw
.chars()
.map(|ch| match ch {
'/' | '\\' | '"' | '\r' | '\n' | '\t' => '_',
ch if ch.is_control() => '_',
ch => ch,
})
.collect();
let sanitized = sanitized.trim_matches('.').trim().to_owned();
if sanitized.is_empty() {
"project".to_owned()
} else {
sanitized
}
}
async fn list_devices_response(
state: Arc<ServerState>,
device: &AuthenticatedDevice,
origin: Option<&str>,
) -> Response<ResponseBody> {
match state
.app
.list_devices
.execute(ListDevicesInput {
current_device_id: Some(device.device_id),
})
.await
{
Ok(output) => {
let devices: Vec<_> = output
.devices
.into_iter()
.map(|device| DeviceDto {
device_id: device.device_id.to_string(),
name: device.name,
paired_at_ms: device.paired_at_ms,
last_seen_at_ms: device.last_seen_at_ms,
is_current_device: device.is_current_device,
})
.collect();
json_response(StatusCode::OK, &json!({ "devices": devices }), origin)
}
Err(err) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
err.code(),
err.to_string(),
origin,
),
}
}
async fn rename_device_response(
body: Bytes,
state: Arc<ServerState>,
device_id: DeviceId,
origin: Option<&str>,
) -> Response<ResponseBody> {
let request = match serde_json::from_slice::<RenameDeviceRequest>(&body) {
Ok(request) => request,
Err(err) => {
return error_response(
StatusCode::BAD_REQUEST,
"INVALID",
format!("invalid rename request: {err}"),
origin,
);
}
};
match state
.app
.rename_device
.execute(RenameDeviceInput {
device_id,
name: request.name,
})
.await
{
Ok(()) => json_response(StatusCode::OK, &json!({ "renamed": true }), origin),
Err(err @ application::AppError::Invalid(_)) => {
error_response(StatusCode::BAD_REQUEST, err.code(), err.to_string(), origin)
}
Err(err @ application::AppError::NotFound(_)) => {
error_response(StatusCode::NOT_FOUND, err.code(), err.to_string(), origin)
}
Err(err) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
err.code(),
err.to_string(),
origin,
),
}
}
async fn revoke_device_response(
state: Arc<ServerState>,
device_id: DeviceId,
origin: Option<&str>,
) -> Response<ResponseBody> {
match state
.app
.revoke_device
.execute(RevokeDeviceInput { device_id })
.await
{
Ok(()) => json_response(StatusCode::OK, &json!({ "revoked": true }), origin),
Err(err @ application::AppError::NotFound(_)) => {
error_response(StatusCode::NOT_FOUND, err.code(), err.to_string(), origin)
}
Err(err) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
err.code(),
err.to_string(),
origin,
),
}
}
async fn revoke_all_devices_response(
state: Arc<ServerState>,
origin: Option<&str>,
) -> Response<ResponseBody> {
match state.app.revoke_all_devices.execute().await {
Ok(()) => json_response(StatusCode::OK, &json!({ "revoked": true }), origin),
Err(err) => error_response(
StatusCode::INTERNAL_SERVER_ERROR,
err.code(),
err.to_string(),
origin,
),
}
}
async fn pair(
body: Bytes,
state: Arc<ServerState>,
origin: Option<&str>,
) -> Response<ResponseBody> {
let request = match serde_json::from_slice::<PairRequest>(&body) {
Ok(request) => request,
Err(err) => {
return error_response(
StatusCode::BAD_REQUEST,
"INVALID",
format!("invalid pairing request: {err}"),
origin,
);
}
};
let limit_key = RateLimitKey {
origin: origin.unwrap_or("<missing>").to_owned(),
route: "/api/pair".to_owned(),
};
match state
.app
.pair_attempt_limiter
.check(limit_key.clone())
.await
{
Ok(PairAttemptDecision::Allowed) => {}
Ok(PairAttemptDecision::RateLimited) => {
state.log_security(SecurityLogEvent::PairingFailed {
origin: origin.map(str::to_owned),
reason: "rate_limited",
});
return error_response(
StatusCode::TOO_MANY_REQUESTS,
"rate_limited",
"too many pairing attempts",
origin,
);
}
Err(_) => {
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"INTERNAL",
"pairing limiter failed",
origin,
);
}
}
let device_name = match DeviceName::new(request.name) {
Ok(name) => name,
Err(err) => {
state.log_security(SecurityLogEvent::PairingFailed {
origin: origin.map(str::to_owned),
reason: "invalid_name",
});
return error_response(
StatusCode::BAD_REQUEST,
"invalid_name",
err.to_string(),
origin,
);
}
};
if state.consume_pairing_code(&request.code) != PairingCodeConsumeResult::Valid {
let _ = state
.app
.pair_attempt_limiter
.record_failure(limit_key)
.await;
state.log_security(SecurityLogEvent::PairingFailed {
origin: origin.map(str::to_owned),
reason: "invalid_or_expired",
});
return error_response(
StatusCode::FORBIDDEN,
"invalid_or_expired",
"invalid pairing code",
origin,
);
}
let token = new_session_token();
let token_hash = SessionTokenHash::from_token_bytes(&token.bytes);
let pair_result = state
.app
.pair_device
.execute(PairDeviceInput {
name: device_name.as_str().to_owned(),
session_token_hash: token_hash,
})
.await;
if pair_result.is_err() {
state.log_security(SecurityLogEvent::PairingFailed {
origin: origin.map(str::to_owned),
reason: "pair_device_failed",
});
return error_response(
StatusCode::INTERNAL_SERVER_ERROR,
"INTERNAL",
"failed to pair device",
origin,
);
}
state.log_security(SecurityLogEvent::PairingSucceeded {
origin: origin.map(str::to_owned),
});
let mut response = json_response(StatusCode::OK, &json!({ "paired": true }), origin);
refresh_session_cookie(&mut response, &state, &token.value);
response
}
fn logout_response(state: &ServerState, origin: Option<&str>) -> Response<ResponseBody> {
let mut response = json_response(StatusCode::OK, &json!({ "revoked": true }), origin);
let secure = if state.config.secure_cookie() {
"; Secure"
} else {
""
};
let cookie = format!("{SESSION_COOKIE}=; Path=/; HttpOnly{secure}; SameSite=Strict; Max-Age=0");
response.headers_mut().insert(
SET_COOKIE,
HeaderValue::from_str(&cookie).expect("session clearing cookie is header-safe"),
);
response
}
fn refresh_session_cookie(response: &mut Response<ResponseBody>, state: &ServerState, token: &str) {
let cookie = Cookie::build((SESSION_COOKIE, token.to_owned()))
.path("/")
.http_only(true)
.secure(state.config.secure_cookie())
.same_site(SameSite::Strict)
.max_age(cookie::time::Duration::seconds(
SESSION_COOKIE_MAX_AGE_SECONDS,
))
.build();
response.headers_mut().insert(
SET_COOKIE,
HeaderValue::from_str(&cookie.to_string()).expect("session cookie is header-safe"),
);
}
#[derive(Debug, Clone)]
struct SessionToken {
value: String,
bytes: [u8; 32],
}
#[derive(Deserialize)]
struct InvokeRequest {
command: String,
#[serde(default)]
args: Value,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct LoadLayoutInvokeArgs {
project_id: String,
#[serde(default)]
layout_id: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct MutateLayoutInvokeArgs {
project_id: String,
#[serde(default)]
layout_id: Option<String>,
operation: LayoutOperationDto,
}
async fn invoke(
body: Bytes,
state: Arc<ServerState>,
origin: Option<&str>,
) -> Response<ResponseBody> {
let request = match serde_json::from_slice::<InvokeRequest>(&body) {
Ok(request) => request,
Err(err) => {
return error_response(
StatusCode::BAD_REQUEST,
"INVALID",
format!("invalid invoke request: {err}"),
origin,
);
}
};
let result = match request.command.as_str() {
"health" => invoke_health(&request.args, &state.app),
"list_projects" => invoke_list_projects(&state.app).await,
"open_project" => invoke_open_project(&request.args, &state.app).await,
"remove_project" => invoke_remove_project(&request.args, &state.app).await,
"read_open_project_restore_state" => {
invoke_read_open_project_restore_state(&state.app).await
}
"restore_open_projects" => invoke_restore_open_projects(&state.app).await,
"retry_open_project_restore" => {
invoke_retry_open_project_restore(&request.args, &state.app).await
}
"remove_project_from_restore" => {
invoke_remove_project_from_restore(&request.args, &state.app).await
}
"save_open_project_restore_order" => {
invoke_save_open_project_restore_order(&request.args, &state.app).await
}
"load_layout" => invoke_load_layout(&request.args, &state.app).await,
"mutate_layout" => invoke_mutate_layout(&request.args, &state.app).await,
"list_layouts" => invoke_list_layouts(&request.args, &state.app).await,
"create_layout" => invoke_create_layout(&request.args, &state.app).await,
"rename_layout" => invoke_rename_layout(&request.args, &state.app).await,
"delete_layout" => invoke_delete_layout(&request.args, &state.app).await,
"set_active_layout" => invoke_set_active_layout(&request.args, &state.app).await,
"read_project_context" => invoke_read_project_context(&request.args, &state.app).await,
"update_project_context" => invoke_update_project_context(&request.args, &state.app).await,
"read_user_settings" => invoke_read_user_settings(&state.app).await,
"update_user_settings" => invoke_update_user_settings(&request.args, &state.app).await,
"read_project_settings" => invoke_read_project_settings(&request.args, &state.app).await,
"update_project_settings" => {
invoke_update_project_settings(&request.args, &state.app).await
}
"first_run_state" => invoke_first_run_state(&state.app).await,
"reference_profiles" => invoke_reference_profiles(&state.app).await,
"detect_profiles" => invoke_detect_profiles(&request.args, &state.app).await,
"list_profiles" => invoke_list_profiles(&state.app).await,
"save_profile" => invoke_save_profile(&request.args, &state.app).await,
"list_opencode_providers" => invoke_list_opencode_providers(&state.app),
"list_claude_models" => invoke_list_claude_models(&state.app).await,
"list_codex_models" => invoke_list_codex_models(&state.app).await,
"save_opencode_provider_profile" => {
invoke_save_opencode_provider_profile(&request.args, &state.app).await
}
"delete_profile" => invoke_delete_profile(&request.args, &state.app).await,
"configure_profiles" => invoke_configure_profiles(&request.args, &state.app).await,
"clone_opencode_profile_from_seed" => {
invoke_clone_opencode_profile_from_seed(&request.args, &state.app).await
}
"list_agents" => invoke_list_agents(&request.args, &state.app).await,
"list_live_agents" => invoke_list_live_agents(&request.args, &state.app),
"list_resumable_agents" => invoke_list_resumable_agents(&request.args, &state.app).await,
"attach_live_agent" => invoke_attach_live_agent(&request.args, &state.app).await,
"stop_live_agent" => invoke_stop_live_agent(&request.args, &state.app).await,
"restart_cell_agent_runtime" => {
invoke_restart_cell_agent_runtime(&request.args, &state.app).await
}
"create_agent" => invoke_create_agent(&request.args, &state.app).await,
"change_agent_profile" => invoke_change_agent_profile(&request.args, &state.app).await,
"read_agent_context" => invoke_read_agent_context(&request.args, &state.app).await,
"update_agent_context" => invoke_update_agent_context(&request.args, &state.app).await,
"delete_agent" => invoke_delete_agent(&request.args, &state.app).await,
"inspect_conversation" => invoke_inspect_conversation(&request.args, &state.app).await,
"list_templates" => invoke_list_templates(&state.app).await,
"create_template" => invoke_create_template(&request.args, &state.app).await,
"update_template" => invoke_update_template(&request.args, &state.app).await,
"delete_template" => invoke_delete_template(&request.args, &state.app).await,
"create_agent_from_template" => {
invoke_create_agent_from_template(&request.args, &state.app).await
}
"detect_agent_drift" => invoke_detect_agent_drift(&request.args, &state.app).await,
"sync_agent_with_template" => {
invoke_sync_agent_with_template(&request.args, &state.app).await
}
"list_skills" => invoke_list_skills(&request.args, &state.app).await,
"create_skill" => invoke_create_skill(&request.args, &state.app).await,
"update_skill" => invoke_update_skill(&request.args, &state.app).await,
"delete_skill" => invoke_delete_skill(&request.args, &state.app).await,
"assign_skill_to_agent" => invoke_assign_skill_to_agent(&request.args, &state.app).await,
"unassign_skill_from_agent" => {
invoke_unassign_skill_from_agent(&request.args, &state.app).await
}
"get_project_permissions" => {
invoke_get_project_permissions(&request.args, &state.app).await
}
"update_project_permissions" => {
invoke_update_project_permissions(&request.args, &state.app).await
}
"update_agent_permissions" => {
invoke_update_agent_permissions(&request.args, &state.app).await
}
"update_agent_effort" => invoke_update_agent_effort(&request.args, &state.app).await,
"update_agent_contactable" => {
invoke_update_agent_contactable(&request.args, &state.app).await
}
"resolve_agent_permissions" => {
invoke_resolve_agent_permissions(&request.args, &state.app).await
}
"get_project_system_permissions" => {
invoke_get_project_system_permissions(&request.args, &state.app).await
}
"update_project_system_permissions" => {
invoke_update_project_system_permissions(&request.args, &state.app).await
}
"update_agent_system_permissions" => {
invoke_update_agent_system_permissions(&request.args, &state.app).await
}
"resolve_agent_system_permissions" => {
invoke_resolve_agent_system_permissions(&request.args, &state.app).await
}
"get_mcp_tool_permissions" => {
invoke_get_mcp_tool_permissions(&request.args, &state.app).await
}
"update_project_mcp_tool_permissions" => {
invoke_update_project_mcp_tool_permissions(&request.args, &state.app).await
}
"update_agent_mcp_tool_permissions" => {
invoke_update_agent_mcp_tool_permissions(&request.args, &state.app).await
}
"set_agent_mcp_tool_allowed" => {
invoke_set_agent_mcp_tool_allowed(&request.args, &state.app).await
}
"list_memories" => invoke_list_memories(&request.args, &state.app).await,
"get_memory" => invoke_get_memory(&request.args, &state.app).await,
"create_memory" => invoke_create_memory(&request.args, &state.app).await,
"update_memory" => invoke_update_memory(&request.args, &state.app).await,
"delete_memory" => invoke_delete_memory(&request.args, &state.app).await,
"read_memory_index" => invoke_read_memory_index(&request.args, &state.app).await,
"resolve_memory_links" => invoke_resolve_memory_links(&request.args, &state.app).await,
"recall_memory" => invoke_recall_memory(&request.args, &state.app).await,
"list_embedder_profiles" => invoke_list_embedder_profiles(&state.app).await,
"save_embedder_profile" => invoke_save_embedder_profile(&request.args, &state.app).await,
"delete_embedder_profile" => {
invoke_delete_embedder_profile(&request.args, &state.app).await
}
"describe_embedder_engines" => invoke_describe_embedder_engines(&state.app).await,
"git_status" => invoke_git_status(&request.args, &state.app).await,
"git_stage" => invoke_git_stage(&request.args, &state.app).await,
"git_unstage" => invoke_git_unstage(&request.args, &state.app).await,
"git_commit" => invoke_git_commit(&request.args, &state.app).await,
"git_branches" => invoke_git_branches(&request.args, &state.app).await,
"git_checkout" => invoke_git_checkout(&request.args, &state.app).await,
"git_log" => invoke_git_log(&request.args, &state.app).await,
"git_init" => invoke_git_init(&request.args, &state.app).await,
"git_graph" => invoke_git_graph(&request.args, &state.app).await,
"read_conversation_page" => invoke_read_conversation_page(&request.args, &state.app).await,
"list_custom_agent_conversations" => {
invoke_list_custom_agent_conversations(&request.args, &state.app).await
}
"get_project_work_state" => invoke_get_project_work_state(&request.args, &state.app).await,
"list_background_tasks" => invoke_list_background_tasks(&request.args, &state.app).await,
"cancel_background_task" => invoke_cancel_background_task(&request.args, &state.app).await,
"retry_background_task" => invoke_retry_background_task(&request.args, &state.app).await,
"list_scheduled_project_tasks" => {
invoke_list_scheduled_project_tasks(&request.args, &state.app).await
}
"create_scheduled_project_task" => {
invoke_create_scheduled_project_task(&request.args, &state.app).await
}
"update_scheduled_project_task" => {
invoke_update_scheduled_project_task(&request.args, &state.app).await
}
"delete_scheduled_project_task" => {
invoke_delete_scheduled_project_task(&request.args, &state.app).await
}
"run_scheduled_project_task_now" => {
invoke_run_scheduled_project_task_now(&request.args, &state.app).await
}
"set_scheduled_project_task_enabled" => {
invoke_set_scheduled_project_task_enabled(&request.args, &state.app).await
}
"ticket_create" => invoke_ticket_create(&request.args, &state.app).await,
"ticket_read" => invoke_ticket_read(&request.args, &state.app).await,
"ticket_list" => invoke_ticket_list(&request.args, &state.app).await,
"ticket_update" => invoke_ticket_update(&request.args, &state.app).await,
"ticket_attachment_add" => invoke_ticket_attachment_add(&request.args, &state.app).await,
"ticket_attachment_read" => invoke_ticket_attachment_read(&request.args, &state.app).await,
"ticket_attachment_mark_summarized" => {
invoke_ticket_attachment_mark_summarized(&request.args, &state.app).await
}
"ticket_delete" => invoke_ticket_delete(&request.args, &state.app).await,
"ticket_read_carnet" => invoke_ticket_read_carnet(&request.args, &state.app).await,
"ticket_update_carnet" => invoke_ticket_update_carnet(&request.args, &state.app).await,
"ticket_link" => invoke_ticket_link(&request.args, &state.app).await,
"ticket_unlink" => invoke_ticket_unlink(&request.args, &state.app).await,
"ticket_assign" => invoke_ticket_assign(&request.args, &state.app).await,
"ticket_assign_sprint" => invoke_ticket_assign_sprint(&request.args, &state.app).await,
"ticket_unassign_sprint" => invoke_ticket_unassign_sprint(&request.args, &state.app).await,
"sprint_create" => invoke_sprint_create(&request.args, &state.app).await,
"sprint_list" => invoke_sprint_list(&request.args, &state.app).await,
"sprint_rename" => invoke_sprint_rename(&request.args, &state.app).await,
"sprint_reorder" => invoke_sprint_reorder(&request.args, &state.app).await,
"sprint_delete" => invoke_sprint_delete(&request.args, &state.app).await,
_ => Err(ErrorDto {
code: "UNKNOWN_COMMAND".to_owned(),
message: format!("unknown command: {}", request.command),
}),
};
match result {
Ok(value) => json_response(StatusCode::OK, &value, origin),
Err(error) => {
let error = normalize_invoke_error(error);
error_dto_response(status_for_error(&error), error, origin)
}
}
}
fn invoke_health(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = optional_request::<HealthRequestDto>(args)?;
let output = state
.health
.execute(request.unwrap_or_default().into())
.map(HealthResponseDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_list_projects(state: &BackendCore) -> Result<Value, ErrorDto> {
let output = state
.list_projects
.execute()
.await
.map(ProjectListDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_open_project(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let project_id = args
.get("projectId")
.and_then(Value::as_str)
.ok_or_else(|| ErrorDto {
code: "INVALID".to_owned(),
message: "open_project requires args.projectId".to_owned(),
})?;
let project = open_project_for_web(project_id, state).await?;
serde_json::to_value(project).map_err(serialization_error)
}
async fn invoke_remove_project(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<RemoveProjectRequestDto>("remove_project", args)?;
let output = state
.remove_project_and_deactivate(parse_project_id(&request.project_id)?)
.await
.map(RemoveProjectResponseDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn open_project_for_web(
project_id: &str,
state: &BackendCore,
) -> Result<ProjectDto, ErrorDto> {
let id = parse_project_id(project_id)?;
let output = state
.open_project_and_activate(id)
.await
.map_err(ErrorDto::from)?;
Ok(ProjectDto::from(output))
}
async fn invoke_read_open_project_restore_state(state: &BackendCore) -> Result<Value, ErrorDto> {
let output = state
.read_open_project_restore_state
.execute()
.await
.map(OpenProjectRestoreStateDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_restore_open_projects(state: &BackendCore) -> Result<Value, ErrorDto> {
let output = state
.restore_open_projects_and_activate()
.await
.map(RestoreOpenProjectsOutputDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_retry_open_project_restore(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = optional_request::<RetryOpenProjectRestoreRequestDto>(args)?.unwrap_or_default();
let project_id = request
.project_id
.as_deref()
.map(parse_project_id)
.transpose()?;
let output = state
.retry_open_project_restore_and_activate(project_id)
.await
.map(RestoreOpenProjectsOutputDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_remove_project_from_restore(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = required_request::<RemoveOpenProjectRestoreEntryRequestDto>(
"remove_project_from_restore",
args,
)?;
let project_id = parse_project_id(&request.project_id)?;
let output = state
.remove_project_from_restore
.execute(project_id)
.await
.map(OpenProjectRestoreStateDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_save_open_project_restore_order(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = required_request::<SaveOpenProjectRestoreOrderRequestDto>(
"save_open_project_restore_order",
args,
)?;
let ordered_project_ids = request
.ordered_project_ids
.iter()
.map(|id| parse_project_id(id))
.collect::<Result<Vec<_>, _>>()?;
let output = state
.save_open_project_restore_order
.execute(ordered_project_ids)
.await
.map(OpenProjectRestoreStateDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_load_layout(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<LoadLayoutInvokeArgs>("load_layout", args)?;
let project_id = parse_project_id(&request.project_id)?;
let layout_id = request
.layout_id
.as_deref()
.map(parse_layout_id)
.transpose()?;
let output = state
.load_layout
.execute(LoadLayoutInput {
project_id,
layout_id,
})
.await
.map(LayoutDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_mutate_layout(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<MutateLayoutInvokeArgs>("mutate_layout", args)?;
let project_id = parse_project_id(&request.project_id)?;
let layout_id = request
.layout_id
.as_deref()
.map(parse_layout_id)
.transpose()?;
let operation = request.operation.into_operation()?;
let output = state
.mutate_layout
.execute(MutateLayoutInput {
project_id,
layout_id,
operation,
})
.await
.map(LayoutDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_list_layouts(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let project_id = parse_project_id(string_arg(args, "projectId", "list_layouts")?)?;
let output = state
.list_layouts
.execute(ListLayoutsInput { project_id })
.await
.map(ListLayoutsDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_create_layout(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<CreateLayoutRequestDto>("create_layout", args)?;
let project_id = parse_project_id(&request.project_id)?;
let kind = request.parse_kind()?;
let output = state
.create_layout
.execute(CreateLayoutInput {
project_id,
name: request.name,
kind,
})
.await
.map(CreateLayoutResultDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_rename_layout(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<RenameLayoutRequestDto>("rename_layout", args)?;
let project_id = parse_project_id(&request.project_id)?;
let layout_id = parse_layout_id(&request.layout_id)?;
state
.rename_layout
.execute(RenameLayoutInput {
project_id,
layout_id,
name: request.name,
})
.await
.map_err(ErrorDto::from)?;
Ok(Value::Null)
}
async fn invoke_delete_layout(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<DeleteLayoutRequestDto>("delete_layout", args)?;
let project_id = parse_project_id(&request.project_id)?;
let layout_id = parse_layout_id(&request.layout_id)?;
let output = state
.delete_layout
.execute(DeleteLayoutInput {
project_id,
layout_id,
})
.await
.map(DeleteLayoutResultDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_set_active_layout(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<SetActiveLayoutRequestDto>("set_active_layout", args)?;
let project_id = parse_project_id(&request.project_id)?;
let layout_id = parse_layout_id(&request.layout_id)?;
let output = state
.set_active_layout
.execute(SetActiveLayoutInput {
project_id,
layout_id,
})
.await
.map(SetActiveLayoutResultDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_read_project_context(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let project = resolve_project_readonly(
string_arg(args, "projectId", "read_project_context")?,
state,
)
.await?;
let output = state
.read_project_context
.execute(ReadProjectContextInput { project })
.await
.map_err(ErrorDto::from)?
.content;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_update_project_context(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request =
required_request::<UpdateProjectContextRequestDto>("update_project_context", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
state
.update_project_context
.execute(UpdateProjectContextInput {
project,
content: request.content,
})
.await
.map_err(ErrorDto::from)?;
Ok(Value::Null)
}
async fn invoke_read_user_settings(state: &BackendCore) -> Result<Value, ErrorDto> {
let output: UserSettingsDto = state
.read_user_settings
.execute()
.await
.map_err(ErrorDto::from)?
.into();
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_update_user_settings(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<UserSettingsDto>("update_user_settings", args)?;
let output: UserSettingsDto = state
.update_user_settings
.execute(request.into())
.await
.map_err(ErrorDto::from)?
.into();
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_read_project_settings(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let project = resolve_project_readonly(
string_arg(args, "projectId", "read_project_settings")?,
state,
)
.await?;
let output: ProjectSettingsDto = state
.read_project_settings
.execute(&project)
.await
.map_err(ErrorDto::from)?
.into();
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_update_project_settings(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request =
required_request::<UpdateProjectSettingsRequestDto>("update_project_settings", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let theme_color = request
.theme_color
.map(|value| value.map(domain::ThemeColor::new).transpose())
.transpose()
.map_err(|message| ErrorDto::from(AppError::Invalid(message)))?;
let icon = request
.icon
.map(|value| value.map(domain::ProjectIconRef::try_from).transpose())
.transpose()
.map_err(|message| ErrorDto::from(AppError::Invalid(message)))?;
let output: ProjectSettingsDto = state
.update_project_settings
.execute(&project, theme_color, icon)
.await
.map_err(ErrorDto::from)?
.into();
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_first_run_state(state: &BackendCore) -> Result<Value, ErrorDto> {
let output = state
.first_run_state
.execute()
.await
.map(FirstRunStateDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_reference_profiles(state: &BackendCore) -> Result<Value, ErrorDto> {
let output = state
.reference_profiles
.execute()
.await
.map(ProfileListDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_detect_profiles(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<DetectProfilesRequestDto>("detect_profiles", args)?;
let output = state
.detect_profiles
.execute(request.into())
.await
.map(DetectProfilesResponseDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_list_profiles(state: &BackendCore) -> Result<Value, ErrorDto> {
let output = state
.list_profiles
.execute()
.await
.map(ProfileListDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_save_profile(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<SaveProfileRequestDto>("save_profile", args)?;
let output = state
.save_profile
.execute(request.into())
.await
.map(ProfileDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
fn invoke_list_opencode_providers(state: &BackendCore) -> Result<Value, ErrorDto> {
let output: OpenCodeProviderListDto = state.list_opencode_providers.execute().into();
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_list_claude_models(state: &BackendCore) -> Result<Value, ErrorDto> {
let output: ProfileModelCatalogDto = state.list_claude_models.execute().await.into();
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_list_codex_models(state: &BackendCore) -> Result<Value, ErrorDto> {
let output: ProfileModelCatalogDto = state.list_codex_models.execute().await.into();
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_save_opencode_provider_profile(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = required_request::<SaveOpenCodeProviderProfileRequestDto>(
"save_opencode_provider_profile",
args,
)?;
let output = state
.save_opencode_provider_profile
.execute(request.into())
.await
.map(ProfileDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_delete_profile(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let profile_id = string_arg(args, "profileId", "delete_profile")?;
let input = backend::dto::parse_delete_profile(profile_id)?;
state
.delete_profile
.execute(input)
.await
.map_err(ErrorDto::from)?;
Ok(Value::Null)
}
async fn invoke_configure_profiles(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<ConfigureProfilesRequestDto>("configure_profiles", args)?;
let output = state
.configure_profiles
.execute(request.into())
.await
.map(ProfileListDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_clone_opencode_profile_from_seed(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = optional_request::<CloneOpenCodeProfileFromSeedRequestDto>(args)?.unwrap_or(
CloneOpenCodeProfileFromSeedRequestDto {
name: None,
opencode: None,
},
);
let output = state
.clone_opencode_profile_from_seed
.execute(request.into())
.await
.map(ProfileDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_list_agents(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let project =
resolve_project_readonly(string_arg(args, "projectId", "list_agents")?, state).await?;
let output = state
.list_agents
.execute(ListAgentsInput { project })
.await
.map(AgentListDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
fn invoke_list_live_agents(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let _ = parse_project_id(string_arg(args, "projectId", "list_live_agents")?)?;
let live = LiveSessions::new(
Arc::clone(&state.terminal_sessions),
Arc::clone(&state.structured_sessions),
);
let output = LiveAgentListDto::from_snapshots(live.live_agent_snapshots());
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_list_resumable_agents(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let project = resolve_project_readonly(
string_arg(args, "projectId", "list_resumable_agents")?,
state,
)
.await?;
let output = state
.list_resumable_agents
.execute(ListResumableAgentsInput { project })
.await
.map(ResumableAgentListDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_attach_live_agent(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<AttachLiveAgentRequestDto>("attach_live_agent", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let output = state
.attach_live_agent
.execute(AttachLiveAgentInput {
project,
agent_id: parse_agent_id(&request.agent_id)?,
node_id: parse_node_id(&request.node_id)?,
})
.map(AttachLiveAgentResponseDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_stop_live_agent(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<StopLiveAgentRequestDto>("stop_live_agent", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let output = state
.stop_live_agent
.execute(StopLiveAgentInput {
project,
agent_id: parse_agent_id(&request.agent_id)?,
reason: request.reason,
})
.await
.map(StopLiveAgentResponseDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_restart_cell_agent_runtime(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request =
required_request::<RestartCellAgentRuntimeRequestDto>("restart_cell_agent_runtime", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let output = state
.restart_cell_agent_runtime
.execute(application::RestartCellAgentRuntimeInput {
project,
node_id: parse_node_id(&request.node_id)?,
agent_id: parse_agent_id(&request.agent_id)?,
presentation: request.presentation.into(),
layout_id: request
.layout_id
.as_deref()
.map(parse_layout_id)
.transpose()?,
})
.map(RestartCellAgentRuntimeResponseDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_create_agent(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<CreateAgentRequestDto>("create_agent", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let output = state
.create_agent
.execute(CreateAgentInput {
project,
name: request.name,
profile_id: parse_profile_id(&request.profile_id)?,
initial_content: request.initial_content,
})
.await
.map(AgentDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_change_agent_profile(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<ChangeAgentProfileRequestDto>("change_agent_profile", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let output = state
.change_agent_profile
.execute(ChangeAgentProfileInput {
project,
agent_id: parse_agent_id(&request.agent_id)?,
profile_id: parse_profile_id(&request.profile_id)?,
rows: request.rows,
cols: request.cols,
})
.await
.map(ChangeAgentProfileDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_read_agent_context(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let project =
resolve_project_readonly(string_arg(args, "projectId", "read_agent_context")?, state)
.await?;
let agent_id = parse_agent_id(string_arg(args, "agentId", "read_agent_context")?)?;
let output = state
.read_agent_context
.execute(ReadAgentContextInput { project, agent_id })
.await
.map(ReadAgentContextResponseDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_update_agent_context(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<UpdateAgentContextRequestDto>("update_agent_context", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
state
.update_agent_context
.execute(UpdateAgentContextInput {
project,
agent_id: parse_agent_id(&request.agent_id)?,
content: request.content,
})
.await
.map_err(ErrorDto::from)?;
Ok(Value::Null)
}
async fn invoke_delete_agent(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let project =
resolve_project_readonly(string_arg(args, "projectId", "delete_agent")?, state).await?;
let agent_id = parse_agent_id(string_arg(args, "agentId", "delete_agent")?)?;
state
.delete_agent
.execute(DeleteAgentInput { project, agent_id })
.await
.map_err(ErrorDto::from)?;
Ok(Value::Null)
}
async fn invoke_inspect_conversation(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<InspectConversationRequestDto>("inspect_conversation", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let output = state
.inspect_conversation
.execute(InspectConversationInput {
project,
agent_id: parse_agent_id(&request.agent_id)?,
conversation_id: request.conversation_id,
})
.await
.map(ConversationDetailsDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_get_project_work_state(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let project_id = args
.get("projectId")
.and_then(Value::as_str)
.ok_or_else(|| ErrorDto {
code: "INVALID".to_owned(),
message: "get_project_work_state requires args.projectId".to_owned(),
})?;
let project = resolve_project_readonly(project_id, state).await?;
// The requested project can be visible in navigation without being the
// currently focused workspace. Reconcile that exact project's persisted
// state before returning its runtime projection.
let _ = state
.reconcile_live_state
.execute(ReconcileLiveStateInput {
project_id: project.id,
})
.await;
let output = state
.get_project_work_state
.execute(GetProjectWorkStateInput { project })
.await
.map(ProjectWorkStateDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_list_background_tasks(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let project_id = args
.get("projectId")
.and_then(Value::as_str)
.ok_or_else(|| ErrorDto {
code: "INVALID".to_owned(),
message: "list_background_tasks requires args.projectId".to_owned(),
})
.and_then(parse_project_id)?;
let agent_id = args
.get("agentId")
.and_then(Value::as_str)
.map(parse_agent_id)
.transpose()?;
let mut by_id = std::collections::HashMap::<domain::TaskId, domain::BackgroundTask>::new();
if let Some(agent_id) = agent_id {
for task in state
.background_task_store
.list_open_for_agent(agent_id)
.await
.map_err(background_error)?
{
by_id.insert(task.id, task);
}
}
for task in state
.background_task_store
.list_undelivered_completions()
.await
.map_err(background_error)?
{
by_id.entry(task.id).or_insert(task);
}
let mut tasks = by_id
.into_values()
.filter(|task| task.project_id == project_id)
.filter(|task| agent_id.map_or(true, |agent_id| task.owner_agent_id == agent_id))
.collect::<Vec<_>>();
tasks.sort_by_key(|task| (task.created_at_ms, task.id));
let dto = tasks
.into_iter()
.map(BackgroundTaskDto::from)
.collect::<Vec<_>>();
serde_json::to_value(dto).map_err(serialization_error)
}
async fn invoke_cancel_background_task(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let task_id = task_id_arg(args, "cancel_background_task")?;
verify_background_task_project(args, state, task_id).await?;
let output = state
.cancel_background_task
.execute(task_id)
.await
.map_err(ErrorDto::from)?;
serde_json::to_value(BackgroundTaskDto::from(output.task)).map_err(serialization_error)
}
async fn invoke_retry_background_task(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let task_id = task_id_arg(args, "retry_background_task")?;
verify_background_task_project(args, state, task_id).await?;
let output = state
.retry_background_task
.execute(task_id)
.await
.map_err(ErrorDto::from)?;
serde_json::to_value(BackgroundTaskDto::from(output.task)).map_err(serialization_error)
}
fn required_string_arg<'a>(
args: &'a Value,
field: &str,
command: &str,
) -> Result<&'a str, ErrorDto> {
args.get(field)
.and_then(Value::as_str)
.ok_or_else(|| ErrorDto {
code: "INVALID".to_owned(),
message: format!("{command} requires args.{field}"),
})
}
async fn invoke_list_scheduled_project_tasks(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let project_id = required_string_arg(args, "projectId", "list_scheduled_project_tasks")?;
let tasks = state
.scheduled_project_tasks
.list(parse_project_id(project_id)?)
.await
.map_err(ErrorDto::from)?;
serde_json::to_value(tasks).map_err(serialization_error)
}
async fn invoke_create_scheduled_project_task(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = required_request::<CreateScheduledProjectTaskRequestDto>(
"create_scheduled_project_task",
args,
)?;
let task = state
.scheduled_project_tasks
.create(application::CreateScheduledProjectTaskInput {
project_id: parse_project_id(&request.project_id)?,
name: request.name,
description: request.description,
command: request.command,
schedule: request.schedule,
timezone: request.timezone,
enabled: request.enabled,
timeout_ms: request.timeout_ms,
actor: domain::ScheduledTaskActor::Human,
})
.await
.map_err(ErrorDto::from)?;
serde_json::to_value(task).map_err(serialization_error)
}
async fn invoke_update_scheduled_project_task(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = required_request::<UpdateScheduledProjectTaskRequestDto>(
"update_scheduled_project_task",
args,
)?;
let task = state
.scheduled_project_tasks
.update(application::UpdateScheduledProjectTaskInput {
project_id: parse_project_id(&request.project_id)?,
scheduled_task_id: parse_scheduled_task_id(&request.scheduled_task_id)?,
name: request.name,
description: request.description,
command: request.command,
schedule: request.schedule,
timezone: request.timezone,
enabled: request.enabled,
timeout_ms: request.timeout_ms,
audit_revision: request.audit_revision,
actor: domain::ScheduledTaskActor::Human,
})
.await
.map_err(ErrorDto::from)?;
serde_json::to_value(task).map_err(serialization_error)
}
fn scheduled_identity(
args: &Value,
command: &str,
) -> Result<(domain::ProjectId, domain::ScheduledTaskId), ErrorDto> {
Ok((
parse_project_id(required_string_arg(args, "projectId", command)?)?,
parse_scheduled_task_id(required_string_arg(args, "scheduledTaskId", command)?)?,
))
}
async fn invoke_delete_scheduled_project_task(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let (project_id, id) = scheduled_identity(args, "delete_scheduled_project_task")?;
state
.scheduled_project_tasks
.delete(project_id, id, domain::ScheduledTaskActor::Human)
.await
.map_err(ErrorDto::from)?;
Ok(Value::Null)
}
async fn invoke_run_scheduled_project_task_now(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let (project_id, id) = scheduled_identity(args, "run_scheduled_project_task_now")?;
let task = state
.scheduled_project_tasks
.run_now(project_id, id, domain::ScheduledTaskActor::Human)
.await
.map_err(ErrorDto::from)?;
serde_json::to_value(task).map_err(serialization_error)
}
async fn invoke_set_scheduled_project_task_enabled(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let (project_id, id) = scheduled_identity(args, "set_scheduled_project_task_enabled")?;
let enabled = args
.get("enabled")
.and_then(Value::as_bool)
.ok_or_else(|| ErrorDto {
code: "INVALID".to_owned(),
message: "set_scheduled_project_task_enabled requires args.enabled".to_owned(),
})?;
let task = state
.scheduled_project_tasks
.set_enabled(project_id, id, enabled, domain::ScheduledTaskActor::Human)
.await
.map_err(ErrorDto::from)?;
serde_json::to_value(task).map_err(serialization_error)
}
async fn invoke_ticket_create(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<TicketCreateRequestDto>("ticket_create", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let issue = state
.create_issue
.execute(create_input(project, request, IssueActor::User)?)
.await
.map_err(ErrorDto::from)?
.issue;
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
}
async fn invoke_ticket_read(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<TicketReadRequestDto>("ticket_read", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let issue_ref = parse_ref_dto(&request.r#ref)?;
let issue = state
.read_issue
.execute(ReadIssueInput {
project: project.clone(),
issue_ref,
})
.await
.map_err(ErrorDto::from)?
.issue;
let fallback_carnet = issue.carnet.as_str().to_owned();
let carnet = if request.include_carnet.unwrap_or(false) {
Some(
read_ticket_carnet_body_best_effort(
&state.read_issue_carnet,
&project,
issue_ref,
fallback_carnet,
)
.await
.map_err(ErrorDto::from)?,
)
} else {
None
};
serde_json::to_value(TicketDto::from_issue(issue, carnet)).map_err(serialization_error)
}
async fn read_ticket_carnet_body_best_effort(
read_carnet: &application::ReadIssueCarnet,
project: &Project,
issue_ref: domain::IssueRef,
fallback_carnet: String,
) -> Result<String, AppError> {
match read_carnet
.execute(ReadIssueCarnetInput {
project: project.clone(),
issue_ref,
})
.await
{
Ok(out) => Ok(out.carnet.carnet.as_str().to_owned()),
Err(AppError::NotFound(_)) => Ok(fallback_carnet),
Err(err) => Err(err),
}
}
async fn invoke_ticket_list(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<TicketListRequestDto>("ticket_list", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let page = TicketListPageInput::from_request(request)?;
let mut rows = state
.list_issues
.execute(ListIssuesInput {
project,
filter: page.filter,
})
.await
.map_err(ErrorDto::from)?
.issues;
sort_ticket_rows(&mut rows, page.sort);
let dto = paginate(rows, page.limit, page.cursor, page.sort)?;
serde_json::to_value(dto).map_err(serialization_error)
}
async fn invoke_ticket_update(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<TicketUpdateRequestDto>("ticket_update", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let issue = state
.update_issue
.execute(update_input(project, request, IssueActor::User)?)
.await
.map_err(ErrorDto::from)?
.issue;
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
}
async fn invoke_ticket_attachment_add(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = required_request::<TicketAttachmentAddRequestDto>("ticket_attachment_add", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let issue = state
.add_issue_attachment
.execute(AddIssueAttachmentInput {
project,
issue_ref: parse_ref_dto(&request.r#ref)?,
path: request.path,
mime: request.mime,
expected_version: version_dto(request.expected_version)?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?
.issue;
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
}
async fn invoke_ticket_attachment_read(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request =
required_request::<TicketAttachmentReadRequestDto>("ticket_attachment_read", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let content = state
.read_issue_attachment
.execute(ReadIssueAttachmentInput {
project,
issue_ref: parse_ref_dto(&request.r#ref)?,
attachment_id: parse_attachment_id_dto(&request.attachment_id)?,
})
.await
.map_err(ErrorDto::from)?
.content;
serde_json::to_value(TicketAttachmentContentDto::from(content)).map_err(serialization_error)
}
async fn invoke_ticket_attachment_mark_summarized(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = required_request::<TicketAttachmentMarkSummarizedRequestDto>(
"ticket_attachment_mark_summarized",
args,
)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let issue = state
.mark_issue_attachment_summarized
.execute(MarkIssueAttachmentSummarizedInput {
project,
issue_ref: parse_ref_dto(&request.r#ref)?,
attachment_id: parse_attachment_id_dto(&request.attachment_id)?,
expected_version: version_dto(request.expected_version)?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?
.issue;
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
}
async fn invoke_ticket_delete(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<TicketDeleteRequestDto>("ticket_delete", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
state
.delete_issue
.execute(DeleteIssueInput {
project,
issue_ref: parse_ref_dto(&request.r#ref)?,
})
.await
.map_err(ErrorDto::from)?;
Ok(Value::Null)
}
async fn invoke_ticket_read_carnet(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<TicketReadRequestDto>("ticket_read_carnet", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let carnet = state
.read_issue_carnet
.execute(ReadIssueCarnetInput {
project,
issue_ref: parse_ref_dto(&request.r#ref)?,
})
.await
.map_err(ErrorDto::from)?
.carnet;
serde_json::to_value(TicketCarnetDto::from(carnet)).map_err(serialization_error)
}
async fn invoke_ticket_update_carnet(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<TicketUpdateCarnetRequestDto>("ticket_update_carnet", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let issue_ref = parse_ref_dto(&request.r#ref)?;
state
.update_issue_carnet
.execute(UpdateIssueCarnetInput {
project: project.clone(),
issue_ref,
expected_version: version_dto(request.expected_version)?,
carnet: request.carnet,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?;
let issue = state
.read_issue
.execute(ReadIssueInput { project, issue_ref })
.await
.map_err(ErrorDto::from)?
.issue;
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
}
async fn invoke_ticket_link(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<TicketLinkCommandRequestDto>("ticket_link", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let issue = state
.link_issues
.execute(LinkIssuesInput {
project,
issue_ref: parse_ref_dto(&request.r#ref)?,
target: parse_ref_dto(&request.target_ref)?,
kind: parse_link_kind_dto(&request.kind)?,
expected_version: version_dto(request.expected_version)?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?
.issue;
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
}
async fn invoke_ticket_unlink(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<TicketUnlinkCommandRequestDto>("ticket_unlink", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let issue = state
.unlink_issues
.execute(UnlinkIssuesInput {
project,
issue_ref: parse_ref_dto(&request.r#ref)?,
target: parse_ref_dto(&request.target_ref)?,
kind: request
.kind
.as_deref()
.map(parse_link_kind_dto)
.transpose()?,
expected_version: version_dto(request.expected_version)?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?
.issue;
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
}
async fn invoke_ticket_assign(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<TicketAssignRequestDto>("ticket_assign", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let issue = state
.assign_issue_agent
.execute(AssignIssueAgentInput {
project,
issue_ref: parse_ref_dto(&request.r#ref)?,
agent_id: parse_agent_id_dto(&request.agent_id)?,
assigned: request.assigned,
expected_version: version_dto(request.expected_version)?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?
.issue;
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
}
async fn invoke_ticket_assign_sprint(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<TicketSprintAssignRequestDto>("ticket_assign_sprint", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let issue = state
.assign_ticket_to_sprint
.execute(AssignTicketToSprintInput {
project,
issue_ref: parse_ref_dto(&request.r#ref)?,
sprint_id: parse_sprint_id_dto(&request.sprint_id)?,
expected_version: version_dto(request.expected_version)?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?
.issue;
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
}
async fn invoke_ticket_unassign_sprint(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request =
required_request::<TicketSprintUnassignRequestDto>("ticket_unassign_sprint", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let issue = state
.unassign_ticket_from_sprint
.execute(UnassignTicketFromSprintInput {
project,
issue_ref: parse_ref_dto(&request.r#ref)?,
expected_version: version_dto(request.expected_version)?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?
.issue;
serde_json::to_value(TicketDto::from_issue(issue, None)).map_err(serialization_error)
}
async fn invoke_sprint_create(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<SprintCreateRequestDto>("sprint_create", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let sprint = state
.create_sprint
.execute(CreateSprintInput {
project,
name: request.name,
status: request
.status
.as_deref()
.map(parse_sprint_status_dto)
.transpose()?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?
.sprint;
serde_json::to_value(SprintDto::from_sprint(sprint, 0)).map_err(serialization_error)
}
async fn invoke_sprint_list(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<SprintListRequestDto>("sprint_list", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let rows = state
.list_sprints
.execute(ListSprintsInput { project })
.await
.map_err(ErrorDto::from)?
.sprints;
serde_json::to_value(SprintListDto::from(rows)).map_err(serialization_error)
}
async fn invoke_sprint_rename(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<SprintRenameRequestDto>("sprint_rename", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let sprint = state
.rename_sprint
.execute(RenameSprintInput {
project,
sprint_id: parse_sprint_id_dto(&request.sprint_id)?,
expected_version: sprint_version_dto(request.expected_version)?,
name: request.name,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?
.sprint;
serde_json::to_value(SprintDto::from_sprint(sprint, 0)).map_err(serialization_error)
}
async fn invoke_sprint_reorder(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<SprintReorderRequestDto>("sprint_reorder", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
state
.reorder_sprints
.execute(ReorderSprintsInput {
project: project.clone(),
ordered_ids: request
.ordered_ids
.iter()
.map(|id| parse_sprint_id_dto(id))
.collect::<Result<Vec<_>, _>>()?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?;
let rows = state
.list_sprints
.execute(ListSprintsInput { project })
.await
.map_err(ErrorDto::from)?
.sprints;
serde_json::to_value(SprintListDto::from(rows)).map_err(serialization_error)
}
async fn invoke_sprint_delete(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<SprintDeleteRequestDto>("sprint_delete", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
state
.delete_sprint
.execute(DeleteSprintInput {
project,
sprint_id: parse_sprint_id_dto(&request.sprint_id)?,
actor: IssueActor::User,
})
.await
.map_err(ErrorDto::from)?;
Ok(Value::Null)
}
async fn invoke_list_templates(state: &BackendCore) -> Result<Value, ErrorDto> {
let output = state
.list_templates
.execute()
.await
.map(TemplateListDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_create_template(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<CreateTemplateRequestDto>("create_template", args)?;
let output = state
.create_template
.execute(request.into_input()?)
.await
.map(TemplateDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_update_template(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<UpdateTemplateRequestDto>("update_template", args)?;
let output = state
.update_template
.execute(request.into_input()?)
.await
.map(TemplateDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_delete_template(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let template_id = parse_template_id(string_arg(args, "templateId", "delete_template")?)?;
state
.delete_template
.execute(DeleteTemplateInput { template_id })
.await
.map_err(ErrorDto::from)?;
Ok(Value::Null)
}
async fn invoke_create_agent_from_template(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request =
required_request::<CreateAgentFromTemplateRequestDto>("create_agent_from_template", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let output = state
.create_agent_from_template
.execute(request.into_input(project)?)
.await
.map(|out| AgentDto::from_agent(out.agent))
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_detect_agent_drift(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let project =
resolve_project_readonly(string_arg(args, "projectId", "detect_agent_drift")?, state)
.await?;
let output = state
.detect_agent_drift
.execute(DetectAgentDriftInput { project })
.await
.map(AgentDriftListDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_sync_agent_with_template(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request =
required_request::<SyncAgentWithTemplateRequestDto>("sync_agent_with_template", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let output = state
.sync_agent_with_template
.execute(SyncAgentWithTemplateInput {
project,
agent_id: parse_agent_id(&request.agent_id)?,
})
.await
.map(SyncResultDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_list_skills(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let project =
resolve_project_readonly(string_arg(args, "projectId", "list_skills")?, state).await?;
let scope = skill_scope_arg(args, "list_skills")?;
let output = state
.list_skills
.execute(ListSkillsInput {
scope,
project_root: project.root,
})
.await
.map(SkillListDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_create_skill(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<CreateSkillRequestDto>("create_skill", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let output = state
.create_skill
.execute(CreateSkillInput {
name: request.name,
description: request.description,
content: request.content,
scope: request.scope,
kind: request.kind,
project_root: project.root,
})
.await
.map(SkillDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_update_skill(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<UpdateSkillRequestDto>("update_skill", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let output = state
.update_skill
.execute(UpdateSkillInput {
scope: request.scope,
skill_id: parse_skill_id(&request.skill_id)?,
content: request.content,
project_root: project.root,
})
.await
.map(SkillDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_delete_skill(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let project =
resolve_project_readonly(string_arg(args, "projectId", "delete_skill")?, state).await?;
let scope = skill_scope_arg(args, "delete_skill")?;
let skill_id = parse_skill_id(string_arg(args, "skillId", "delete_skill")?)?;
state
.delete_skill
.execute(DeleteSkillInput {
scope,
skill_id,
project_root: project.root,
})
.await
.map_err(ErrorDto::from)?;
Ok(Value::Null)
}
async fn invoke_assign_skill_to_agent(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = required_request::<AssignSkillRequestDto>("assign_skill_to_agent", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
state
.assign_skill
.execute(AssignSkillToAgentInput {
project,
agent_id: parse_agent_id(&request.agent_id)?,
skill: SkillRef::new(parse_skill_id(&request.skill_id)?, request.scope),
})
.await
.map_err(ErrorDto::from)?;
Ok(Value::Null)
}
async fn invoke_unassign_skill_from_agent(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = required_request::<UnassignSkillRequestDto>("unassign_skill_from_agent", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
state
.unassign_skill
.execute(UnassignSkillFromAgentInput {
project,
agent_id: parse_agent_id(&request.agent_id)?,
skill_id: parse_skill_id(&request.skill_id)?,
})
.await
.map_err(ErrorDto::from)?;
Ok(Value::Null)
}
async fn invoke_get_project_permissions(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let project = resolve_project_readonly(
string_arg(args, "projectId", "get_project_permissions")?,
state,
)
.await?;
let output = state
.get_project_permissions
.execute(application::GetProjectPermissionsInput { project })
.await
.map(|out| ProjectPermissionsDto(out.permissions))
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_update_project_permissions(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request =
required_request::<UpdateProjectPermissionsRequestDto>("update_project_permissions", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let output = state
.update_project_permissions
.execute(UpdateProjectPermissionsInput {
project,
permissions: request.permissions,
})
.await
.map(|out| ProjectPermissionsDto(out.permissions))
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_update_agent_permissions(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request =
required_request::<UpdateAgentPermissionsRequestDto>("update_agent_permissions", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let output = state
.update_agent_permissions
.execute(UpdateAgentPermissionsInput {
project,
agent_id: parse_agent_id(&request.agent_id)?,
permissions: request.permissions,
})
.await
.map(|out| ProjectPermissionsDto(out.permissions))
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_update_agent_effort(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<UpdateAgentEffortRequestDto>("update_agent_effort", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let output = state
.update_agent_effort
.execute(UpdateAgentEffortInput {
project,
agent_id: parse_agent_id(&request.agent_id)?,
effort: request.effort,
})
.await
.map(|out| AgentDto::from_agent(out.agent))
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_update_agent_contactable(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request =
required_request::<UpdateAgentContactableRequestDto>("update_agent_contactable", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let output = state
.update_agent_contactable
.execute(UpdateAgentContactableInput {
project,
agent_id: parse_agent_id(&request.agent_id)?,
contactable: request.contactable,
})
.await
.map(|out| AgentDto::from_agent(out.agent))
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_resolve_agent_permissions(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request =
required_request::<ResolveAgentPermissionsRequestDto>("resolve_agent_permissions", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let output = state
.resolve_agent_permissions
.execute(ResolveAgentPermissionsInput {
project,
agent_id: parse_agent_id(&request.agent_id)?,
})
.await
.map(ResolveAgentPermissionsResponseDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_get_project_system_permissions(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let project = resolve_project_readonly(
string_arg(args, "projectId", "get_project_system_permissions")?,
state,
)
.await?;
let output = state
.get_project_system_permissions
.execute(GetProjectSystemPermissionsInput { project })
.await
.map(|out| ProjectSystemPermissionsDto(out.permissions))
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_update_project_system_permissions(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = required_request::<UpdateProjectSystemPermissionsRequestDto>(
"update_project_system_permissions",
args,
)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let output = state
.update_project_system_permissions
.execute(UpdateProjectSystemPermissionsInput {
project,
permissions: request.permissions,
})
.await
.map(|out| ProjectSystemPermissionsDto(out.permissions))
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_update_agent_system_permissions(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = required_request::<UpdateAgentSystemPermissionsRequestDto>(
"update_agent_system_permissions",
args,
)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let output = state
.update_agent_system_permissions
.execute(UpdateAgentSystemPermissionsInput {
project,
agent_id: parse_agent_id(&request.agent_id)?,
permissions: request.permissions,
})
.await
.map(|out| ProjectSystemPermissionsDto(out.permissions))
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_resolve_agent_system_permissions(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = required_request::<ResolveAgentSystemPermissionsRequestDto>(
"resolve_agent_system_permissions",
args,
)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let output = state
.resolve_agent_system_permissions
.execute(ResolveAgentSystemPermissionsInput {
project,
agent_id: parse_agent_id(&request.agent_id)?,
})
.await
.map(|out| ResolvedAgentSystemPermissionsDto(out.permissions))
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_get_mcp_tool_permissions(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let project = resolve_project_readonly(
string_arg(args, "projectId", "get_mcp_tool_permissions")?,
state,
)
.await?;
let output = state
.read_mcp_tool_permissions
.execute(ReadMcpToolPermissionsInput { project })
.await
.map(ProjectMcpToolPermissionsDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_update_project_mcp_tool_permissions(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = required_request::<UpdateProjectMcpToolPermissionsRequestDto>(
"update_project_mcp_tool_permissions",
args,
)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let output = state
.update_project_mcp_tool_permissions
.execute(UpdateProjectMcpToolPermissionsInput {
project,
policy: request.policy,
})
.await
.map(ProjectMcpToolPermissionsDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_update_agent_mcp_tool_permissions(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = required_request::<UpdateAgentMcpToolPermissionsRequestDto>(
"update_agent_mcp_tool_permissions",
args,
)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let output = state
.update_agent_mcp_tool_permissions
.execute(UpdateAgentMcpToolPermissionsInput {
project,
agent_id: parse_agent_id(&request.agent_id)?,
policy: request.policy,
})
.await
.map(ProjectMcpToolPermissionsDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_set_agent_mcp_tool_allowed(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request =
required_request::<SetAgentMcpToolAllowedRequestDto>("set_agent_mcp_tool_allowed", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let output = state
.set_agent_mcp_tool_allowed
.execute(SetAgentMcpToolAllowedInput {
project,
agent_id: parse_agent_id(&request.agent_id)?,
tool_name: request.tool_name,
allowed: request.allowed,
})
.await
.map(ProjectMcpToolPermissionsDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_list_memories(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let project =
resolve_project_readonly(string_arg(args, "projectId", "list_memories")?, state).await?;
let output = state
.list_memories
.execute(ListMemoriesInput {
project_root: project.root,
})
.await
.map(MemoryListDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_get_memory(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let project =
resolve_project_readonly(string_arg(args, "projectId", "get_memory")?, state).await?;
let slug = backend::dto::parse_memory_slug(string_arg(args, "slug", "get_memory")?)?;
let output = state
.get_memory
.execute(GetMemoryInput {
project_root: project.root,
slug,
})
.await
.map(MemoryDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_create_memory(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<CreateMemoryRequestDto>("create_memory", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let output = state
.create_memory
.execute(CreateMemoryInput {
project_root: project.root,
name: request.name,
description: request.description,
r#type: request.r#type,
content: request.content,
})
.await
.map(MemoryDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_update_memory(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<UpdateMemoryRequestDto>("update_memory", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let output = state
.update_memory
.execute(UpdateMemoryInput {
project_root: project.root,
slug: backend::dto::parse_memory_slug(&request.slug)?,
description: request.description,
r#type: request.r#type,
content: request.content,
})
.await
.map(MemoryDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_delete_memory(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let project =
resolve_project_readonly(string_arg(args, "projectId", "delete_memory")?, state).await?;
let slug = backend::dto::parse_memory_slug(string_arg(args, "slug", "delete_memory")?)?;
state
.delete_memory
.execute(DeleteMemoryInput {
project_root: project.root,
slug,
})
.await
.map_err(ErrorDto::from)?;
Ok(Value::Null)
}
async fn invoke_read_memory_index(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let project =
resolve_project_readonly(string_arg(args, "projectId", "read_memory_index")?, state)
.await?;
let output = state
.read_memory_index
.execute(ReadMemoryIndexInput {
project_root: project.root,
})
.await
.map(MemoryIndexDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_resolve_memory_links(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let project = resolve_project_readonly(
string_arg(args, "projectId", "resolve_memory_links")?,
state,
)
.await?;
let slug = backend::dto::parse_memory_slug(string_arg(args, "slug", "resolve_memory_links")?)?;
let output = state
.resolve_memory_links
.execute(ResolveMemoryLinksInput {
project_root: project.root,
slug,
})
.await
.map(MemoryLinksDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_recall_memory(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<RecallMemoryRequestDto>("recall_memory", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let output = state
.recall_memory
.execute(RecallMemoryInput {
project_root: project.root,
text: request.text,
token_budget: request.token_budget,
})
.await
.map(MemoryIndexDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_list_embedder_profiles(state: &BackendCore) -> Result<Value, ErrorDto> {
let output = state
.list_embedder_profiles
.execute()
.await
.map(EmbedderProfileListDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_save_embedder_profile(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = required_request::<SaveEmbedderProfileRequestDto>("save_embedder_profile", args)?;
let output = state
.save_embedder_profile
.execute(request.into())
.await
.map(EmbedderProfileDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_delete_embedder_profile(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let id = string_arg(args, "embedderId", "delete_embedder_profile")?.to_owned();
state
.delete_embedder_profile
.execute(DeleteEmbedderProfileInput { id })
.await
.map_err(ErrorDto::from)?;
Ok(Value::Null)
}
async fn invoke_describe_embedder_engines(state: &BackendCore) -> Result<Value, ErrorDto> {
let output = state
.describe_embedder_engines
.execute()
.await
.map(EmbedderEnginesDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_git_status(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let project =
resolve_project_readonly(string_arg(args, "projectId", "git_status")?, state).await?;
let output = state
.git_status
.execute(GitStatusInput {
root: project.root.as_str().to_owned(),
})
.await
.map(GitStatusListDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_git_stage(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<GitStageRequestDto>("git_stage", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
state
.git_stage
.execute(GitStagePathInput {
root: project.root.as_str().to_owned(),
path: request.path,
})
.await
.map_err(ErrorDto::from)?;
Ok(Value::Null)
}
async fn invoke_git_unstage(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<GitStageRequestDto>("git_unstage", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
state
.git_unstage
.execute(GitStagePathInput {
root: project.root.as_str().to_owned(),
path: request.path,
})
.await
.map_err(ErrorDto::from)?;
Ok(Value::Null)
}
async fn invoke_git_commit(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<GitCommitRequestDto>("git_commit", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let output = state
.git_commit
.execute(GitCommitInput {
project_id: project.id,
root: project.root.as_str().to_owned(),
message: request.message,
})
.await
.map(GitCommitDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_git_branches(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let project =
resolve_project_readonly(string_arg(args, "projectId", "git_branches")?, state).await?;
let output = state
.git_branches
.execute(GitBranchesInput {
root: project.root.as_str().to_owned(),
})
.await
.map(GitBranchesDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_git_checkout(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let request = required_request::<GitCheckoutRequestDto>("git_checkout", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
state
.git_checkout
.execute(GitCheckoutInput {
project_id: project.id,
root: project.root.as_str().to_owned(),
branch: request.branch,
})
.await
.map_err(ErrorDto::from)?;
Ok(Value::Null)
}
async fn invoke_git_log(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let project =
resolve_project_readonly(string_arg(args, "projectId", "git_log")?, state).await?;
let output = state
.git_log
.execute(GitLogInput {
root: project.root.as_str().to_owned(),
limit: usize_arg(args, "limit").unwrap_or(50),
})
.await
.map(GitCommitListDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_git_init(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let project =
resolve_project_readonly(string_arg(args, "projectId", "git_init")?, state).await?;
state
.git_init
.execute(GitInitInput {
project_id: project.id,
root: project.root.as_str().to_owned(),
})
.await
.map_err(ErrorDto::from)?;
Ok(Value::Null)
}
async fn invoke_git_graph(args: &Value, state: &BackendCore) -> Result<Value, ErrorDto> {
let project =
resolve_project_readonly(string_arg(args, "projectId", "git_graph")?, state).await?;
let output = state
.git_graph
.execute(GitGraphInput {
root: project.root.as_str().to_owned(),
limit: usize_arg(args, "limit").unwrap_or(200),
})
.await
.map(GraphCommitListDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_read_conversation_page(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request =
required_request::<ReadConversationPageRequestDto>("read_conversation_page", args)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let conversation = Uuid::parse_str(&request.conversation_id)
.map(domain::ConversationId::from_uuid)
.map_err(|_| ErrorDto {
code: "INVALID".to_owned(),
message: format!("invalid conversation id: {}", request.conversation_id),
})?;
let output = state
.read_conversation_page
.execute(ReadConversationPageInput {
project_root: project.root,
conversation,
cursor: request.cursor(),
limit: request.limit.unwrap_or(0),
})
.await
.map(TurnPageDto::from)
.map_err(ErrorDto::from)?;
serde_json::to_value(output).map_err(serialization_error)
}
async fn invoke_list_custom_agent_conversations(
args: &Value,
state: &BackendCore,
) -> Result<Value, ErrorDto> {
let request = required_request::<ListCustomConversationsRequestDto>(
"list_custom_agent_conversations",
args,
)?;
let project = resolve_project_readonly(&request.project_id, state).await?;
let agent_id = parse_agent_id(&request.agent_id)?;
let conversations = state
.custom_conversations
.list(&project.root, agent_id, request.limit.unwrap_or(0))
.await
.map_err(ErrorDto::from)?
.into_iter()
.map(CustomConversationSummaryDto::from)
.collect();
serde_json::to_value(CustomConversationListDto { conversations }).map_err(serialization_error)
}
fn task_id_arg(args: &Value, command: &str) -> Result<domain::TaskId, ErrorDto> {
args.get("taskId")
.and_then(Value::as_str)
.ok_or_else(|| ErrorDto {
code: "INVALID".to_owned(),
message: format!("{command} requires args.taskId"),
})
.and_then(parse_task_id)
}
fn arg_container(args: &Value) -> Result<&Value, ErrorDto> {
match args {
Value::Object(map) => Ok(map.get("request").unwrap_or(args)),
_ => Err(ErrorDto {
code: "INVALID".to_owned(),
message: "args must be an object".to_owned(),
}),
}
}
fn string_arg<'a>(args: &'a Value, key: &str, command: &str) -> Result<&'a str, ErrorDto> {
arg_container(args)?
.get(key)
.and_then(Value::as_str)
.ok_or_else(|| ErrorDto {
code: "INVALID".to_owned(),
message: format!("{command} requires args.{key}"),
})
}
fn usize_arg(args: &Value, key: &str) -> Option<usize> {
arg_container(args)
.ok()
.and_then(|container| container.get(key))
.and_then(Value::as_u64)
.and_then(|value| usize::try_from(value).ok())
}
fn skill_scope_arg(args: &Value, command: &str) -> Result<SkillScope, ErrorDto> {
let raw = arg_container(args)?
.get("scope")
.cloned()
.ok_or_else(|| ErrorDto {
code: "INVALID".to_owned(),
message: format!("{command} requires args.scope"),
})?;
serde_json::from_value(raw).map_err(invalid_args_error)
}
async fn verify_background_task_project(
args: &Value,
state: &BackendCore,
task_id: domain::TaskId,
) -> Result<(), ErrorDto> {
let Some(project_id) = args.get("projectId").and_then(Value::as_str) else {
return Ok(());
};
let project_id = parse_project_id(project_id)?;
let Some(task) = state
.background_task_store
.get(task_id)
.await
.map_err(background_error)?
else {
return Err(ErrorDto {
code: "NOT_FOUND".to_owned(),
message: format!("background task not found: {task_id}"),
});
};
if task.project_id != project_id {
return Err(ErrorDto {
code: "FORBIDDEN".to_owned(),
message: "background task does not belong to requested project".to_owned(),
});
}
Ok(())
}
fn background_error(err: BackgroundTaskPortError) -> ErrorDto {
let code = match &err {
BackgroundTaskPortError::NotFound => "NOT_FOUND",
BackgroundTaskPortError::AlreadyExists | BackgroundTaskPortError::Invalid(_) => "INVALID",
BackgroundTaskPortError::Runner(_) => "PROCESS",
BackgroundTaskPortError::Store(_) => "STORE",
};
ErrorDto {
code: code.to_owned(),
message: err.to_string(),
}
}
fn normalize_invoke_error(error: ErrorDto) -> ErrorDto {
if error.code == "INVALID" && error.message.contains("version conflict") {
return ErrorDto {
code: "CONFLICT".to_owned(),
message: error.message,
};
}
error
}
async fn resolve_project_readonly(
project_id: &str,
state: &BackendCore,
) -> Result<Project, ErrorDto> {
let id = parse_project_id(project_id)?;
state
.open_project
.execute(OpenProjectInput { project_id: id })
.await
.map(|output| output.project)
.map_err(ErrorDto::from)
}
fn optional_request<T>(args: &Value) -> Result<Option<T>, ErrorDto>
where
T: for<'de> Deserialize<'de>,
{
match args {
Value::Object(map) => match map.get("request") {
Some(value) => serde_json::from_value(value.clone())
.map(Some)
.map_err(invalid_args_error),
None if map.is_empty() => Ok(None),
None => serde_json::from_value(args.clone())
.map(Some)
.map_err(invalid_args_error),
},
Value::Null => Ok(None),
_ => Err(ErrorDto {
code: "INVALID".to_owned(),
message: "args must be an object".to_owned(),
}),
}
}
fn required_request<T>(command: &str, args: &Value) -> Result<T, ErrorDto>
where
T: for<'de> Deserialize<'de>,
{
optional_request(args)?.ok_or_else(|| ErrorDto {
code: "INVALID".to_owned(),
message: format!("{command} requires args.request"),
})
}
fn validate_request_origin(
headers: &HeaderMap,
config: &ServerConfig,
allow_missing: bool,
) -> Result<Option<String>, Box<Response<ResponseBody>>> {
let Some(origin) = headers.get(ORIGIN).and_then(|value| value.to_str().ok()) else {
if allow_missing {
return Ok(None);
}
return Err(Box::new(error_response(
StatusCode::FORBIDDEN,
"FORBIDDEN",
"missing Origin header",
None,
)));
};
if origin_allowed(origin, config) {
Ok(Some(origin.to_owned()))
} else {
Err(Box::new(error_response(
StatusCode::FORBIDDEN,
"FORBIDDEN",
"origin not allowed",
None,
)))
}
}
fn api_route_allows_missing_origin(method: &Method, path: &str) -> bool {
method == Method::GET
&& matches!(
path,
"/api/project-files"
| "/api/project-files/download-file"
| "/api/project-files/download-directory"
)
|| (method == Method::GET
&& (path == "/api/project-icon-assets"
|| path.starts_with("/api/project-icon-assets/")))
}
fn validate_reverse_proxy(
headers: &HeaderMap,
peer_ip: Option<IpAddr>,
state: &ServerState,
_route: &str,
) -> Result<(), Box<Response<ResponseBody>>> {
let config = &state.config;
if !config.allow_remote {
return Ok(());
}
let Some(public_origin) = &config.public_origin else {
return Ok(());
};
let peer = peer_ip.unwrap_or(config.listen.ip());
if !trusted_proxy_peer(peer, config) {
let forwarded_for = forwarded_header_first(headers, "x-forwarded-for");
state.log_security(SecurityLogEvent::UntrustedProxyPeer {
peer,
forwarded_for,
});
return Err(Box::new(error_response(
StatusCode::FORBIDDEN,
"FORBIDDEN",
untrusted_proxy_message(peer, config.trusted_proxies.is_empty()),
None,
)));
}
let proto = forwarded_header_first(headers, "x-forwarded-proto");
if !proto
.as_deref()
.is_some_and(|proto| proto.eq_ignore_ascii_case("https"))
{
state.log_security(SecurityLogEvent::ForwardedProtoRejected { peer, proto });
return Err(Box::new(error_response(
StatusCode::FORBIDDEN,
"FORBIDDEN",
"IdeA rejected the request because the trusted proxy did not report X-Forwarded-Proto: https. Configure the reverse proxy to send it; for nginx add: proxy_set_header X-Forwarded-Proto $scheme;",
None,
)));
}
let expected = public_origin_authority(public_origin).unwrap_or_else(|| public_origin.clone());
let host = forwarded_host_diagnostic(headers);
if !host
.as_deref()
.is_some_and(|host| host.trim().eq_ignore_ascii_case(&expected))
{
state.log_security(SecurityLogEvent::ForwardedHostMismatch {
peer,
host,
expected: expected.clone(),
});
}
Ok(())
}
fn trusted_proxy_peer(peer: IpAddr, config: &ServerConfig) -> bool {
if config.listen.ip().is_loopback() {
return peer.is_loopback();
}
config
.trusted_proxies
.iter()
.any(|trusted| trusted.contains(peer))
}
fn untrusted_proxy_message(peer: IpAddr, no_authorized_proxy: bool) -> String {
let reason = if no_authorized_proxy {
"but no authorized proxy is configured"
} else {
"but it is not an authorized proxy"
};
format!(
"IdeA rejected the request because it came from {peer}, {reason}. Add --trusted-proxy {peer} or a restricted proxy CIDR such as --trusted-proxy {peer}/32."
)
}
fn forwarded_header_first(headers: &HeaderMap, name: &str) -> Option<String> {
headers
.get(name)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.split(',').next())
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_owned)
}
fn forwarded_host_diagnostic(headers: &HeaderMap) -> Option<String> {
forwarded_header_first(headers, "x-forwarded-host")
.or_else(|| forwarded_header_host_param(headers))
.or_else(|| forwarded_header_first(headers, "host"))
}
fn forwarded_header_host_param(headers: &HeaderMap) -> Option<String> {
headers
.get("forwarded")
.and_then(|value| value.to_str().ok())
.and_then(|value| {
value.split(',').find_map(|entry| {
entry.split(';').find_map(|part| {
let (name, value) = part.trim().split_once('=')?;
name.trim()
.eq_ignore_ascii_case("host")
.then(|| value.trim().trim_matches('"').to_owned())
})
})
})
.filter(|value| !value.is_empty())
}
fn public_origin_authority(origin: &str) -> Option<String> {
origin.parse::<Uri>().ok().and_then(|uri| {
uri.authority()
.map(|authority| authority.as_str().to_owned())
})
}
fn request_origin(headers: &HeaderMap) -> Option<String> {
headers
.get(ORIGIN)
.and_then(|value| value.to_str().ok())
.map(str::to_owned)
}
fn origin_allowed(origin: &str, config: &ServerConfig) -> bool {
if let Some(public_origin) = &config.public_origin {
return origin == public_origin;
}
if !config.listen.ip().is_loopback() {
return false;
}
let port = config.listen.port();
origin == format!("http://127.0.0.1:{port}")
|| origin == format!("http://localhost:{port}")
|| origin == format!("http://[::1]:{port}")
}
fn has_forbidden_query_secret(uri: &Uri) -> bool {
uri.query().is_some_and(|query| {
query.split('&').any(|pair| {
let key = pair.split_once('=').map_or(pair, |(key, _)| key);
matches!(
key.to_ascii_lowercase().as_str(),
"token" | "secret" | "session" | "code"
)
})
})
}
fn session_cookie(headers: &HeaderMap) -> Option<String> {
headers
.get(COOKIE)
.and_then(|value| value.to_str().ok())
.and_then(|raw| {
raw.split(';').find_map(|part| {
let (name, value) = part.trim().split_once('=')?;
(name == SESSION_COOKIE).then(|| value.to_owned())
})
})
}
fn json_response(
status: StatusCode,
value: &Value,
origin: Option<&str>,
) -> Response<ResponseBody> {
let body = serde_json::to_vec(value).expect("JSON value serializes");
let mut response = Response::new(ResponseBody::bytes(Bytes::from(body)));
*response.status_mut() = status;
response
.headers_mut()
.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
add_cors_headers(&mut response, origin);
response
}
fn error_dto_response(
status: StatusCode,
error: ErrorDto,
origin: Option<&str>,
) -> Response<ResponseBody> {
let value = serde_json::to_value(error).expect("ErrorDto serializes");
json_response(status, &value, origin)
}
fn error_response(
status: StatusCode,
code: impl Into<String>,
message: impl Into<String>,
origin: Option<&str>,
) -> Response<ResponseBody> {
error_dto_response(
status,
ErrorDto {
code: code.into(),
message: message.into(),
},
origin,
)
}
fn error_status(error: &ErrorDto) -> StatusCode {
match error.code.as_str() {
"NOT_FOUND" => StatusCode::NOT_FOUND,
"UNAUTHORIZED" => StatusCode::UNAUTHORIZED,
"CONFLICT" | "CUSTOM_ICON_IN_USE" => StatusCode::CONFLICT,
"INVALID" => StatusCode::BAD_REQUEST,
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn cors_response(status: StatusCode, origin: Option<&str>) -> Response<ResponseBody> {
let mut response = Response::new(ResponseBody::empty());
*response.status_mut() = status;
add_cors_headers(&mut response, origin);
response
}
fn add_cors_headers(response: &mut Response<ResponseBody>, origin: Option<&str>) {
if let Some(origin) = origin {
if let Ok(origin) = HeaderValue::from_str(origin) {
response
.headers_mut()
.insert("access-control-allow-origin", origin);
response.headers_mut().insert(
"access-control-allow-credentials",
HeaderValue::from_static("true"),
);
response.headers_mut().insert(
"access-control-allow-headers",
HeaderValue::from_static("content-type"),
);
response.headers_mut().insert(
"access-control-allow-methods",
HeaderValue::from_static("GET, POST, DELETE, OPTIONS"),
);
}
}
}
fn status_for_error(error: &ErrorDto) -> StatusCode {
match error.code.as_str() {
"UNKNOWN_COMMAND" => StatusCode::BAD_REQUEST,
"INVALID" => StatusCode::BAD_REQUEST,
"NOT_FOUND" => StatusCode::NOT_FOUND,
"CONFLICT" | "CUSTOM_ICON_IN_USE" => StatusCode::CONFLICT,
"FORBIDDEN" => StatusCode::FORBIDDEN,
"UNAUTHORIZED" => StatusCode::UNAUTHORIZED,
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn validate_origin(origin: &str) -> Result<(), String> {
if origin == "*" || origin.contains('*') {
return Err("--public-origin must be exact, not a wildcard".to_owned());
}
if origin.ends_with('/') {
return Err("--public-origin must not end with '/'".to_owned());
}
if !(origin.starts_with("https://") || origin.starts_with("http://")) {
return Err("--public-origin must be an HTTP(S) origin".to_owned());
}
Ok(())
}
fn default_app_data_dir() -> PathBuf {
if let Some(path) = env::var_os("IDEA_APP_DATA_DIR") {
return PathBuf::from(path);
}
if let Some(path) = env::var_os("XDG_DATA_HOME") {
return PathBuf::from(path).join("app.idea.ide");
}
if let Some(home) = env::var_os("HOME") {
return PathBuf::from(home).join(".local/share/app.idea.ide");
}
env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(".ideai/app-data")
}
fn new_pairing_code() -> String {
let mut bytes = [0_u8; 4];
getrandom::fill(&mut bytes).expect("OS CSPRNG must be available for pairing codes");
hex::encode_upper(bytes)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PairingCodeConsumeResult {
Valid,
InvalidOrExpired,
}
fn pairing_code_hash(code: &str) -> [u8; 32] {
let normalized = application::normalize_pairing_code(code);
let mut hasher = Sha256::new();
hasher.update(PAIRING_CODE_HASH_CONTEXT);
hasher.update(normalized.as_bytes());
hasher.finalize().into()
}
fn current_time_millis() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_millis() as i64)
.unwrap_or(0)
}
fn new_session_token() -> SessionToken {
let mut bytes = [0_u8; 32];
getrandom::fill(&mut bytes).expect("OS CSPRNG must be available for session tokens");
SessionToken {
value: URL_SAFE_NO_PAD.encode(bytes),
bytes,
}
}
fn decode_session_token(token: &str) -> Result<Vec<u8>, base64::DecodeError> {
URL_SAFE_NO_PAD.decode(token.as_bytes())
}
fn invalid_args_error(err: serde_json::Error) -> ErrorDto {
ErrorDto {
code: "INVALID".to_owned(),
message: format!("invalid command args: {err}"),
}
}
fn serialization_error(err: serde_json::Error) -> ErrorDto {
ErrorDto {
code: "SERIALIZATION".to_owned(),
message: format!("failed to serialize response: {err}"),
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct ClientFrame {
id: String,
kind: String,
#[serde(default)]
payload: Value,
}
#[derive(Debug, Clone, Serialize)]
struct ServerFrame {
kind: String,
#[serde(skip_serializing_if = "Option::is_none", rename = "replyTo")]
reply_to: Option<String>,
payload: Value,
}
impl ServerFrame {
fn attached(
request_id: &str,
session: &TerminalSessionDto,
scrollback: Vec<u8>,
next_seq: u64,
gap: bool,
) -> Self {
let scrollback = if scrollback.is_empty() {
Vec::new()
} else {
vec![json!({
"seq": 0,
"bytesBase64": base64_encode(&scrollback),
})]
};
Self {
kind: "terminal.attached".to_owned(),
reply_to: Some(request_id.to_owned()),
payload: json!({
"session": {
"sessionId": session.session_id,
"rows": session.rows,
"cols": session.cols,
},
"scrollback": scrollback,
"nextSeq": next_seq,
"status": "running",
"gap": gap,
"assignedConversationId": session.assigned_conversation_id,
}),
}
}
fn chat_attached(
request_id: &str,
session: &application::StructuredSessionDescriptor,
assigned_conversation_id: Option<&str>,
engine_session_id: Option<&str>,
scrollback: Vec<ChatChunk>,
transcript_unavailable_reason: Option<ConversationTranscriptUnavailableReasonDto>,
history: Option<application::TurnPage>,
) -> Self {
let history = history.map(|mut page| {
page.turns.retain(|turn| {
matches!(
turn.role,
domain::TurnRole::Prompt | domain::TurnRole::Response
)
});
TurnPageDto::from(page)
});
let resumed = history.is_some();
Self {
kind: "chat.attached".to_owned(),
reply_to: Some(request_id.to_owned()),
payload: json!({
"sessionId": session.session_id.to_string(),
"session": {
"sessionId": session.session_id.to_string(),
"nodeId": session.node_id.to_string(),
},
"cellKind": "chat",
"assignedConversationId": assigned_conversation_id,
"engineSessionId": engine_session_id,
"scrollback": scrollback,
"transcriptUnavailableReason": transcript_unavailable_reason,
"history": history,
"resumed": resumed,
}),
}
}
fn output(session_id: SessionId, seq: u64, bytes: Vec<u8>) -> Self {
Self {
kind: "terminal.output".to_owned(),
reply_to: None,
payload: json!({
"sessionId": session_id.to_string(),
"seq": seq,
"bytesBase64": base64_encode(&bytes),
}),
}
}
fn chat_output(request_id: Option<&str>, session_id: SessionId, chunk: Value) -> Self {
Self {
kind: "chat.output".to_owned(),
reply_to: request_id.map(ToOwned::to_owned),
payload: json!({
"sessionId": session_id.to_string(),
"chunk": chunk,
}),
}
}
fn status(session_id: SessionId, status: &str, exit_code: Option<i32>) -> Self {
Self {
kind: "terminal.status".to_owned(),
reply_to: None,
payload: json!({
"sessionId": session_id.to_string(),
"status": status,
"exitCode": exit_code,
}),
}
}
fn domain_event(event: &DomainEvent) -> Self {
Self {
kind: "event.domain".to_owned(),
reply_to: None,
payload: serde_json::to_value(DomainEventDto::from(event))
.expect("DomainEventDto serializes"),
}
}
fn error(
request_id: Option<String>,
session_id: Option<String>,
code: impl Into<String>,
message: impl Into<String>,
) -> Self {
Self {
kind: "error".to_owned(),
reply_to: request_id,
payload: json!({
"sessionId": session_id,
"code": code.into(),
"message": message.into(),
}),
}
}
fn pong() -> Self {
Self {
kind: "pong".to_owned(),
reply_to: None,
payload: json!({}),
}
}
}
struct WsPtySink {
session_id: SessionId,
tx: mpsc::Sender<ServerFrame>,
seq: AtomicU64,
}
impl WsPtySink {
fn new(session_id: SessionId, tx: mpsc::Sender<ServerFrame>, next_seq: u64) -> Self {
Self {
session_id,
tx,
seq: AtomicU64::new(next_seq),
}
}
}
impl OutputSink<PtyChunk> for WsPtySink {
fn send(&self, item: PtyChunk) -> Result<(), OutputSinkError> {
let seq = self.seq.fetch_add(1, Ordering::Relaxed);
self.tx
.try_send(ServerFrame::output(self.session_id, seq, item))
.map_err(|err| match err {
mpsc::error::TrySendError::Full(_) => OutputSinkError::Full,
mpsc::error::TrySendError::Closed(_) => OutputSinkError::Closed,
})
}
}
struct WsChatSink {
session_id: SessionId,
tx: mpsc::Sender<ServerFrame>,
}
impl WsChatSink {
fn new(session_id: SessionId, tx: mpsc::Sender<ServerFrame>) -> Self {
Self { session_id, tx }
}
}
impl OutputSink<ChatChunk> for WsChatSink {
fn send(&self, item: ChatChunk) -> Result<(), OutputSinkError> {
self.tx
.try_send(ServerFrame::chat_output(None, self.session_id, item))
.map_err(|err| match err {
mpsc::error::TrySendError::Full(_) => OutputSinkError::Full,
mpsc::error::TrySendError::Closed(_) => OutputSinkError::Closed,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WsOpcode {
Text,
Binary,
Close,
Ping,
Pong,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct WsFrame {
opcode: WsOpcode,
payload: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum WsFrameError {
Incomplete,
Protocol(String),
TooLarge,
}
impl std::fmt::Display for WsFrameError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Incomplete => f.write_str("incomplete websocket frame"),
Self::Protocol(message) => write!(f, "websocket protocol error: {message}"),
Self::TooLarge => f.write_str("websocket frame payload too large"),
}
}
}
async fn read_ws_frame<R>(reader: &mut R) -> Result<WsFrame, WsFrameError>
where
R: AsyncRead + Unpin,
{
let mut header = [0_u8; 2];
reader
.read_exact(&mut header)
.await
.map_err(|_| WsFrameError::Incomplete)?;
let len_code = header[1] & 0x7f;
let masked = header[1] & 0x80 != 0;
let mut rest = Vec::new();
let extra_len = match len_code {
126 => 2,
127 => 8,
_ => 0,
};
rest.resize(extra_len + usize::from(masked) * 4, 0);
if !rest.is_empty() {
reader
.read_exact(&mut rest)
.await
.map_err(|_| WsFrameError::Incomplete)?;
}
let mut raw = Vec::with_capacity(2 + rest.len());
raw.extend_from_slice(&header);
raw.extend_from_slice(&rest);
let payload_len = decoded_payload_len(&raw)?;
if payload_len > WS_MAX_PAYLOAD {
return Err(WsFrameError::TooLarge);
}
let mut payload = vec![0_u8; payload_len];
reader
.read_exact(&mut payload)
.await
.map_err(|_| WsFrameError::Incomplete)?;
raw.extend_from_slice(&payload);
decode_client_ws_frame(&raw)
}
fn decode_client_ws_frame(raw: &[u8]) -> Result<WsFrame, WsFrameError> {
if raw.len() < 2 {
return Err(WsFrameError::Incomplete);
}
let fin = raw[0] & 0x80 != 0;
if !fin {
return Err(WsFrameError::Protocol(
"fragmented frames are not supported".to_owned(),
));
}
let opcode = match raw[0] & 0x0f {
0x1 => WsOpcode::Text,
0x2 => WsOpcode::Binary,
0x8 => WsOpcode::Close,
0x9 => WsOpcode::Ping,
0xA => WsOpcode::Pong,
other => {
return Err(WsFrameError::Protocol(format!(
"unsupported opcode {other}"
)))
}
};
let masked = raw[1] & 0x80 != 0;
if !masked {
return Err(WsFrameError::Protocol(
"client frames must be masked".to_owned(),
));
}
let len_code = raw[1] & 0x7f;
let mut offset = 2;
let len = match len_code {
126 => {
if raw.len() < offset + 2 {
return Err(WsFrameError::Incomplete);
}
let len = u16::from_be_bytes([raw[offset], raw[offset + 1]]) as usize;
offset += 2;
len
}
127 => {
if raw.len() < offset + 8 {
return Err(WsFrameError::Incomplete);
}
let len = u64::from_be_bytes(raw[offset..offset + 8].try_into().expect("8 bytes"));
offset += 8;
usize::try_from(len).map_err(|_| WsFrameError::TooLarge)?
}
len => usize::from(len),
};
if len > WS_MAX_PAYLOAD {
return Err(WsFrameError::TooLarge);
}
if raw.len() < offset + 4 + len {
return Err(WsFrameError::Incomplete);
}
let mask = &raw[offset..offset + 4];
offset += 4;
let mut payload = raw[offset..offset + len].to_vec();
for (i, byte) in payload.iter_mut().enumerate() {
*byte ^= mask[i % 4];
}
Ok(WsFrame { opcode, payload })
}
fn decoded_payload_len(raw_header: &[u8]) -> Result<usize, WsFrameError> {
let len_code = raw_header[1] & 0x7f;
let offset = 2;
let len = match len_code {
126 => {
if raw_header.len() < offset + 2 {
return Err(WsFrameError::Incomplete);
}
u16::from_be_bytes([raw_header[offset], raw_header[offset + 1]]) as usize
}
127 => {
if raw_header.len() < offset + 8 {
return Err(WsFrameError::Incomplete);
}
let len =
u64::from_be_bytes(raw_header[offset..offset + 8].try_into().expect("8 bytes"));
usize::try_from(len).map_err(|_| WsFrameError::TooLarge)?
}
len => usize::from(len),
};
Ok(len)
}
fn encode_ws_frame(opcode: WsOpcode, payload: &[u8]) -> Vec<u8> {
let opcode = match opcode {
WsOpcode::Text => 0x1,
WsOpcode::Binary => 0x2,
WsOpcode::Close => 0x8,
WsOpcode::Ping => 0x9,
WsOpcode::Pong => 0xA,
};
let mut out = vec![0x80 | opcode];
match payload.len() {
0..=125 => out.push(payload.len() as u8),
126..=65535 => {
out.push(126);
out.extend_from_slice(&(payload.len() as u16).to_be_bytes());
}
len => {
out.push(127);
out.extend_from_slice(&(len as u64).to_be_bytes());
}
}
out.extend_from_slice(payload);
out
}
fn websocket_accept(key: &str) -> String {
let mut input = Vec::with_capacity(key.len() + WS_MAGIC.len());
input.extend_from_slice(key.as_bytes());
input.extend_from_slice(WS_MAGIC.as_bytes());
base64_encode(&sha1_digest(&input))
}
fn base64_encode(bytes: &[u8]) -> String {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD.encode(bytes)
}
fn base64_decode(raw: &str) -> Result<Vec<u8>, ErrorDto> {
use base64::Engine as _;
base64::engine::general_purpose::STANDARD
.decode(raw)
.map_err(|err| ErrorDto {
code: "INVALID".to_owned(),
message: format!("invalid base64 bytes: {err}"),
})
}
fn sha1_digest(input: &[u8]) -> [u8; 20] {
let mut h0: u32 = 0x67452301;
let mut h1: u32 = 0xEFCDAB89;
let mut h2: u32 = 0x98BADCFE;
let mut h3: u32 = 0x10325476;
let mut h4: u32 = 0xC3D2E1F0;
let bit_len = (input.len() as u64) * 8;
let mut msg = input.to_vec();
msg.push(0x80);
while (msg.len() % 64) != 56 {
msg.push(0);
}
msg.extend_from_slice(&bit_len.to_be_bytes());
for chunk in msg.chunks(64) {
let mut w = [0_u32; 80];
for (i, word) in w.iter_mut().take(16).enumerate() {
let j = i * 4;
*word = u32::from_be_bytes([chunk[j], chunk[j + 1], chunk[j + 2], chunk[j + 3]]);
}
for i in 16..80 {
w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1);
}
let mut a = h0;
let mut b = h1;
let mut c = h2;
let mut d = h3;
let mut e = h4;
for (i, word) in w.iter().enumerate() {
let (f, k) = match i {
0..=19 => ((b & c) | ((!b) & d), 0x5A827999),
20..=39 => (b ^ c ^ d, 0x6ED9EBA1),
40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1BBCDC),
_ => (b ^ c ^ d, 0xCA62C1D6),
};
let temp = a
.rotate_left(5)
.wrapping_add(f)
.wrapping_add(e)
.wrapping_add(k)
.wrapping_add(*word);
e = d;
d = c;
c = b.rotate_left(30);
b = a;
a = temp;
}
h0 = h0.wrapping_add(a);
h1 = h1.wrapping_add(b);
h2 = h2.wrapping_add(c);
h3 = h3.wrapping_add(d);
h4 = h4.wrapping_add(e);
}
let mut out = [0_u8; 20];
out[0..4].copy_from_slice(&h0.to_be_bytes());
out[4..8].copy_from_slice(&h1.to_be_bytes());
out[8..12].copy_from_slice(&h2.to_be_bytes());
out[12..16].copy_from_slice(&h3.to_be_bytes());
out[16..20].copy_from_slice(&h4.to_be_bytes());
out
}
fn payload_string(payload: &Value, key: &str) -> Result<String, ErrorDto> {
payload
.get(key)
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.ok_or_else(|| ErrorDto {
code: "INVALID".to_owned(),
message: format!("payload requires {key}"),
})
}
fn payload_u16(payload: &Value, key: &str) -> Option<u16> {
payload
.get(key)
.and_then(Value::as_u64)
.and_then(|value| u16::try_from(value).ok())
}
fn payload_bytes(payload: &Value) -> Result<Vec<u8>, ErrorDto> {
if let Some(raw) = payload.get("bytesBase64").and_then(Value::as_str) {
base64_decode(raw)
} else if let Some(raw) = payload.get("bytes").and_then(Value::as_str) {
base64_decode(raw)
} else {
Err(ErrorDto {
code: "INVALID".to_owned(),
message: "payload requires bytesBase64".to_owned(),
})
}
}
fn payload_session_id(payload: &Value) -> Option<String> {
payload
.get("sessionId")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
}
#[cfg(test)]
mod tests {
use super::*;
use application::{
CreateAgentInput, CreateProjectInput, LaunchAgentOutput, SaveProfileInput,
StructuredSessionDescriptor,
};
use async_trait::async_trait;
use base64::engine::general_purpose::STANDARD;
use domain::ports::{AgentSession, AgentSessionError, EventBus, ReplyStream};
use domain::{
AgentId, AgentProfile, BackgroundTask, BackgroundTaskKind, BackgroundTaskWakePolicy,
ContextInjection, NodeId, ProfileId, ProjectId, ProjectPath, PtySize, SessionKind,
SessionStatus, SessionStrategy, TaskId, TerminalSession,
};
use http::header::HeaderName;
use std::time::Duration;
static ENV_LOCK: Mutex<()> = Mutex::new(());
struct EnvVarGuard {
saved: Vec<(&'static str, Option<std::ffi::OsString>)>,
}
impl EnvVarGuard {
fn new(keys: &[&'static str]) -> Self {
Self {
saved: keys
.iter()
.map(|key| (*key, std::env::var_os(key)))
.collect(),
}
}
}
impl Drop for EnvVarGuard {
fn drop(&mut self) {
for (key, value) in self.saved.iter().rev() {
if let Some(value) = value {
std::env::set_var(key, value);
} else {
std::env::remove_var(key);
}
}
}
}
#[derive(Default)]
struct RecordingSecurityLogger {
events: Mutex<Vec<SecurityLogEvent>>,
}
impl RecordingSecurityLogger {
fn events(&self) -> Vec<SecurityLogEvent> {
self.events.lock().unwrap().clone()
}
}
impl SecurityLogger for RecordingSecurityLogger {
fn log(&self, event: SecurityLogEvent) {
self.events.lock().unwrap().push(event);
}
}
fn create_web_root() -> PathBuf {
let root = std::env::temp_dir().join(format!("idea-web-root-{}", Uuid::new_v4()));
std::fs::create_dir_all(root.join("assets")).unwrap();
std::fs::write(
root.join("index.html"),
r#"<!doctype html><div id="root"></div><script type="module" src="/assets/app.js"></script>"#,
)
.unwrap();
std::fs::write(root.join("assets").join("app.js"), "console.log('idea');").unwrap();
std::fs::write(root.join("assets").join("style.css"), "body{margin:0}").unwrap();
root
}
fn test_config() -> ServerConfig {
ServerConfig {
listen: "127.0.0.1:17373".parse().unwrap(),
public_origin: None,
allow_remote: false,
trust_reverse_proxy: false,
trusted_proxies: Vec::new(),
app_data_dir: std::env::temp_dir().join(format!("idea-server-test-{}", Uuid::new_v4())),
web_root: create_web_root(),
new_code: false,
}
}
#[test]
fn idea_serve_lock_failure_stops_before_backend_store_writes() {
let app_data_dir =
std::env::temp_dir().join(format!("idea-server-lock-{}", Uuid::new_v4()));
let web_root = create_web_root();
let _guard = backend::acquire_app_data_dir_lock(&app_data_dir).unwrap();
let code = run_from_args(vec![
"--app-data-dir".to_owned(),
app_data_dir.to_string_lossy().into_owned(),
"--web-root".to_owned(),
web_root.to_string_lossy().into_owned(),
]);
assert_eq!(code, ExitCode::from(1));
let mut entries = std::fs::read_dir(&app_data_dir)
.unwrap()
.map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
.collect::<Vec<_>>();
entries.sort();
assert_eq!(entries, vec!["idea.lock"]);
let _ = std::fs::remove_dir_all(app_data_dir);
let _ = std::fs::remove_dir_all(web_root);
}
#[test]
fn default_app_data_dir_uses_tauri_identifier_with_env_precedence() {
let _lock = ENV_LOCK.lock().unwrap();
let _guard = EnvVarGuard::new(&["IDEA_APP_DATA_DIR", "XDG_DATA_HOME", "HOME"]);
let root = std::env::temp_dir().join(format!("idea-app-data-env-{}", Uuid::new_v4()));
let home = root.join("home");
let xdg = root.join("xdg");
let override_dir = root.join("override");
std::env::remove_var("IDEA_APP_DATA_DIR");
std::env::remove_var("XDG_DATA_HOME");
std::env::set_var("HOME", &home);
assert_eq!(
default_app_data_dir(),
home.join(".local/share/app.idea.ide")
);
std::env::set_var("XDG_DATA_HOME", &xdg);
assert_eq!(default_app_data_dir(), xdg.join("app.idea.ide"));
std::env::set_var("IDEA_APP_DATA_DIR", &override_dir);
assert_eq!(default_app_data_dir(), override_dir);
}
#[test]
fn serve_args_parse_new_code_without_persisting_config_side_effects() {
let web_root = create_web_root();
let config = ServerConfig::from_args(vec![
"--web-root".to_owned(),
web_root.to_string_lossy().into_owned(),
"--new-code".to_owned(),
])
.unwrap();
assert!(config.new_code);
}
fn state() -> Arc<ServerState> {
Arc::new(ServerState::new_for_test(test_config(), "PAIR1234"))
}
fn trusted_proxy(value: &str) -> TrustedProxy {
TrustedProxy::parse(value).unwrap()
}
fn remote_config() -> ServerConfig {
ServerConfig {
listen: "192.168.1.75:17373".parse().unwrap(),
public_origin: Some("https://idea.example.com".to_owned()),
allow_remote: true,
trust_reverse_proxy: true,
trusted_proxies: vec![trusted_proxy("192.168.1.22")],
..test_config()
}
}
async fn remote_pair_request(
state: Arc<ServerState>,
peer: &str,
origin: &str,
proto: &str,
host: Option<&str>,
extra_headers: &[(&str, &str)],
) -> Response<ResponseBody> {
let mut builder = Request::builder()
.method(Method::POST)
.uri("/api/pair")
.header(ORIGIN, origin)
.header("x-forwarded-proto", proto)
.header(CONTENT_TYPE, "application/json");
if let Some(host) = host {
builder = builder.header("x-forwarded-host", host);
}
for (name, value) in extra_headers {
builder = builder.header(HeaderName::from_bytes(name.as_bytes()).unwrap(), *value);
}
handle_request_from_peer(
builder
.body(Full::new(Bytes::from_static(
br#"{"code":"PAIR1234","name":"Test device"}"#,
)))
.unwrap(),
state,
Some(peer.parse().unwrap()),
)
.await
}
#[tokio::test]
async fn run_embedded_stop_shuts_down_accept_loop() {
let config = ServerConfig {
listen: "127.0.0.1:0".parse().unwrap(),
..test_config()
};
let handle = run_embedded(config).await.unwrap();
assert_eq!(handle.state(), EmbeddedServerState::Running);
assert_ne!(handle.url(), "http://127.0.0.1:0");
handle.stop().await.unwrap();
}
#[tokio::test]
async fn run_embedded_with_core_uses_injected_core_for_http_invokes() {
let core = Arc::new(BackendCore::build(
std::env::temp_dir().join(format!("idea-shared-core-{}", Uuid::new_v4())),
));
let project_id = create_project_on_core(&core, "Shared Core").await;
let config = ServerConfig {
listen: "127.0.0.1:0".parse().unwrap(),
app_data_dir: std::env::temp_dir()
.join(format!("idea-unused-embedded-core-{}", Uuid::new_v4())),
..test_config()
};
let handle = run_embedded_with_core(config, Arc::clone(&core))
.await
.unwrap();
let pairing = handle.generate_pairing_code();
let (pair_status, _, pair_headers) = embedded_json_request(
handle.url(),
"/api/pair",
json!({ "code": pairing.code, "name": "Test device" }),
&[],
)
.await;
let cookie = pair_headers
.get(SET_COOKIE)
.unwrap()
.to_str()
.unwrap()
.split(';')
.next()
.unwrap()
.to_owned();
let (invoke_status, body, _) = embedded_json_request(
handle.url(),
"/api/invoke",
json!({ "command": "list_projects", "args": {} }),
&[("cookie", &cookie)],
)
.await;
handle.stop().await.unwrap();
assert_eq!(pair_status, StatusCode::OK);
assert_eq!(invoke_status, StatusCode::OK);
let projects = body
.as_array()
.expect("ProjectListDto is a transparent array");
let project = projects
.iter()
.find(|project| project["id"] == project_id)
.expect("project created on injected core is listed");
assert_eq!(project["name"], "Shared Core");
}
async fn request(
state: Arc<ServerState>,
method: Method,
uri: &str,
body: Value,
extra_headers: &[(&str, &str)],
) -> Response<ResponseBody> {
let mut builder = Request::builder()
.method(method)
.uri(uri)
.header(ORIGIN, "http://127.0.0.1:17373")
.header(CONTENT_TYPE, "application/json");
for (name, value) in extra_headers {
builder = builder.header(HeaderName::from_bytes(name.as_bytes()).unwrap(), *value);
}
handle_request(
builder
.body(Full::new(Bytes::from(serde_json::to_vec(&body).unwrap())))
.unwrap(),
state,
)
.await
}
async fn request_without_origin(
state: Arc<ServerState>,
method: Method,
uri: &str,
body: Value,
extra_headers: &[(&str, &str)],
) -> Response<ResponseBody> {
let mut builder = Request::builder()
.method(method)
.uri(uri)
.header(CONTENT_TYPE, "application/json");
for (name, value) in extra_headers {
builder = builder.header(HeaderName::from_bytes(name.as_bytes()).unwrap(), *value);
}
handle_request(
builder
.body(Full::new(Bytes::from(serde_json::to_vec(&body).unwrap())))
.unwrap(),
state,
)
.await
}
async fn response_json(response: Response<ResponseBody>) -> (StatusCode, Value, HeaderMap) {
let status = response.status();
let headers = response.headers().clone();
let body = response.into_body().into_bytes().await;
let value = if body.is_empty() {
Value::Null
} else {
serde_json::from_slice(&body).unwrap()
};
(status, value, headers)
}
async fn embedded_json_request(
base_url: &str,
path: &str,
body: Value,
extra_headers: &[(&str, &str)],
) -> (StatusCode, Value, HeaderMap) {
let addr = base_url
.strip_prefix("http://")
.expect("embedded server URL is HTTP");
let body = serde_json::to_vec(&body).unwrap();
let mut request = format!(
"POST {path} HTTP/1.1\r\n\
host: {addr}\r\n\
origin: {base_url}\r\n\
content-type: application/json\r\n\
content-length: {}\r\n",
body.len()
)
.into_bytes();
for (name, value) in extra_headers {
request.extend_from_slice(name.as_bytes());
request.extend_from_slice(b": ");
request.extend_from_slice(value.as_bytes());
request.extend_from_slice(b"\r\n");
}
request.extend_from_slice(b"\r\n");
request.extend_from_slice(&body);
let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
stream.write_all(&request).await.unwrap();
let mut response = Vec::new();
stream.read_to_end(&mut response).await.unwrap();
let header_end = find_header_end(&response).expect("response has headers");
let head = std::str::from_utf8(&response[..header_end]).unwrap();
let mut lines = head.split("\r\n");
let status_line = lines.next().unwrap();
let status_code = status_line
.split_whitespace()
.nth(1)
.unwrap()
.parse::<u16>()
.unwrap();
let mut headers = HeaderMap::new();
for line in lines {
let Some((name, value)) = line.split_once(':') else {
continue;
};
headers.insert(
HeaderName::from_bytes(name.trim().as_bytes()).unwrap(),
HeaderValue::from_str(value.trim()).unwrap(),
);
}
let body = &response[header_end + 4..];
let value = if body.is_empty() {
Value::Null
} else {
serde_json::from_slice(body).unwrap()
};
(StatusCode::from_u16(status_code).unwrap(), value, headers)
}
async fn response_bytes(response: Response<ResponseBody>) -> (StatusCode, Bytes, HeaderMap) {
let status = response.status();
let headers = response.headers().clone();
let body = response.into_body().into_bytes().await;
(status, body, headers)
}
async fn pair_and_cookie(state: Arc<ServerState>) -> String {
let response = request(
state,
Method::POST,
"/api/pair",
json!({ "code": "PAIR1234", "name": "Test device" }),
&[],
)
.await;
response
.headers()
.get(SET_COOKIE)
.unwrap()
.to_str()
.unwrap()
.split(';')
.next()
.unwrap()
.to_owned()
}
async fn pair_and_cookie_with(state: Arc<ServerState>, code: &str, name: &str) -> String {
let response = request(
state,
Method::POST,
"/api/pair",
json!({ "code": code, "name": name }),
&[],
)
.await;
response
.headers()
.get(SET_COOKIE)
.unwrap()
.to_str()
.unwrap()
.split(';')
.next()
.unwrap()
.to_owned()
}
async fn authenticated_device_id(state: &ServerState, cookie: &str) -> DeviceId {
let token = cookie
.strip_prefix("idea_session=")
.expect("test cookie contains session token");
state
.authenticate_session(token)
.await
.expect("test cookie authenticates")
.device_id
}
async fn create_project_for_test(state: &Arc<ServerState>, name: &str) -> String {
let root = std::env::temp_dir()
.join(format!("idea-server-project-{}", Uuid::new_v4()))
.to_string_lossy()
.into_owned();
let output = state
.app
.create_project
.execute(CreateProjectInput {
name: name.to_owned(),
root,
remote: None,
default_profile_id: None,
})
.await
.expect("test project is created");
output.project.id.to_string()
}
async fn create_project_on_core(core: &BackendCore, name: &str) -> String {
let root = std::env::temp_dir()
.join(format!(
"idea-server-shared-core-project-{}",
Uuid::new_v4()
))
.to_string_lossy()
.into_owned();
let output = core
.create_project
.execute(CreateProjectInput {
name: name.to_owned(),
root,
remote: None,
default_profile_id: None,
})
.await
.expect("test project is created on injected core");
output.project.id.to_string()
}
async fn create_agent_for_project_for_test(
state: &Arc<ServerState>,
project_id: &str,
name: &str,
) -> String {
let project = resolve_project_readonly(project_id, &state.app)
.await
.expect("test project resolves");
let profile_id = ProfileId::from_uuid(Uuid::new_v4());
let profile = AgentProfile::new(
profile_id,
format!("{name} profile"),
"/bin/sh",
vec!["-c".to_owned(), "printf ready".to_owned()],
ContextInjection::env("IDEA_CONTEXT").expect("valid env injection"),
None,
"{agentRunDir}",
None,
)
.expect("valid profile");
state
.app
.save_profile
.execute(SaveProfileInput { profile })
.await
.expect("test profile saved");
state
.app
.create_agent
.execute(CreateAgentInput {
project,
name: format!("{name} agent"),
profile_id,
initial_content: Some("Test agent context".to_owned()),
})
.await
.expect("test agent created")
.agent
.id
.to_string()
}
async fn create_background_task_for_test(
state: &Arc<ServerState>,
project_id: &str,
label: &str,
) -> (TaskId, AgentId) {
let task_id = TaskId::from_uuid(Uuid::new_v4());
let owner = AgentId::from_uuid(Uuid::new_v4());
let project_id = ProjectId::from_uuid(Uuid::parse_str(project_id).unwrap());
let task = BackgroundTask::new(
task_id,
project_id,
owner,
BackgroundTaskKind::Command {
label: label.to_owned(),
},
BackgroundTaskWakePolicy::WakeOwner,
1_000,
None,
)
.expect("test background task is valid");
state
.app
.background_task_store
.create(&task)
.await
.expect("test background task is stored");
(task_id, owner)
}
async fn invoke_request_for_test(
state: Arc<ServerState>,
cookie: &str,
command: &str,
args: Value,
) -> (StatusCode, Value) {
let response = request(
state,
Method::POST,
"/api/invoke",
json!({ "command": command, "args": args }),
&[("cookie", cookie)],
)
.await;
let (status, body, _) = response_json(response).await;
(status, body)
}
async fn assert_invoke_command_is_routed(state: Arc<ServerState>, cookie: &str, command: &str) {
let (status, body) = invoke_request_for_test(state, cookie, command, json!({})).await;
assert_ne!(
body["code"], "UNKNOWN_COMMAND",
"{command} must be allowlisted via /api/invoke"
);
assert!(
status == StatusCode::OK || status == StatusCode::BAD_REQUEST,
"{command} returned unexpected status {status}: {body}"
);
}
async fn assert_invoke_commands_are_routed(
state: &Arc<ServerState>,
cookie: &str,
commands: &[&str],
) {
for command in commands {
assert_invoke_command_is_routed(Arc::clone(state), cookie, command).await;
}
}
async fn create_raw_cli_agent_for_test(
state: &Arc<ServerState>,
name: &str,
) -> (String, String) {
let project_id = create_project_for_test(state, name).await;
let project = resolve_project_readonly(&project_id, &state.app)
.await
.expect("test project resolves");
let profile_id = ProfileId::from_uuid(Uuid::new_v4());
let profile = AgentProfile::new(
profile_id,
format!("{name} shell profile"),
"/bin/sh",
vec![
"-c".to_owned(),
"printf agent-ready; sleep 30".to_owned(),
"idea-test-sh".to_owned(),
],
ContextInjection::env("IDEA_CONTEXT").expect("valid env injection"),
None,
"{agentRunDir}",
Some(
SessionStrategy::new(Some("--session-id".to_owned()), "--resume")
.expect("valid session strategy"),
),
)
.expect("valid raw CLI profile");
state
.app
.save_profile
.execute(SaveProfileInput { profile })
.await
.expect("test profile saved");
let agent = state
.app
.create_agent
.execute(CreateAgentInput {
project,
name: format!("{name} agent"),
profile_id,
initial_content: Some("Test agent context".to_owned()),
})
.await
.expect("test agent created")
.agent;
(project_id, agent.id.to_string())
}
fn ws_headers(origin: &str, cookie: Option<&str>) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(ORIGIN, HeaderValue::from_str(origin).unwrap());
headers.insert("upgrade", HeaderValue::from_static("websocket"));
headers.insert("connection", HeaderValue::from_static("Upgrade"));
headers.insert(
"sec-websocket-key",
HeaderValue::from_static("dGhlIHNhbXBsZSBub25jZQ=="),
);
if let Some(cookie) = cookie {
headers.insert(COOKIE, HeaderValue::from_str(cookie).unwrap());
}
headers
}
fn masked_text_frame(text: &str) -> Vec<u8> {
let payload = text.as_bytes();
let mask = [1_u8, 2, 3, 4];
let mut out = vec![0x81];
out.push(0x80 | payload.len() as u8);
out.extend_from_slice(&mask);
for (i, byte) in payload.iter().enumerate() {
out.push(byte ^ mask[i % 4]);
}
out
}
async fn recv_server_frame(rx: &mut mpsc::Receiver<ServerFrame>) -> ServerFrame {
tokio::time::timeout(Duration::from_secs(2), rx.recv())
.await
.expect("server frame received before timeout")
.expect("server frame channel is open")
}
async fn recv_server_frame_where(
rx: &mut mpsc::Receiver<ServerFrame>,
mut predicate: impl FnMut(&ServerFrame) -> bool,
) -> ServerFrame {
for _ in 0..16 {
let frame = recv_server_frame(rx).await;
if predicate(&frame) {
return frame;
}
}
panic!("matching server frame was not received");
}
fn drain_server_frames(rx: &mut mpsc::Receiver<ServerFrame>) {
while rx.try_recv().is_ok() {}
}
fn ws_frame(id: &str, kind: &str, payload: Value) -> ClientFrame {
ClientFrame {
id: id.to_owned(),
kind: kind.to_owned(),
payload,
}
}
struct FakeChatSession {
id: SessionId,
conversation_id: Option<String>,
events: Vec<ReplyEvent>,
tap_events: Vec<ReplyEvent>,
prompts: Mutex<Vec<String>>,
}
impl FakeChatSession {
fn new(id: SessionId, events: Vec<ReplyEvent>) -> Self {
Self {
id,
conversation_id: Some("engine-chat-session".to_owned()),
events,
tap_events: Vec::new(),
prompts: Mutex::new(Vec::new()),
}
}
fn with_tap_events(
id: SessionId,
events: Vec<ReplyEvent>,
tap_events: Vec<ReplyEvent>,
) -> Self {
Self {
id,
conversation_id: Some("engine-chat-session".to_owned()),
events,
tap_events,
prompts: Mutex::new(Vec::new()),
}
}
}
#[async_trait]
impl AgentSession for FakeChatSession {
fn id(&self) -> SessionId {
self.id
}
fn conversation_id(&self) -> Option<String> {
self.conversation_id.clone()
}
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
self.prompts
.lock()
.expect("prompt trace mutex poisoned")
.push(prompt.to_owned());
Ok(Box::new(self.events.clone().into_iter()))
}
async fn send_with_tap(
&self,
prompt: &str,
tap: std::sync::mpsc::Sender<ReplyEvent>,
) -> Result<ReplyStream, AgentSessionError> {
for event in self.tap_events.clone() {
let _ = tap.send(event);
}
self.send(prompt).await
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(())
}
}
struct GatedChatSession {
id: SessionId,
prompts: Mutex<Vec<String>>,
started: Mutex<Option<oneshot::Sender<()>>>,
release: Mutex<Option<oneshot::Receiver<()>>>,
}
impl GatedChatSession {
fn new(
id: SessionId,
started: oneshot::Sender<()>,
release: oneshot::Receiver<()>,
) -> Self {
Self {
id,
prompts: Mutex::new(Vec::new()),
started: Mutex::new(Some(started)),
release: Mutex::new(Some(release)),
}
}
}
#[async_trait]
impl AgentSession for GatedChatSession {
fn id(&self) -> SessionId {
self.id
}
fn conversation_id(&self) -> Option<String> {
Some("engine-gated-chat-session".to_owned())
}
async fn send(&self, prompt: &str) -> Result<ReplyStream, AgentSessionError> {
self.send_with_tap(prompt, std::sync::mpsc::channel().0)
.await
}
async fn send_with_tap(
&self,
prompt: &str,
_tap: std::sync::mpsc::Sender<ReplyEvent>,
) -> Result<ReplyStream, AgentSessionError> {
self.prompts
.lock()
.expect("prompt trace mutex poisoned")
.push(prompt.to_owned());
if let Some(started) = self
.started
.lock()
.expect("started gate mutex poisoned")
.take()
{
let _ = started.send(());
}
let release = self
.release
.lock()
.expect("release gate mutex poisoned")
.take();
if let Some(release) = release {
let _ = release.await;
}
Ok(Box::new(
vec![ReplyEvent::Final {
content: "released".to_owned(),
}]
.into_iter(),
))
}
async fn shutdown(&self) -> Result<(), AgentSessionError> {
Ok(())
}
}
fn attached_session_id(frame: &ServerFrame) -> String {
assert_eq!(frame.kind, "terminal.attached");
frame.payload["session"]["sessionId"]
.as_str()
.expect("attached frame carries sessionId")
.to_owned()
}
fn open_terminal_payload(command: &str, args: Vec<&str>) -> Value {
json!({
"request": {
"cwd": std::env::temp_dir().to_string_lossy(),
"rows": 24,
"cols": 80,
"command": command,
"args": args,
}
})
}
fn agent_launch_payload(
project_id: &str,
agent_id: &str,
node_id: &str,
conversation_id: Option<&str>,
) -> Value {
json!({
"projectId": project_id,
"agentId": agent_id,
"nodeId": node_id,
"rows": 24,
"cols": 80,
"conversationId": conversation_id,
})
}
async fn close_ws_session(state: &Arc<ServerState>, session_id: &str) {
let (tx, mut rx) = mpsc::channel(8);
let owned = Arc::new(Mutex::new(Vec::new()));
handle_client_frame(
ws_frame(
"close-cleanup",
"terminal.close",
json!({ "sessionId": session_id }),
),
state,
&tx,
&owned,
)
.await;
let _ = recv_server_frame(&mut rx).await;
}
async fn wait_for_scrollback(
state: &Arc<ServerState>,
session_id: &str,
needle: &[u8],
) -> Vec<u8> {
let sid = parse_session_id(session_id).expect("test session id parses");
let handle = PtyHandle { session_id: sid };
for _ in 0..100 {
if let Ok(scrollback) = state.app.pty_port.scrollback(&handle) {
if scrollback
.windows(needle.len())
.any(|window| window == needle)
{
return scrollback;
}
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
panic!(
"scrollback did not contain {:?}",
String::from_utf8_lossy(needle)
);
}
#[test]
fn websocket_accept_matches_rfc_example() {
assert_eq!(
websocket_accept("dGhlIHNhbXBsZSBub25jZQ=="),
"s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
);
}
#[tokio::test]
async fn websocket_upgrade_requires_valid_cookie() {
let state = state();
let headers = ws_headers("http://127.0.0.1:17373", None);
let response = validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state)
.await
.unwrap_err();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn websocket_upgrade_rejects_invalid_cookie() {
let state = state();
let headers = ws_headers(
"http://127.0.0.1:17373",
Some("idea_session=not-a-valid-session"),
);
let response = validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state)
.await
.unwrap_err();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn websocket_upgrade_requires_allowed_origin() {
let state = state();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let headers = ws_headers("https://evil.example", Some(&cookie));
let response = validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state)
.await
.unwrap_err();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn websocket_upgrade_requires_origin_header() {
let state = state();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let mut headers = ws_headers("http://127.0.0.1:17373", Some(&cookie));
headers.remove(ORIGIN);
let response = validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state)
.await
.unwrap_err();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn websocket_upgrade_accepts_valid_cookie_and_origin() {
let state = state();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let headers = ws_headers("http://127.0.0.1:17373", Some(&cookie));
let (accept, device) =
validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state)
.await
.unwrap();
assert_eq!(accept, "s3pPLMBiTxaQ9kYGzzhZRbK+xOo=");
assert_eq!(
device.device_id,
authenticated_device_id(&state, &cookie).await
);
}
#[test]
fn websocket_client_text_frame_decodes_masked_json() {
let raw = masked_text_frame(r#"{"kind":"ping"}"#);
let frame = decode_client_ws_frame(&raw).unwrap();
assert_eq!(frame.opcode, WsOpcode::Text);
assert_eq!(frame.payload, br#"{"kind":"ping"}"#);
}
#[test]
fn websocket_client_json_frame_round_trips_base64_bytes() {
let sid = domain::SessionId::new_random().to_string();
let value = json!({
"id": "req-1",
"kind": "input",
"payload": {
"sessionId": sid,
"bytesBase64": "AQID"
}
});
let frame: ClientFrame = serde_json::from_value(value.clone()).unwrap();
let roundtrip = serde_json::to_value(&frame).unwrap();
assert_eq!(roundtrip, value);
assert_eq!(payload_bytes(&frame.payload).unwrap(), vec![1, 2, 3]);
}
#[test]
fn websocket_server_attached_frame_uses_contract_shape() {
let sid = domain::SessionId::new_random().to_string();
let frame = ServerFrame::attached(
"req-attach",
&TerminalSessionDto {
session_id: sid.clone(),
cwd: "/tmp".to_owned(),
rows: 24,
cols: 80,
assigned_conversation_id: None,
engine_session_id: None,
cell_kind: backend::dto::CellKind::Pty,
history: Vec::new(),
history_has_more: false,
history_next_anchor: None,
resumed: false,
},
vec![4, 5, 6],
1,
true,
);
let value = serde_json::to_value(frame).unwrap();
assert_eq!(value["kind"], "terminal.attached");
assert_eq!(value["replyTo"], "req-attach");
assert_eq!(value["payload"]["session"]["sessionId"], sid);
assert_eq!(value["payload"]["scrollback"][0]["seq"], 0);
assert_eq!(value["payload"]["scrollback"][0]["bytesBase64"], "BAUG");
assert_eq!(value["payload"]["nextSeq"], 1);
assert_eq!(value["payload"]["gap"], true);
}
#[tokio::test]
async fn websocket_app_ping_emits_pong() {
let state = state();
let (tx, mut rx) = mpsc::channel(4);
let owned = Arc::new(Mutex::new(Vec::new()));
handle_client_frame(ws_frame("ping-1", "ping", json!({})), &state, &tx, &owned).await;
let frame = recv_server_frame(&mut rx).await;
assert_eq!(frame.kind, "pong");
assert!(frame.payload.as_object().unwrap().is_empty());
}
#[test]
fn websocket_domain_event_frame_uses_contract_shape() {
let project_id = ProjectId::from_uuid(Uuid::new_v4());
let frame = ServerFrame::domain_event(&DomainEvent::ProjectCreated { project_id });
assert_eq!(frame.kind, "event.domain");
assert!(frame.reply_to.is_none());
assert_eq!(frame.payload["type"], "projectCreated");
assert_eq!(frame.payload["projectId"], project_id.to_string());
}
#[tokio::test]
async fn websocket_domain_event_relay_forwards_domain_events() {
let state = state();
let project_id = ProjectId::from_uuid(Uuid::new_v4());
let event_rx = state.app.event_bus.raw_receiver();
let (tx, mut rx) = mpsc::channel(4);
let relay = spawn_ws_domain_event_relay(event_rx, tx);
state
.app
.event_bus
.publish(DomainEvent::ProjectCreated { project_id });
let frame = tokio::time::timeout(Duration::from_secs(1), rx.recv())
.await
.expect("event frame is forwarded")
.expect("event frame channel is open");
relay.abort();
assert_eq!(frame.kind, "event.domain");
assert_eq!(frame.payload["type"], "projectCreated");
assert_eq!(frame.payload["projectId"], project_id.to_string());
}
#[tokio::test]
async fn websocket_domain_event_relay_forwards_background_completion() {
let state = state();
let project_id = ProjectId::from_uuid(Uuid::new_v4());
let task_id = TaskId::from_uuid(Uuid::new_v4());
let owner = AgentId::from_uuid(Uuid::new_v4());
let event_rx = state.app.event_bus.raw_receiver();
let (tx, mut rx) = mpsc::channel(4);
let relay = spawn_ws_domain_event_relay(event_rx, tx);
state
.app
.event_bus
.publish(DomainEvent::BackgroundTaskCompleted {
project_id,
task_id,
owner_agent_id: owner,
rendezvous: None,
});
let frame = tokio::time::timeout(Duration::from_secs(1), rx.recv())
.await
.expect("background completion event is forwarded")
.expect("event frame channel is open");
relay.abort();
assert_eq!(frame.kind, "event.domain");
assert_eq!(frame.payload["type"], "backgroundTaskChanged");
assert_eq!(frame.payload["projectId"], project_id.to_string());
assert_eq!(frame.payload["taskId"], task_id.to_string());
assert_eq!(frame.payload["agentId"], owner.to_string());
assert_eq!(frame.payload["state"], "completed");
}
#[tokio::test]
async fn websocket_domain_event_relay_excludes_pty_output() {
let state = state();
let project_id = ProjectId::from_uuid(Uuid::new_v4());
let event_rx = state.app.event_bus.raw_receiver();
let (tx, mut rx) = mpsc::channel(4);
let relay = spawn_ws_domain_event_relay(event_rx, tx);
state.app.event_bus.publish(DomainEvent::PtyOutput {
session_id: SessionId::new_random(),
bytes: b"hidden from global live bus".to_vec(),
});
state
.app
.event_bus
.publish(DomainEvent::ProjectCreated { project_id });
let frame = tokio::time::timeout(Duration::from_secs(1), rx.recv())
.await
.expect("sentinel event is forwarded")
.expect("event frame channel is open");
relay.abort();
assert_eq!(frame.kind, "event.domain");
assert_eq!(frame.payload["type"], "projectCreated");
assert_eq!(frame.payload["projectId"], project_id.to_string());
assert!(
rx.try_recv().is_err(),
"PtyOutput must not be emitted on the global event.domain stream"
);
}
#[tokio::test]
async fn websocket_domain_event_relay_drops_when_client_queue_is_full() {
let state = state();
let event_rx = state.app.event_bus.raw_receiver();
let (tx, mut rx) = mpsc::channel(1);
assert!(tx.try_send(ServerFrame::pong()).is_ok());
let relay = spawn_ws_domain_event_relay(event_rx, tx);
state.app.event_bus.publish(DomainEvent::ProjectCreated {
project_id: ProjectId::from_uuid(Uuid::new_v4()),
});
tokio::time::sleep(Duration::from_millis(20)).await;
let first = rx.try_recv().expect("pre-filled frame remains queued");
relay.abort();
assert_eq!(first.kind, "pong");
assert!(
rx.try_recv().is_err(),
"full client queue should drop the live event instead of buffering"
);
}
#[tokio::test]
async fn websocket_open_terminal_emits_attached_with_empty_scrollback() {
let state = state();
let (tx, mut rx) = mpsc::channel(16);
let owned = Arc::new(Mutex::new(Vec::new()));
handle_client_frame(
ws_frame(
"open-1",
"terminal.open",
open_terminal_payload("/bin/sh", vec!["-c", "sleep 30"]),
),
&state,
&tx,
&owned,
)
.await;
let frame = recv_server_frame(&mut rx).await;
let session_id = attached_session_id(&frame);
assert_eq!(frame.reply_to.as_deref(), Some("open-1"));
assert_eq!(frame.payload["scrollback"].as_array().unwrap().len(), 0);
assert_eq!(frame.payload["nextSeq"], 0);
close_ws_session(&state, &session_id).await;
}
#[tokio::test]
async fn websocket_open_terminal_delivers_the_initial_process_output() {
let state = state();
let (tx, mut rx) = mpsc::channel(16);
let owned = Arc::new(Mutex::new(Vec::new()));
handle_client_frame(
ws_frame(
"open-initial-output",
"terminal.open",
open_terminal_payload("/bin/sh", vec!["-c", "printf initial-screen; sleep 30"]),
),
&state,
&tx,
&owned,
)
.await;
let attached = recv_server_frame(&mut rx).await;
let session_id = attached_session_id(&attached);
let output = recv_server_frame(&mut rx).await;
assert_eq!(attached.kind, "terminal.attached");
assert_eq!(output.kind, "terminal.output");
assert_eq!(output.payload["sessionId"], session_id);
assert_eq!(output.payload["seq"], 0);
assert_eq!(
base64_decode(output.payload["bytesBase64"].as_str().unwrap()).unwrap(),
b"initial-screen"
);
close_ws_session(&state, &session_id).await;
}
#[tokio::test]
async fn websocket_attach_terminal_replays_scrollback_in_attached_ack() {
let state = state();
let (tx, mut rx) = mpsc::channel(32);
let owned = Arc::new(Mutex::new(Vec::new()));
handle_client_frame(
ws_frame(
"open-replay",
"terminal.open",
open_terminal_payload("/bin/sh", vec!["-c", "printf replay-ready; sleep 30"]),
),
&state,
&tx,
&owned,
)
.await;
let opened = recv_server_frame(&mut rx).await;
let session_id = attached_session_id(&opened);
let scrollback = wait_for_scrollback(&state, &session_id, b"replay-ready").await;
assert!(scrollback.len() <= 100 * 1024);
drain_server_frames(&mut rx);
handle_client_frame(
ws_frame(
"attach-replay",
"terminal.attach",
json!({
"sessionId": session_id,
"lastSeq": 0,
"rows": 30,
"cols": 100,
}),
),
&state,
&tx,
&owned,
)
.await;
let attached = recv_server_frame(&mut rx).await;
let replay = attached.payload["scrollback"].as_array().unwrap();
assert_eq!(attached.kind, "terminal.attached");
assert_eq!(attached.reply_to.as_deref(), Some("attach-replay"));
assert_eq!(attached.payload["session"]["rows"], 30);
assert_eq!(attached.payload["session"]["cols"], 100);
assert_eq!(attached.payload["gap"], true);
assert_eq!(attached.payload["nextSeq"], 1);
assert_eq!(
base64_decode(replay[0]["bytesBase64"].as_str().unwrap()).unwrap(),
scrollback
);
close_ws_session(
&state,
attached.payload["session"]["sessionId"].as_str().unwrap(),
)
.await;
}
#[tokio::test]
async fn websocket_close_terminal_emits_exited_status_and_releases_session() {
let state = state();
let (tx, mut rx) = mpsc::channel(16);
let owned = Arc::new(Mutex::new(Vec::new()));
handle_client_frame(
ws_frame(
"open-close",
"terminal.open",
open_terminal_payload("/bin/sh", vec!["-c", "sleep 30"]),
),
&state,
&tx,
&owned,
)
.await;
let opened = recv_server_frame(&mut rx).await;
let session_id = attached_session_id(&opened);
handle_client_frame(
ws_frame(
"close-1",
"terminal.close",
json!({ "sessionId": session_id }),
),
&state,
&tx,
&owned,
)
.await;
let status = recv_server_frame(&mut rx).await;
assert_eq!(status.kind, "terminal.status");
assert_eq!(status.payload["status"], "exited");
assert_eq!(status.payload["sessionId"], session_id);
assert_eq!(state.ws_pty_bridge.active_sessions(), 0);
assert!(state.app.terminal_sessions.is_empty());
}
#[tokio::test]
async fn websocket_launch_agent_emits_attached_with_assigned_conversation_id() {
let state = state();
let (project_id, agent_id) = create_raw_cli_agent_for_test(&state, "ws-launch").await;
let node_id = Uuid::new_v4().to_string();
let (tx, mut rx) = mpsc::channel(32);
let owned = Arc::new(Mutex::new(Vec::new()));
handle_client_frame(
ws_frame(
"agent-launch-1",
"agent.launch",
agent_launch_payload(&project_id, &agent_id, &node_id, None),
),
&state,
&tx,
&owned,
)
.await;
let attached = recv_server_frame(&mut rx).await;
let session_id = attached_session_id(&attached);
assert_eq!(attached.kind, "terminal.attached");
assert_eq!(attached.reply_to.as_deref(), Some("agent-launch-1"));
assert_eq!(attached.payload["scrollback"].as_array().unwrap().len(), 0);
assert!(attached.payload["assignedConversationId"]
.as_str()
.and_then(|raw| Uuid::parse_str(raw).ok())
.is_some());
close_ws_session(&state, &session_id).await;
}
#[tokio::test]
async fn websocket_launch_agent_structured_emits_chat_attached() {
let state = state();
let (tx, mut rx) = mpsc::channel(4);
let owned = Arc::new(Mutex::new(Vec::new()));
let session_id = SessionId::new_random();
let agent_id = AgentId::from_uuid(Uuid::new_v4());
let node_id = NodeId::from_uuid(Uuid::new_v4());
let size = PtySize::new(24, 80).unwrap();
let mut session = TerminalSession::starting(
session_id,
node_id,
ProjectPath::new("/".to_owned()).unwrap(),
SessionKind::Agent { agent_id },
size,
);
session.status = SessionStatus::Running;
let frame = ws_frame("agent-structured", "agent.launch", json!({}));
let output = LaunchAgentOutput {
session,
assigned_conversation_id: Some("pair-conversation".to_owned()),
engine_session_id: Some("engine-session".to_owned()),
structured: Some(StructuredSessionDescriptor {
session_id,
agent_id,
node_id,
conversation_id: Some("engine-session".to_owned()),
}),
profile: None,
};
send_launch_agent_attached(&frame, &state, &tx, &owned, output)
.await
.unwrap();
let attached = recv_server_frame(&mut rx).await;
assert_eq!(attached.kind, "chat.attached");
assert_eq!(attached.reply_to.as_deref(), Some("agent-structured"));
assert_eq!(attached.payload["sessionId"], session_id.to_string());
assert_eq!(
attached.payload["session"]["sessionId"],
session_id.to_string()
);
assert_eq!(attached.payload["session"]["nodeId"], node_id.to_string());
assert_eq!(attached.payload["cellKind"], "chat");
assert_eq!(
attached.payload["assignedConversationId"],
"pair-conversation"
);
assert_eq!(attached.payload["engineSessionId"], "engine-session");
assert_eq!(attached.payload["scrollback"], json!([]));
assert_eq!(
attached.payload["transcriptUnavailableReason"],
"emptyConversation"
);
assert_eq!(state.ws_pty_bridge.active_sessions(), 0);
}
#[tokio::test]
async fn websocket_chat_attach_emits_chat_attached_and_registers_sink() {
let state = state();
let session_id = SessionId::new_random();
let agent_id = AgentId::from_uuid(Uuid::new_v4());
let node_id = NodeId::from_uuid(Uuid::new_v4());
let session = Arc::new(FakeChatSession::new(
session_id,
vec![ReplyEvent::Final {
content: "unused".to_owned(),
}],
));
state.app.structured_sessions.insert(
Arc::clone(&session) as Arc<dyn AgentSession>,
agent_id,
node_id,
);
let (tx, mut rx) = mpsc::channel(8);
let owned = Arc::new(Mutex::new(Vec::new()));
handle_client_frame(
ws_frame(
"chat-attach",
"chat.attach",
json!({
"sessionId": session_id.to_string(),
"projectId": "not-parsed-for-a-live-session",
"agentId": "not-parsed-for-a-live-session",
}),
),
&state,
&tx,
&owned,
)
.await;
let attached = recv_server_frame(&mut rx).await;
assert_eq!(attached.kind, "chat.attached");
assert_eq!(attached.reply_to.as_deref(), Some("chat-attach"));
assert_eq!(attached.payload["sessionId"], session_id.to_string());
assert_eq!(
attached.payload["session"]["sessionId"],
session_id.to_string()
);
assert_eq!(attached.payload["cellKind"], "chat");
assert_eq!(attached.payload["engineSessionId"], "engine-chat-session");
assert_eq!(attached.payload["scrollback"], json!([]));
assert_eq!(
attached.payload["transcriptUnavailableReason"],
"emptyConversation"
);
assert_eq!(state.ws_chat_bridge.active_sessions(), 1);
}
#[tokio::test]
async fn websocket_chat_attach_recovers_stale_session_from_live_project_agent() {
let state = state();
let stale_session_id = SessionId::new_random();
let live_session_id = SessionId::new_random();
let project_id = ProjectId::from_uuid(Uuid::new_v4());
let agent_id = AgentId::from_uuid(Uuid::new_v4());
let node_id = NodeId::from_uuid(Uuid::new_v4());
let session = Arc::new(FakeChatSession::new(
live_session_id,
vec![ReplyEvent::Final {
content: "unused".to_owned(),
}],
));
state.app.structured_sessions.insert_in_project(
project_id,
Arc::clone(&session) as Arc<dyn AgentSession>,
agent_id,
node_id,
);
state
.app
.structured_sessions
.set_transcript_restore_expected(live_session_id, true);
let (tx, mut rx) = mpsc::channel(8);
let owned = Arc::new(Mutex::new(Vec::new()));
handle_client_frame(
ws_frame(
"chat-attach-fallback",
"chat.attach",
json!({
"sessionId": stale_session_id.to_string(),
"projectId": project_id.to_string(),
"agentId": agent_id.to_string(),
}),
),
&state,
&tx,
&owned,
)
.await;
let attached = recv_server_frame(&mut rx).await;
assert_eq!(attached.kind, "chat.attached");
assert_eq!(attached.payload["sessionId"], live_session_id.to_string());
assert_eq!(
attached.payload["session"]["sessionId"],
live_session_id.to_string()
);
assert_eq!(attached.payload["scrollback"], json!([]));
assert_eq!(
attached.payload["transcriptUnavailableReason"],
"missingTranscript"
);
assert!(state.ws_chat_bridge.send_output(
&live_session_id,
json!({ "kind": "textDelta", "text": "after restart" })
));
let output = recv_server_frame(&mut rx).await;
assert_eq!(output.kind, "chat.output");
assert_eq!(output.payload["sessionId"], live_session_id.to_string());
assert_eq!(output.payload["chunk"]["text"], "after restart");
assert!(matches!(
owned.lock().unwrap().as_slice(),
[WsAttachment::Chat { session, .. }] if *session == live_session_id
));
}
#[tokio::test]
async fn websocket_chat_attach_stale_session_without_fallback_stays_not_found() {
let state = state();
let stale_session_id = SessionId::new_random();
let (tx, mut rx) = mpsc::channel(4);
let owned = Arc::new(Mutex::new(Vec::new()));
handle_client_frame(
ws_frame(
"chat-attach-missing",
"chat.attach",
json!({ "sessionId": stale_session_id.to_string() }),
),
&state,
&tx,
&owned,
)
.await;
let error = recv_server_frame(&mut rx).await;
assert_eq!(error.kind, "error");
assert_eq!(error.reply_to.as_deref(), Some("chat-attach-missing"));
assert_eq!(error.payload["sessionId"], stale_session_id.to_string());
assert_eq!(error.payload["code"], "NOT_FOUND");
assert_eq!(state.ws_chat_bridge.active_sessions(), 0);
assert!(owned.lock().unwrap().is_empty());
}
#[tokio::test]
async fn websocket_chat_send_acknowledges_first_chunk_and_streams_rest() {
let state = state();
let session_id = SessionId::new_random();
let agent_id = AgentId::from_uuid(Uuid::new_v4());
let node_id = NodeId::from_uuid(Uuid::new_v4());
let session = Arc::new(FakeChatSession::new(
session_id,
vec![
ReplyEvent::TextDelta {
text: "Hello ".to_owned(),
},
ReplyEvent::ToolActivity {
label: "read_file".to_owned(),
},
ReplyEvent::Final {
content: "Hello back".to_owned(),
},
],
));
state.app.structured_sessions.insert(
Arc::clone(&session) as Arc<dyn AgentSession>,
agent_id,
node_id,
);
let (tx, mut rx) = mpsc::channel(16);
let owned = Arc::new(Mutex::new(Vec::new()));
handle_client_frame(
ws_frame(
"chat-send",
"chat.send",
json!({ "sessionId": session_id.to_string(), "prompt": "Salut" }),
),
&state,
&tx,
&owned,
)
.await;
let ack = recv_server_frame(&mut rx).await;
let tool = recv_server_frame(&mut rx).await;
let final_chunk = recv_server_frame(&mut rx).await;
assert_eq!(ack.kind, "chat.output");
assert_eq!(ack.reply_to.as_deref(), Some("chat-send"));
assert_eq!(ack.payload["sessionId"], session_id.to_string());
assert_eq!(ack.payload["chunk"]["kind"], "textDelta");
assert_eq!(ack.payload["chunk"]["text"], "Hello ");
assert!(ack.payload["chunk"]["atMs"].is_number());
assert_eq!(tool.kind, "chat.output");
assert!(tool.reply_to.is_none());
assert_eq!(tool.payload["chunk"]["kind"], "toolActivity");
assert_eq!(tool.payload["chunk"]["label"], "read_file");
assert!(tool.payload["chunk"]["atMs"].is_number());
assert_eq!(final_chunk.kind, "chat.output");
assert!(final_chunk.reply_to.is_none());
assert_eq!(final_chunk.payload["chunk"]["kind"], "final");
assert_eq!(final_chunk.payload["chunk"]["content"], "Hello back");
assert!(final_chunk.payload["chunk"]["atMs"].is_number());
assert_eq!(
session.prompts.lock().unwrap().as_slice(),
&["Salut".to_owned()]
);
}
#[tokio::test]
async fn websocket_chat_send_releases_busy_before_visible_final() {
let state = state();
let session_id = SessionId::new_random();
let agent_id = AgentId::from_uuid(Uuid::new_v4());
let node_id = NodeId::from_uuid(Uuid::new_v4());
let (started_tx, started_rx) = oneshot::channel();
let (release_tx, release_rx) = oneshot::channel();
let session = Arc::new(GatedChatSession::new(session_id, started_tx, release_rx));
state.app.structured_sessions.insert(
Arc::clone(&session) as Arc<dyn AgentSession>,
agent_id,
node_id,
);
let runtime_key = domain::RuntimeAgentKey::new(ProjectId::from_uuid(Uuid::nil()), agent_id);
// Keep the outbound channel full while the provider completes. This makes
// publication of the visible Final block deterministically: the backend must
// become Idle while the terminal frame is still unable to enter the channel.
let (tx, mut rx) = mpsc::channel(1);
tx.send(ServerFrame::chat_output(
None,
session_id,
json!({ "kind": "testBarrier" }),
))
.await
.expect("prefill websocket output barrier");
let owned = Arc::new(Mutex::new(Vec::new()));
let frame = ws_frame(
"chat-send-busy",
"chat.send",
json!({ "sessionId": session_id.to_string(), "prompt": "Work" }),
);
let send_task = tokio::spawn({
let state = Arc::clone(&state);
let tx = tx.clone();
let owned = Arc::clone(&owned);
async move {
handle_client_frame(frame, &state, &tx, &owned).await;
}
});
started_rx.await.expect("gated session started");
assert!(
state.app.turn_watch_input.busy_state(runtime_key).is_busy(),
"direct structured human turn should be busy while send is in flight"
);
release_tx.send(()).expect("release gated session");
tokio::time::timeout(std::time::Duration::from_secs(1), async {
while state.app.turn_watch_input.busy_state(runtime_key).is_busy() {
tokio::task::yield_now().await;
}
})
.await
.expect("busy/ticket accounting must be released while Final publication is still blocked");
let barrier = recv_server_frame(&mut rx).await;
assert_eq!(barrier.payload["chunk"]["kind"], "testBarrier");
let final_chunk = recv_server_frame(&mut rx).await;
assert_eq!(final_chunk.kind, "chat.output");
assert_eq!(final_chunk.reply_to.as_deref(), Some("chat-send-busy"));
assert_eq!(final_chunk.payload["chunk"]["kind"], "final");
assert_eq!(final_chunk.payload["chunk"]["content"], "released");
assert!(final_chunk.payload["chunk"]["atMs"].is_number());
assert!(
!state.app.turn_watch_input.busy_state(runtime_key).is_busy(),
"busy/ticket accounting must be released before Final becomes visible to the client"
);
send_task.await.expect("chat send task finished");
}
#[tokio::test]
async fn websocket_chat_send_relays_tap_progress_live() {
let state = state();
let session_id = SessionId::new_random();
let agent_id = AgentId::from_uuid(Uuid::new_v4());
let node_id = NodeId::from_uuid(Uuid::new_v4());
let session = Arc::new(FakeChatSession::with_tap_events(
session_id,
vec![ReplyEvent::Final {
content: "done".to_owned(),
}],
vec![ReplyEvent::Progress {
progress: ReplyProgress::new(
ReplyProgressSource::IdeaLocal,
ReplyProgressKind::Mcp,
ReplyProgressStage::Started,
"idea_ask_agent",
)
.with_tool_name("idea_ask_agent"),
}],
));
state.app.structured_sessions.insert(
Arc::clone(&session) as Arc<dyn AgentSession>,
agent_id,
node_id,
);
let (tx, mut rx) = mpsc::channel(16);
let owned = Arc::new(Mutex::new(Vec::new()));
handle_client_frame(
ws_frame(
"chat-send-progress",
"chat.send",
json!({ "sessionId": session_id.to_string(), "prompt": "Run tool" }),
),
&state,
&tx,
&owned,
)
.await;
let first = recv_server_frame(&mut rx).await;
let second = recv_server_frame(&mut rx).await;
let frames = [first, second];
let progress = frames
.iter()
.find(|frame| frame.payload["chunk"]["kind"] == "progress")
.expect("tap progress frame was relayed");
let final_chunk = frames
.iter()
.find(|frame| frame.payload["chunk"]["kind"] == "final")
.expect("final frame was relayed");
assert_eq!(progress.kind, "chat.output");
assert!(progress.reply_to.is_none());
assert_eq!(progress.payload["sessionId"], session_id.to_string());
assert_eq!(progress.payload["chunk"]["progress"]["source"], "ideaLocal");
assert_eq!(progress.payload["chunk"]["progress"]["kind"], "mcp");
assert_eq!(progress.payload["chunk"]["progress"]["stage"], "started");
assert_eq!(
progress.payload["chunk"]["progress"]["toolName"],
"idea_ask_agent"
);
assert_eq!(final_chunk.reply_to.as_deref(), Some("chat-send-progress"));
assert_eq!(final_chunk.payload["chunk"]["kind"], "final");
assert_eq!(final_chunk.payload["chunk"]["content"], "done");
assert!(final_chunk.payload["chunk"]["atMs"].is_number());
}
#[tokio::test]
async fn websocket_chat_detach_unregisters_sink_without_killing_session() {
let state = state();
let session_id = SessionId::new_random();
let agent_id = AgentId::from_uuid(Uuid::new_v4());
let node_id = NodeId::from_uuid(Uuid::new_v4());
let session = Arc::new(FakeChatSession::new(
session_id,
vec![ReplyEvent::Final {
content: "still alive".to_owned(),
}],
));
state.app.structured_sessions.insert(
Arc::clone(&session) as Arc<dyn AgentSession>,
agent_id,
node_id,
);
let (tx, mut rx) = mpsc::channel(8);
let owned = Arc::new(Mutex::new(Vec::new()));
handle_client_frame(
ws_frame(
"chat-attach",
"chat.attach",
json!({ "sessionId": session_id.to_string() }),
),
&state,
&tx,
&owned,
)
.await;
let _attached = recv_server_frame(&mut rx).await;
assert_eq!(state.ws_chat_bridge.active_sessions(), 1);
handle_client_frame(
ws_frame(
"chat-detach",
"chat.detach",
json!({ "sessionId": session_id.to_string() }),
),
&state,
&tx,
&owned,
)
.await;
assert_eq!(state.ws_chat_bridge.active_sessions(), 0);
assert!(state.app.structured_sessions.session(&session_id).is_some());
}
#[tokio::test]
async fn websocket_launch_agent_reattach_replays_scrollback_without_respawn() {
let state = state();
let (project_id, agent_id) = create_raw_cli_agent_for_test(&state, "ws-reattach").await;
let node_id = Uuid::new_v4().to_string();
let (tx, mut rx) = mpsc::channel(32);
let owned = Arc::new(Mutex::new(Vec::new()));
handle_client_frame(
ws_frame(
"agent-launch-reattach",
"agent.launch",
agent_launch_payload(&project_id, &agent_id, &node_id, None),
),
&state,
&tx,
&owned,
)
.await;
let launched = recv_server_frame(&mut rx).await;
let session_id = attached_session_id(&launched);
let scrollback = wait_for_scrollback(&state, &session_id, b"agent-ready").await;
drain_server_frames(&mut rx);
handle_client_frame(
ws_frame(
"agent-attach",
"terminal.attach",
json!({ "sessionId": session_id, "lastSeq": 0, "rows": 24, "cols": 80 }),
),
&state,
&tx,
&owned,
)
.await;
let attached = recv_server_frame_where(&mut rx, |frame| {
frame.kind == "terminal.attached" && frame.reply_to.as_deref() == Some("agent-attach")
})
.await;
let replay = attached.payload["scrollback"].as_array().unwrap();
assert_eq!(attached.kind, "terminal.attached");
assert_eq!(attached.payload["session"]["sessionId"], session_id);
assert_eq!(
base64_decode(replay[0]["bytesBase64"].as_str().unwrap()).unwrap(),
scrollback
);
let aid = parse_agent_id(&agent_id).unwrap();
let pid = parse_project_id(&project_id).unwrap();
assert_eq!(
state
.app
.terminal_sessions
.sessions_for_agent_in_project(pid, &aid)
.len(),
1
);
close_ws_session(&state, &session_id).await;
}
#[tokio::test]
async fn websocket_launch_agent_same_cell_is_idempotent_singleton() {
let state = state();
let (project_id, agent_id) = create_raw_cli_agent_for_test(&state, "ws-singleton").await;
let node_id = Uuid::new_v4().to_string();
let (tx, mut rx) = mpsc::channel(32);
let owned = Arc::new(Mutex::new(Vec::new()));
handle_client_frame(
ws_frame(
"agent-launch-first",
"agent.launch",
agent_launch_payload(&project_id, &agent_id, &node_id, None),
),
&state,
&tx,
&owned,
)
.await;
let first = recv_server_frame(&mut rx).await;
let first_session = attached_session_id(&first);
handle_client_frame(
ws_frame(
"agent-launch-second",
"agent.launch",
agent_launch_payload(
&project_id,
&agent_id,
&node_id,
first.payload["assignedConversationId"].as_str(),
),
),
&state,
&tx,
&owned,
)
.await;
let second = recv_server_frame_where(&mut rx, |frame| {
frame.kind == "terminal.attached"
&& frame.reply_to.as_deref() == Some("agent-launch-second")
})
.await;
assert_eq!(second.kind, "terminal.attached");
assert_eq!(attached_session_id(&second), first_session);
let aid = parse_agent_id(&agent_id).unwrap();
let pid = parse_project_id(&project_id).unwrap();
assert_eq!(
state
.app
.terminal_sessions
.sessions_for_agent_in_project(pid, &aid)
.len(),
1
);
close_ws_session(&state, &first_session).await;
}
#[tokio::test]
async fn websocket_launch_agent_different_cell_is_refused_by_singleton_guard() {
let state = state();
let (project_id, agent_id) =
create_raw_cli_agent_for_test(&state, "ws-singleton-refuse").await;
let first_node = Uuid::new_v4().to_string();
let second_node = Uuid::new_v4().to_string();
let (tx, mut rx) = mpsc::channel(32);
let owned = Arc::new(Mutex::new(Vec::new()));
handle_client_frame(
ws_frame(
"agent-launch-first-cell",
"agent.launch",
agent_launch_payload(&project_id, &agent_id, &first_node, None),
),
&state,
&tx,
&owned,
)
.await;
let first = recv_server_frame(&mut rx).await;
let session_id = attached_session_id(&first);
handle_client_frame(
ws_frame(
"agent-launch-other-cell",
"agent.launch",
agent_launch_payload(&project_id, &agent_id, &second_node, None),
),
&state,
&tx,
&owned,
)
.await;
let error = recv_server_frame_where(&mut rx, |frame| {
frame.kind == "error" && frame.reply_to.as_deref() == Some("agent-launch-other-cell")
})
.await;
assert_eq!(error.kind, "error");
assert_eq!(error.reply_to.as_deref(), Some("agent-launch-other-cell"));
assert_eq!(error.payload["code"], "AGENT_ALREADY_RUNNING");
let aid = parse_agent_id(&agent_id).unwrap();
let pid = parse_project_id(&project_id).unwrap();
assert_eq!(
state
.app
.terminal_sessions
.sessions_for_agent_in_project(pid, &aid)
.len(),
1
);
close_ws_session(&state, &session_id).await;
}
#[test]
fn websocket_client_unmasked_frame_is_rejected() {
let raw = [0x81, 0x02, b'h', b'i'];
let err = decode_client_ws_frame(&raw).unwrap_err();
assert!(matches!(err, WsFrameError::Protocol(_)));
}
#[test]
fn websocket_payload_too_large_is_rejected() {
let len = (WS_MAX_PAYLOAD + 1) as u64;
let mut raw = vec![0x81, 0x80 | 127];
raw.extend_from_slice(&len.to_be_bytes());
raw.extend_from_slice(&[1, 2, 3, 4]);
let err = decode_client_ws_frame(&raw).unwrap_err();
assert_eq!(err, WsFrameError::TooLarge);
}
#[test]
fn websocket_server_frames_are_unmasked() {
let raw = encode_ws_frame(WsOpcode::Text, b"{}");
assert_eq!(raw[0], 0x81);
assert_eq!(raw[1] & 0x80, 0, "server frames must not be masked");
}
#[tokio::test]
async fn websocket_sink_full_reports_backpressure_without_blocking() {
let (tx, mut rx) = mpsc::channel(1);
let sid = domain::SessionId::new_random();
let sink = WsPtySink::new(sid, tx, 0);
assert!(sink.send(vec![1]).is_ok());
assert_eq!(sink.send(vec![2]), Err(OutputSinkError::Full));
let frame = rx.recv().await.unwrap();
assert_eq!(frame.kind, "terminal.output");
}
#[tokio::test]
async fn websocket_output_bridge_replaces_attachment_without_double_delivery() {
let bridge = OutputBridge::<SessionId, PtyChunk>::new();
let sid = domain::SessionId::new_random();
let (old_tx, mut old_rx) = mpsc::channel(4);
let (new_tx, mut new_rx) = mpsc::channel(4);
let old_gen = bridge.register(sid, Arc::new(WsPtySink::new(sid, old_tx, 0)));
let _new_gen = bridge.register(sid, Arc::new(WsPtySink::new(sid, new_tx, 0)));
assert!(bridge.send_output(&sid, vec![9]));
assert!(old_rx.try_recv().is_err());
let frame = new_rx.recv().await.unwrap();
assert_eq!(frame.kind, "terminal.output");
assert_eq!(frame.payload["bytesBase64"], "CQ==");
bridge.unregister_if(&sid, old_gen);
assert_eq!(bridge.active_sessions(), 1);
}
#[test]
fn public_bind_requires_explicit_remote_security() {
let config = ServerConfig {
listen: "0.0.0.0:17373".parse().unwrap(),
..test_config()
};
assert!(config.validate().is_err());
let config = ServerConfig {
allow_remote: true,
public_origin: Some("https://idea.example.com".to_owned()),
trust_reverse_proxy: true,
..config
};
let err = config.validate().unwrap_err();
assert!(err.contains("--trusted-proxy <proxy-ip-or-cidr>"));
assert!(!err.contains("192.168.1.22"));
let config = ServerConfig {
trusted_proxies: vec![trusted_proxy("192.168.1.22")],
..config
};
assert!(config.validate().is_ok());
}
#[test]
fn loopback_remote_reverse_proxy_does_not_require_trusted_proxy() {
let config = ServerConfig {
listen: "127.0.0.1:17373".parse().unwrap(),
allow_remote: true,
public_origin: Some("https://idea.example.com".to_owned()),
trust_reverse_proxy: true,
trusted_proxies: Vec::new(),
..test_config()
};
assert!(config.validate().is_ok());
}
#[test]
fn effective_listen_replaces_port_zero_for_origin_checks() {
let config = ServerConfig {
listen: "127.0.0.1:0".parse().unwrap(),
..test_config()
};
let effective = config_with_effective_listen(config, "127.0.0.1:43689".parse().unwrap());
assert!(origin_allowed("http://127.0.0.1:43689", &effective));
assert!(!origin_allowed("http://127.0.0.1:0", &effective));
}
#[test]
fn allow_remote_requires_https_origin_and_reverse_proxy() {
let base = ServerConfig {
listen: "0.0.0.0:17373".parse().unwrap(),
allow_remote: true,
..test_config()
};
assert!(base.validate().is_err());
let with_http = ServerConfig {
public_origin: Some("http://idea.example.com".to_owned()),
trust_reverse_proxy: true,
..base.clone()
};
assert!(with_http.validate().is_err());
let without_proxy = ServerConfig {
public_origin: Some("https://idea.example.com".to_owned()),
trust_reverse_proxy: false,
..base
};
assert!(without_proxy.validate().is_err());
}
#[tokio::test]
async fn post_pair_requires_origin_header() {
let state = state();
let (status, body, _) = response_json(
request_without_origin(
state,
Method::POST,
"/api/pair",
json!({ "code": "PAIR1234", "name": "Test device" }),
&[],
)
.await,
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["message"], "missing Origin header");
}
#[tokio::test]
async fn remote_bind_rejects_peer_when_no_authorized_proxy_is_configured() {
let logger = Arc::new(RecordingSecurityLogger::default());
let config = ServerConfig {
trusted_proxies: Vec::new(),
..remote_config()
};
let state = Arc::new(ServerState::new_for_test_with_logger(
config,
"PAIR1234",
logger.clone(),
));
let response = remote_pair_request(
state,
"192.168.1.22",
"https://idea.example.com",
"https",
Some("idea.example.com"),
&[],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert!(body["message"]
.as_str()
.is_some_and(|message| message.contains("came from 192.168.1.22")
&& message.contains("no authorized proxy is configured")
&& message.contains("--trusted-proxy 192.168.1.22")));
assert!(logger
.events()
.iter()
.any(|event| matches!(event, SecurityLogEvent::UntrustedProxyPeer { peer, .. } if *peer == "192.168.1.22".parse::<IpAddr>().unwrap())));
}
#[tokio::test]
async fn remote_bind_rejects_untrusted_proxy_peer_without_using_x_forwarded_for() {
let logger = Arc::new(RecordingSecurityLogger::default());
let state = Arc::new(ServerState::new_for_test_with_logger(
remote_config(),
"PAIR1234",
logger.clone(),
));
let response = remote_pair_request(
state,
"192.168.1.23",
"https://idea.example.com",
"https",
Some("idea.example.com"),
&[("x-forwarded-for", "192.168.1.22")],
)
.await;
let (status, _, _) = response_json(response).await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert!(logger.events().iter().any(|event| matches!(
event,
SecurityLogEvent::UntrustedProxyPeer {
peer,
forwarded_for: Some(forwarded_for)
} if *peer == "192.168.1.23".parse::<IpAddr>().unwrap() && forwarded_for == "192.168.1.22"
)));
}
#[tokio::test]
async fn trusted_proxy_requires_forwarded_https_proto() {
let logger = Arc::new(RecordingSecurityLogger::default());
let state = Arc::new(ServerState::new_for_test_with_logger(
remote_config(),
"PAIR1234",
logger.clone(),
));
let response = remote_pair_request(
state,
"192.168.1.22",
"https://idea.example.com",
"http",
Some("idea.example.com"),
&[],
)
.await;
let (status, _, _) = response_json(response).await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert!(logger.events().iter().any(|event| matches!(
event,
SecurityLogEvent::ForwardedProtoRejected {
peer,
proto: Some(proto)
} if *peer == "192.168.1.22".parse::<IpAddr>().unwrap() && proto == "http"
)));
}
#[tokio::test]
async fn trusted_proxy_allows_forwarded_host_mismatch_with_warning() {
let logger = Arc::new(RecordingSecurityLogger::default());
let state = Arc::new(ServerState::new_for_test_with_logger(
remote_config(),
"PAIR1234",
logger.clone(),
));
let response = remote_pair_request(
state,
"192.168.1.22",
"https://idea.example.com",
"https",
Some("other.example.com"),
&[],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["paired"], true);
assert!(logger.events().iter().any(|event| matches!(
event,
SecurityLogEvent::ForwardedHostMismatch {
peer,
host: Some(host),
expected
} if *peer == "192.168.1.22".parse::<IpAddr>().unwrap()
&& host == "other.example.com"
&& expected == "idea.example.com"
)));
}
#[tokio::test]
async fn trusted_proxy_allows_missing_forwarded_host_with_warning() {
let logger = Arc::new(RecordingSecurityLogger::default());
let state = Arc::new(ServerState::new_for_test_with_logger(
remote_config(),
"PAIR1234",
logger.clone(),
));
let response = remote_pair_request(
state,
"192.168.1.22",
"https://idea.example.com",
"https",
None,
&[],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["paired"], true);
assert!(logger.events().iter().any(|event| matches!(
event,
SecurityLogEvent::ForwardedHostMismatch {
peer,
host: None,
expected
} if *peer == "192.168.1.22".parse::<IpAddr>().unwrap()
&& expected == "idea.example.com"
)));
}
#[tokio::test]
async fn trusted_proxy_with_https_and_matching_host_allows_remote_api() {
let state = Arc::new(ServerState::new_for_test(remote_config(), "PAIR1234"));
let response = remote_pair_request(
state,
"192.168.1.22",
"https://idea.example.com",
"https",
Some("idea.example.com"),
&[("x-forwarded-for", "203.0.113.44")],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["paired"], true);
}
#[tokio::test]
async fn trusted_proxy_still_rejects_non_matching_api_origin() {
let state = Arc::new(ServerState::new_for_test(remote_config(), "PAIR1234"));
let response = remote_pair_request(
state,
"192.168.1.22",
"https://evil.example",
"https",
Some("idea.example.com"),
&[],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["message"], "origin not allowed");
}
#[tokio::test]
async fn invoke_requires_valid_cookie() {
let state = state();
let response = request(
state,
Method::POST,
"/api/invoke",
json!({ "command": "health", "args": {} }),
&[],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(body["code"], "UNAUTHORIZED");
}
#[tokio::test]
async fn invoke_rejects_invalid_session_cookie() {
let state = state();
let response = request(
state,
Method::POST,
"/api/invoke",
json!({ "command": "health", "args": {} }),
&[("cookie", "idea_session=not-a-valid-session")],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(body["code"], "UNAUTHORIZED");
}
#[tokio::test]
async fn logout_revokes_session_for_http_and_websocket() {
let state = state();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let device_id = authenticated_device_id(&state, &cookie).await;
let registration = state.active_connections.register(device_id);
let response = request(
Arc::clone(&state),
Method::POST,
"/api/logout",
json!({}),
&[("cookie", &cookie)],
)
.await;
let (status, body, headers) = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, json!({ "revoked": true }));
assert!(headers
.get(SET_COOKIE)
.unwrap()
.to_str()
.unwrap()
.contains("Max-Age=0"));
let response = request(
Arc::clone(&state),
Method::POST,
"/api/invoke",
json!({ "command": "health", "args": {} }),
&[("cookie", &cookie)],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(body["code"], "UNAUTHORIZED");
let headers = ws_headers("http://127.0.0.1:17373", Some(&cookie));
let response = validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state)
.await
.unwrap_err();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
tokio::time::timeout(Duration::from_secs(2), registration.shutdown)
.await
.expect("logout revocation closes active connection")
.expect("shutdown sender is delivered");
}
#[tokio::test]
async fn devices_list_rename_and_revoke_expose_safe_contract() {
let state = state();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let response = request(
Arc::clone(&state),
Method::GET,
"/api/devices",
json!({}),
&[("cookie", &cookie)],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::OK);
let devices = body["devices"].as_array().unwrap();
assert_eq!(devices.len(), 1);
let row = devices[0].as_object().unwrap();
assert_eq!(row["name"], "Test device");
assert_eq!(row["isCurrentDevice"], true);
assert!(row.contains_key("deviceId"));
assert!(row["pairedAtMs"].is_number());
assert!(row["lastSeenAtMs"].is_number());
assert!(!row.contains_key("sessionTokenHash"));
assert!(!row.contains_key("ip"));
assert!(!row.contains_key("userAgent"));
let device_id = row["deviceId"].as_str().unwrap().to_owned();
let response = request(
Arc::clone(&state),
Method::POST,
&format!("/api/devices/{device_id}/rename"),
json!({ "name": "Renamed phone" }),
&[("cookie", &cookie)],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, json!({ "renamed": true }));
let response = request(
Arc::clone(&state),
Method::GET,
"/api/devices",
json!({}),
&[("cookie", &cookie)],
)
.await;
let (_, body, _) = response_json(response).await;
assert_eq!(body["devices"][0]["name"], "Renamed phone");
let response = request(
Arc::clone(&state),
Method::POST,
&format!("/api/devices/{device_id}/revoke"),
json!({}),
&[("cookie", &cookie)],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, json!({ "revoked": true }));
let response = request(
Arc::clone(&state),
Method::GET,
"/api/devices",
json!({}),
&[("cookie", &cookie)],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(body["code"], "UNAUTHORIZED");
}
#[tokio::test]
async fn device_revocation_closes_only_matching_active_connections() {
let state = state();
let cookie_a = pair_and_cookie_with(Arc::clone(&state), "PAIR1234", "Phone A").await;
state.set_pairing_code_for_test("PAIR5678".to_owned());
let cookie_b = pair_and_cookie_with(Arc::clone(&state), "PAIR5678", "Phone B").await;
let device_a = authenticated_device_id(&state, &cookie_a).await;
let device_b = authenticated_device_id(&state, &cookie_b).await;
let registration_a = state.active_connections.register(device_a);
let mut registration_b = state.active_connections.register(device_b);
let response = request(
Arc::clone(&state),
Method::POST,
&format!("/api/devices/{device_a}/revoke"),
json!({}),
&[("cookie", &cookie_b)],
)
.await;
let (status, _, _) = response_json(response).await;
assert_eq!(status, StatusCode::OK);
tokio::time::timeout(Duration::from_secs(2), registration_a.shutdown)
.await
.expect("revoked device connection closes")
.expect("shutdown sender is delivered");
assert!(
tokio::time::timeout(Duration::from_millis(100), &mut registration_b.shutdown)
.await
.is_err()
);
assert_eq!(state.active_connections.active_count(), 1);
}
#[tokio::test]
async fn revoke_all_closes_all_active_connections() {
let state = state();
let cookie_a = pair_and_cookie_with(Arc::clone(&state), "PAIR1234", "Phone A").await;
state.set_pairing_code_for_test("PAIR5678".to_owned());
let cookie_b = pair_and_cookie_with(Arc::clone(&state), "PAIR5678", "Phone B").await;
let device_a = authenticated_device_id(&state, &cookie_a).await;
let device_b = authenticated_device_id(&state, &cookie_b).await;
let registration_a = state.active_connections.register(device_a);
let registration_b = state.active_connections.register(device_b);
let response = request(
Arc::clone(&state),
Method::POST,
"/api/devices/revoke-all",
json!({}),
&[("cookie", &cookie_a)],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, json!({ "revoked": true }));
tokio::time::timeout(Duration::from_secs(2), registration_a.shutdown)
.await
.expect("first connection closes")
.expect("shutdown sender is delivered");
tokio::time::timeout(Duration::from_secs(2), registration_b.shutdown)
.await
.expect("second connection closes")
.expect("shutdown sender is delivered");
assert_eq!(state.active_connections.active_count(), 0);
}
#[tokio::test]
async fn security_logs_pairing_origin_ws_and_revoke_without_secrets() {
let logger = Arc::new(RecordingSecurityLogger::default());
let state = Arc::new(ServerState::new_for_test_with_logger(
test_config(),
"PAIR1234",
logger.clone(),
));
let bad_origin = handle_request(
Request::builder()
.method(Method::POST)
.uri("/api/pair")
.header(ORIGIN, "https://evil.example")
.header(CONTENT_TYPE, "application/json")
.body(Full::new(Bytes::from_static(
br#"{"code":"PAIR1234","name":"Test device"}"#,
)))
.unwrap(),
Arc::clone(&state),
)
.await;
assert_eq!(bad_origin.status(), StatusCode::FORBIDDEN);
let wrong_code = request(
Arc::clone(&state),
Method::POST,
"/api/pair",
json!({ "code": "WRONG999", "name": "Test device" }),
&[],
)
.await;
assert_eq!(wrong_code.status(), StatusCode::FORBIDDEN);
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let _ = request(
Arc::clone(&state),
Method::POST,
"/api/logout",
json!({}),
&[("cookie", &cookie)],
)
.await;
let headers = ws_headers(
"http://127.0.0.1:17373",
Some("idea_session=invalid-token-value"),
);
let _ = validate_ws_upgrade(&headers, Some("127.0.0.1".parse().unwrap()), &state).await;
let events = logger.events();
assert!(events.iter().any(|event| matches!(
event,
SecurityLogEvent::OriginRejected { route, .. } if route == "/api/pair"
)));
assert!(events.iter().any(|event| matches!(
event,
SecurityLogEvent::PairingFailed {
reason: "invalid_or_expired",
..
}
)));
assert!(events
.iter()
.any(|event| matches!(event, SecurityLogEvent::PairingSucceeded { .. })));
assert!(events
.iter()
.any(|event| matches!(event, SecurityLogEvent::SessionRevoked { .. })));
assert!(events.iter().any(|event| matches!(
event,
SecurityLogEvent::WsUpgradeRejected {
reason: "invalid_session",
..
}
)));
let lines = events.iter().map(security_log_line).collect::<Vec<_>>();
assert!(
!lines.iter().any(|line| line.contains("PAIR1234")
|| line.contains("WRONG999")
|| line.contains("invalid-token-value")),
"security logs must not leak pairing codes or session tokens"
);
}
#[tokio::test]
async fn pairing_rejects_wrong_code() {
let state = state();
let response = request(
state,
Method::POST,
"/api/pair",
json!({ "code": "WRONG999", "name": "Test device" }),
&[],
)
.await;
let (status, body, headers) = response_json(response).await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["code"], "invalid_or_expired");
assert!(
!headers.contains_key(SET_COOKIE),
"wrong pairing code must not issue a session cookie"
);
}
#[tokio::test]
async fn pairing_rejects_when_no_code_exists_at_boot() {
let state = Arc::new(ServerState::new(test_config()));
let response = request(
state,
Method::POST,
"/api/pair",
json!({ "code": "PAIR1234", "name": "Test device" }),
&[],
)
.await;
let (status, body, headers) = response_json(response).await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["code"], "invalid_or_expired");
assert!(!headers.contains_key(SET_COOKIE));
}
#[tokio::test]
async fn pairing_code_expires_and_returns_public_invalid_or_expired() {
let state = state();
state.expire_pairing_code_for_test();
let response = request(
state,
Method::POST,
"/api/pair",
json!({ "code": "PAIR1234", "name": "Test device" }),
&[],
)
.await;
let (status, body, headers) = response_json(response).await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["code"], "invalid_or_expired");
assert!(!headers.contains_key(SET_COOKIE));
}
#[tokio::test]
async fn pairing_code_is_single_use() {
let state = state();
let first = request(
Arc::clone(&state),
Method::POST,
"/api/pair",
json!({ "code": "PAIR1234", "name": "First device" }),
&[],
)
.await;
let (first_status, _, first_headers) = response_json(first).await;
let second = request(
state,
Method::POST,
"/api/pair",
json!({ "code": "PAIR1234", "name": "Second device" }),
&[],
)
.await;
let (second_status, second_body, second_headers) = response_json(second).await;
assert_eq!(first_status, StatusCode::OK);
assert!(first_headers.contains_key(SET_COOKIE));
assert_eq!(second_status, StatusCode::FORBIDDEN);
assert_eq!(second_body["code"], "invalid_or_expired");
assert!(!second_headers.contains_key(SET_COOKIE));
}
#[tokio::test]
async fn generating_new_pairing_code_invalidates_previous_code() {
let state = Arc::new(ServerState::new(test_config()));
let old = state.generate_pairing_code();
let new = state.generate_pairing_code();
let old_response = request(
Arc::clone(&state),
Method::POST,
"/api/pair",
json!({ "code": old.code, "name": "Old device" }),
&[],
)
.await;
let (old_status, old_body, _) = response_json(old_response).await;
let new_response = request(
state,
Method::POST,
"/api/pair",
json!({ "code": new.code, "name": "New device" }),
&[],
)
.await;
let (new_status, _, new_headers) = response_json(new_response).await;
assert_eq!(old_status, StatusCode::FORBIDDEN);
assert_eq!(old_body["code"], "invalid_or_expired");
assert_eq!(new_status, StatusCode::OK);
assert!(new_headers.contains_key(SET_COOKIE));
}
#[tokio::test]
async fn pairing_code_endpoint_requires_auth_and_returns_ttl() {
let state = state();
let unauthenticated = request(
Arc::clone(&state),
Method::POST,
"/api/pairing-code",
json!({}),
&[],
)
.await;
let (unauth_status, _, _) = response_json(unauthenticated).await;
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let response = request(
state,
Method::POST,
"/api/pairing-code",
json!({}),
&[("cookie", &cookie)],
)
.await;
let (status, body, headers) = response_json(response).await;
assert_eq!(unauth_status, StatusCode::UNAUTHORIZED);
assert_eq!(status, StatusCode::OK);
assert_eq!(body["ttlSeconds"], 600);
assert_eq!(body["code"].as_str().unwrap().len(), 8);
assert!(headers.contains_key(SET_COOKIE));
}
#[tokio::test]
async fn invalid_or_expired_response_does_not_leak_cause() {
let absent = Arc::new(ServerState::new(test_config()));
let expired = state();
expired.expire_pairing_code_for_test();
let used = state();
let _ = pair_and_cookie(Arc::clone(&used)).await;
let cases = [
(absent, json!({ "code": "PAIR1234", "name": "Absent" })),
(expired, json!({ "code": "PAIR1234", "name": "Expired" })),
(used, json!({ "code": "PAIR1234", "name": "Used" })),
(state(), json!({ "code": "WRONG999", "name": "Wrong" })),
];
let mut bodies = Vec::new();
for (state, body) in cases {
let response = request(state, Method::POST, "/api/pair", body, &[]).await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::FORBIDDEN);
bodies.push(body);
}
assert!(bodies
.iter()
.all(|body| body["code"] == "invalid_or_expired"));
assert!(bodies.windows(2).all(|pair| pair[0] == pair[1]));
}
#[tokio::test]
async fn pairing_rate_limits_failures_by_origin() {
let state = state();
for _ in 0..5 {
let response = request(
Arc::clone(&state),
Method::POST,
"/api/pair",
json!({ "code": "WRONG999", "name": "Test device" }),
&[],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["code"], "invalid_or_expired");
}
let response = request(
state,
Method::POST,
"/api/pair",
json!({ "code": "WRONG999", "name": "Test device" }),
&[],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::TOO_MANY_REQUESTS);
assert_eq!(body["code"], "rate_limited");
}
#[tokio::test]
async fn pairing_sets_http_only_strict_cookie_for_loopback_dev() {
let state = state();
let response = request(
state,
Method::POST,
"/api/pair",
json!({ "code": "PAIR1234", "name": "Test device" }),
&[],
)
.await;
let (status, body, headers) = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, json!({ "paired": true }));
let cookie = headers.get(SET_COOKIE).unwrap().to_str().unwrap();
assert!(cookie.contains("idea_session="));
assert!(cookie.contains("HttpOnly"));
assert!(cookie.contains("SameSite=Strict"));
assert!(cookie.contains("Max-Age=34560000"));
assert!(
!cookie.contains("Secure"),
"loopback dev over HTTP deliberately avoids Secure so browsers store it"
);
}
#[tokio::test]
async fn authenticated_invoke_renews_session_cookie_max_age() {
let state = state();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let response = request(
state,
Method::POST,
"/api/invoke",
json!({ "command": "health", "args": {} }),
&[("cookie", &cookie)],
)
.await;
let (status, _, headers) = response_json(response).await;
assert_eq!(status, StatusCode::OK);
let renewed = headers.get(SET_COOKIE).unwrap().to_str().unwrap();
assert!(renewed.contains(&cookie));
assert!(renewed.contains("Max-Age=34560000"));
assert!(renewed.contains("HttpOnly"));
assert!(renewed.contains("SameSite=Strict"));
}
#[tokio::test]
async fn pairing_rejects_missing_device_name() {
let state = state();
let response = request(
Arc::clone(&state),
Method::POST,
"/api/pair",
json!({ "code": "PAIR1234", "name": "" }),
&[],
)
.await;
let (status, body, headers) = response_json(response).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["code"], "invalid_name");
assert!(!headers.contains_key(SET_COOKIE));
let response = request(
state,
Method::POST,
"/api/pair",
json!({ "code": "PAIR1234", "name": "Recovered device" }),
&[],
)
.await;
let (status, body, headers) = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, json!({ "paired": true }));
assert!(headers.contains_key(SET_COOKIE));
}
#[tokio::test]
async fn pairing_sets_secure_cookie_for_remote_https_origin() {
let config = ServerConfig {
listen: "0.0.0.0:17373".parse().unwrap(),
public_origin: Some("https://idea.example.com".to_owned()),
allow_remote: true,
trust_reverse_proxy: true,
trusted_proxies: vec![trusted_proxy("192.168.1.22")],
..test_config()
};
let state = Arc::new(ServerState::new_for_test(config, "PAIR1234"));
let mut req = Request::builder()
.method(Method::POST)
.uri("/api/pair")
.header(ORIGIN, "https://idea.example.com")
.header("x-forwarded-proto", "https")
.header("x-forwarded-host", "idea.example.com")
.header(CONTENT_TYPE, "application/json")
.body(Full::new(Bytes::from_static(
br#"{"code":"PAIR1234","name":"Test device"}"#,
)))
.unwrap();
*req.headers_mut().get_mut(CONTENT_TYPE).unwrap() =
HeaderValue::from_static("application/json");
let response =
handle_request_from_peer(req, state, Some("192.168.1.22".parse().unwrap())).await;
let (status, _, headers) = response_json(response).await;
assert_eq!(status, StatusCode::OK);
let cookie = headers.get(SET_COOKIE).unwrap().to_str().unwrap();
assert!(cookie.contains("Secure"));
assert!(cookie.contains("HttpOnly"));
assert!(cookie.contains("SameSite=Strict"));
assert!(cookie.contains("Max-Age=34560000"));
}
#[tokio::test]
async fn token_or_secret_in_query_string_is_refused() {
let state = state();
let response = request(
state,
Method::POST,
"/api/invoke?token=abc",
json!({ "command": "health", "args": {} }),
&[],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["code"], "INVALID");
}
#[tokio::test]
async fn serves_web_index_same_origin_with_csp() {
let state = state();
let response = request(state, Method::GET, "/", json!({}), &[]).await;
let (status, body, headers) = response_bytes(response).await;
assert_eq!(status, StatusCode::OK);
assert!(std::str::from_utf8(&body).unwrap().contains("id=\"root\""));
assert_eq!(
headers.get(CONTENT_TYPE).unwrap(),
"text/html; charset=utf-8"
);
assert!(headers.contains_key("content-security-policy"));
assert_eq!(headers.get("x-content-type-options").unwrap(), "nosniff");
}
#[tokio::test]
async fn serves_static_assets_with_mime_types() {
let state = state();
let response = request(state, Method::GET, "/assets/app.js", json!({}), &[]).await;
let (status, body, headers) = response_bytes(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(std::str::from_utf8(&body).unwrap(), "console.log('idea');");
assert_eq!(
headers.get(CONTENT_TYPE).unwrap(),
"text/javascript; charset=utf-8"
);
}
#[tokio::test]
async fn serves_spa_fallback_for_non_api_routes_without_extension() {
let state = state();
let response = request(state, Method::GET, "/projects/abc", json!({}), &[]).await;
let (status, body, headers) = response_bytes(response).await;
assert_eq!(status, StatusCode::OK);
assert!(std::str::from_utf8(&body).unwrap().contains("id=\"root\""));
assert!(headers.contains_key("content-security-policy"));
}
#[tokio::test]
async fn missing_asset_and_api_routes_do_not_fallback_to_spa() {
let state = state();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let missing_asset = request(
Arc::clone(&state),
Method::GET,
"/assets/missing.js",
json!({}),
&[],
)
.await;
let (asset_status, asset_body, _) = response_json(missing_asset).await;
assert_eq!(asset_status, StatusCode::NOT_FOUND);
assert_eq!(asset_body["code"], "NOT_FOUND");
let api_unknown = request(
state,
Method::GET,
"/api/unknown",
json!({}),
&[("cookie", &cookie)],
)
.await;
let (api_status, api_body, _) = response_json(api_unknown).await;
assert_eq!(api_status, StatusCode::NOT_FOUND);
assert_eq!(api_body["code"], "NOT_FOUND");
}
#[test]
fn static_route_rejects_traversal_and_dotfiles() {
let root = PathBuf::from("/tmp/web");
assert!(static_route(&root, "/../secret").is_none());
assert!(static_route(&root, "/%2e%2e/secret").is_none());
assert!(static_route(&root, "/.env").is_none());
assert_eq!(
static_route(&root, "/dashboard").unwrap(),
StaticRoute {
candidate: root.join("dashboard"),
spa_fallback: true
}
);
assert_eq!(
static_route(&root, "/assets/app.js").unwrap(),
StaticRoute {
candidate: root.join("assets").join("app.js"),
spa_fallback: false
}
);
}
#[test]
fn explicit_web_root_requires_index_html() {
let missing = std::env::temp_dir().join(format!("idea-empty-web-{}", Uuid::new_v4()));
std::fs::create_dir_all(&missing).unwrap();
assert!(validate_web_root(missing, "--web-root").is_err());
assert!(validate_web_root(create_web_root(), "--web-root").is_ok());
}
#[test]
fn web_root_candidates_include_appimage_resource_dir() {
let appdir = PathBuf::from("/tmp/.mount_IdeA_test");
let candidates = web_root_candidates_from_appdir(&appdir);
assert!(candidates.contains(&appdir.join("usr").join("lib").join("IdeA").join("web")));
assert!(candidates.contains(&appdir.join("web")));
}
#[tokio::test]
async fn non_allowed_origin_is_refused() {
let state = state();
let mut req = Request::builder()
.method(Method::POST)
.uri("/api/pair")
.header(ORIGIN, "https://evil.example")
.header(CONTENT_TYPE, "application/json")
.body(Full::new(Bytes::from_static(
br#"{"code":"PAIR1234","name":"Test device"}"#,
)))
.unwrap();
*req.headers_mut().get_mut(CONTENT_TYPE).unwrap() =
HeaderValue::from_static("application/json");
let response = handle_request(req, state).await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["code"], "FORBIDDEN");
}
#[tokio::test]
async fn unknown_allowlisted_command_returns_unknown_command() {
let state = state();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let response = request(
state,
Method::POST,
"/api/invoke",
json!({ "command": "debug_dump", "args": {} }),
&[("cookie", &cookie)],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["code"], "UNKNOWN_COMMAND");
}
#[tokio::test]
async fn authorized_health_invoke_returns_health_report() {
let state = state();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let response = request(
state,
Method::POST,
"/api/invoke",
json!({ "command": "health", "args": { "request": { "note": "hi" } } }),
&[("cookie", &cookie)],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["alive"], true);
assert_eq!(body["note"], "hi");
}
#[tokio::test]
async fn authorized_list_projects_returns_tauri_project_list_contract() {
let state = state();
let project_id = create_project_for_test(&state, "Web Read").await;
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let response = request(
state,
Method::POST,
"/api/invoke",
json!({ "command": "list_projects", "args": {} }),
&[("cookie", &cookie)],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::OK);
let projects = body
.as_array()
.expect("ProjectListDto is a transparent array");
let project = projects
.iter()
.find(|project| project["id"] == project_id)
.expect("created project is listed");
assert_eq!(project["name"], "Web Read");
assert!(project["root"]
.as_str()
.is_some_and(|root| root.contains("idea-server-project-")));
}
#[tokio::test]
async fn authorized_remove_project_returns_common_contract_and_keeps_files() {
let state = state();
let project_id = create_project_for_test(&state, "Remove Web").await;
let project = state
.app
.project_store
.load_project(parse_project_id(&project_id).unwrap())
.await
.unwrap();
state
.app
.record_project_opened_for_restore
.execute(project.clone())
.await
.unwrap();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let response = request(
Arc::clone(&state),
Method::POST,
"/api/invoke",
json!({
"command": "remove_project",
"args": { "request": { "projectId": project_id } }
}),
&[("cookie", &cookie)],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["removed"]["id"], project_id);
assert_eq!(body["projects"], json!([]));
assert_eq!(body["restoreState"]["entries"], json!([]));
assert_eq!(body["wasOpen"], true);
assert!(std::path::Path::new(project.root.as_str()).exists());
let repeated = request(
state,
Method::POST,
"/api/invoke",
json!({
"command": "remove_project",
"args": { "request": { "projectId": project_id } }
}),
&[("cookie", &cookie)],
)
.await;
let (status, body, _) = response_json(repeated).await;
assert_eq!(status, StatusCode::NOT_FOUND);
assert_eq!(body["code"], "NOT_FOUND");
let _ = std::fs::remove_dir_all(project.root.as_str());
}
#[tokio::test]
async fn remove_project_rejects_invalid_id() {
let state = state();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let response = request(
state,
Method::POST,
"/api/invoke",
json!({
"command": "remove_project",
"args": { "request": { "projectId": "not-a-uuid" } }
}),
&[("cookie", &cookie)],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["code"], "INVALID");
}
#[tokio::test]
async fn authorized_open_project_restore_commands_share_the_tauri_contract() {
let state = state();
let project_id = create_project_for_test(&state, "Restore Web").await;
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let opened = request(
Arc::clone(&state),
Method::POST,
"/api/invoke",
json!({ "command": "open_project", "args": { "projectId": project_id } }),
&[("cookie", &cookie)],
)
.await;
assert_eq!(response_json(opened).await.0, StatusCode::OK);
let restored = request(
Arc::clone(&state),
Method::POST,
"/api/invoke",
json!({ "command": "restore_open_projects", "args": {} }),
&[("cookie", &cookie)],
)
.await;
let (status, body, _) = response_json(restored).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["opened"][0]["id"], project_id);
assert_eq!(body["failures"], json!([]));
let removed = request(
state,
Method::POST,
"/api/invoke",
json!({
"command": "remove_project_from_restore",
"args": { "request": { "projectId": project_id } }
}),
&[("cookie", &cookie)],
)
.await;
let (status, body, _) = response_json(removed).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["entries"], json!([]));
}
#[tokio::test]
async fn authorized_open_project_returns_readonly_project_dto() {
let state = state();
let project_id = create_project_for_test(&state, "Readonly Open").await;
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let response = request(
state,
Method::POST,
"/api/invoke",
json!({ "command": "open_project", "args": { "projectId": project_id } }),
&[("cookie", &cookie)],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["id"], project_id);
assert_eq!(body["name"], "Readonly Open");
assert!(body["root"].is_string());
}
#[tokio::test]
async fn authorized_open_project_arms_orchestrator_watch() {
let config = test_config();
let core = Arc::new(BackendCore::build(config.app_data_dir.clone()));
let project_id = create_project_on_core(&core, "MCP Armed Open").await;
let state = Arc::new(ServerState::with_core(config, core));
state.set_pairing_code_for_test("PAIR1234".to_owned());
let cookie = pair_and_cookie(Arc::clone(&state)).await;
assert!(
state.app.open_project_ids().is_empty(),
"precondition: the project must not already be armed before HTTP open_project"
);
let response = request(
Arc::clone(&state),
Method::POST,
"/api/invoke",
json!({ "command": "open_project", "args": { "projectId": project_id } }),
&[("cookie", &cookie)],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["id"], project_id);
let expected = parse_project_id(&project_id)
.expect("test project id parses")
.as_uuid()
.simple()
.to_string();
let opened = state
.app
.open_project_ids()
.into_iter()
.map(|id| id.as_uuid().simple().to_string())
.collect::<Vec<_>>();
assert!(
opened.iter().any(|id| id == &expected),
"expected open_project HTTP flow to arm the orchestrator watch"
);
}
#[tokio::test]
async fn authorized_layout_invokes_match_tauri_contracts() {
let state = state();
let project_id = create_project_for_test(&state, "Web Layouts").await;
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let (load_status, layout) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"load_layout",
json!({ "projectId": project_id }),
)
.await;
assert_eq!(load_status, StatusCode::OK);
assert_eq!(layout["root"]["type"], "leaf");
let (list_status, listed) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"list_layouts",
json!({ "projectId": project_id }),
)
.await;
assert_eq!(list_status, StatusCode::OK);
assert!(listed["activeId"].is_string());
assert_eq!(listed["layouts"].as_array().unwrap().len(), 1);
let (create_status, created) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"create_layout",
json!({ "projectId": project_id, "name": "Second", "kind": "terminal" }),
)
.await;
assert_eq!(create_status, StatusCode::OK);
let created_layout_id = created["layoutId"].as_str().unwrap().to_owned();
let (rename_status, renamed) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"rename_layout",
json!({ "projectId": project_id, "layoutId": created_layout_id, "name": "Renamed" }),
)
.await;
assert_eq!(rename_status, StatusCode::OK);
assert_eq!(renamed, Value::Null);
let (activate_status, activated) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"set_active_layout",
json!({ "projectId": project_id, "layoutId": created_layout_id }),
)
.await;
assert_eq!(activate_status, StatusCode::OK);
assert_eq!(activated["activeId"], created_layout_id);
let (load_created_status, created_layout) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"load_layout",
json!({ "projectId": project_id, "layoutId": created_layout_id }),
)
.await;
assert_eq!(load_created_status, StatusCode::OK);
let (mutate_status, mutated) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"mutate_layout",
json!({
"projectId": project_id,
"layoutId": created_layout_id,
"operation": {
"type": "setCellConversation",
"target": created_layout["root"]["node"]["id"],
"conversationId": "web-conversation"
}
}),
)
.await;
assert_eq!(mutate_status, StatusCode::OK);
assert_eq!(
mutated["root"]["node"]["conversationId"],
"web-conversation"
);
let (delete_status, deleted) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"delete_layout",
json!({ "projectId": project_id, "layoutId": created_layout_id }),
)
.await;
assert_eq!(delete_status, StatusCode::OK);
assert!(deleted["activeId"].is_string());
}
#[tokio::test]
async fn authorized_project_files_lists_relative_paths_and_hides_ideai() {
let state = state();
let project_id = create_project_for_test(&state, "Project Files").await;
let project = resolve_project_readonly(&project_id, &state.app)
.await
.expect("project resolves");
std::fs::create_dir_all(format!("{}/src", project.root.as_str())).unwrap();
std::fs::write(
format!("{}/src/main.rs", project.root.as_str()),
"fn main() {}\n",
)
.unwrap();
std::fs::write(format!("{}/README.md", project.root.as_str()), "readme").unwrap();
std::fs::create_dir_all(format!("{}/.ideai", project.root.as_str())).unwrap();
std::fs::write(
format!("{}/.ideai/secret.txt", project.root.as_str()),
"secret",
)
.unwrap();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let (status, body, _) = response_json(
request(
Arc::clone(&state),
Method::GET,
&format!("/api/project-files?projectId={project_id}&path="),
json!({}),
&[("cookie", &cookie)],
)
.await,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body["path"], "");
let entries = body["entries"].as_array().unwrap();
assert!(entries
.iter()
.any(|entry| entry["name"] == "src" && entry["kind"] == "directory"));
assert!(entries
.iter()
.any(|entry| entry["path"] == "README.md" && entry["kind"] == "file"));
assert!(!entries.iter().any(|entry| entry["name"] == ".ideai"));
}
#[tokio::test]
async fn custom_project_icon_routes_import_list_read_usage_and_delete() {
let state = state();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let png_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
let (status, imported, _) = response_json(
request(
Arc::clone(&state),
Method::POST,
"/api/project-icon-assets",
json!({
"originalFilename": "/home/user/avatar.png",
"mediaType": "image/png",
"dataBase64": png_base64
}),
&[("cookie", &cookie)],
)
.await,
)
.await;
assert_eq!(status, StatusCode::CREATED);
assert_eq!(imported["mediaType"], "image/png");
assert_eq!(imported["originalFilename"], "avatar.png");
assert_eq!(imported["width"], 1);
assert_eq!(imported["height"], 1);
assert_eq!(imported["usedByProjectCount"], 0);
let asset_id = imported["id"].as_str().unwrap().to_owned();
let (status, listed, _) = response_json(
request(
Arc::clone(&state),
Method::GET,
"/api/project-icon-assets",
json!({}),
&[("cookie", &cookie)],
)
.await,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(listed.as_array().unwrap().len(), 1);
let (status, bytes, headers) = response_bytes(
request(
Arc::clone(&state),
Method::GET,
&format!("/api/project-icon-assets/{asset_id}"),
json!({}),
&[("cookie", &cookie)],
)
.await,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(headers.get(CONTENT_TYPE).unwrap(), "image/png");
assert_eq!(STANDARD.decode(png_base64).unwrap(), bytes);
let project_id = create_project_for_test(&state, "Custom Icon User").await;
let (update_status, _) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"update_project_settings",
json!({
"projectId": project_id,
"icon": { "set": "custom", "name": asset_id.clone() }
}),
)
.await;
assert_eq!(update_status, StatusCode::OK);
let (status, usage, _) = response_json(
request(
Arc::clone(&state),
Method::GET,
&format!("/api/project-icon-assets/{asset_id}/usage"),
json!({}),
&[("cookie", &cookie)],
)
.await,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(usage["usedByProjectCount"], 1);
let (status, conflict, _) = response_json(
request(
Arc::clone(&state),
Method::DELETE,
&format!("/api/project-icon-assets/{asset_id}"),
json!({}),
&[("cookie", &cookie)],
)
.await,
)
.await;
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(conflict["code"], "CUSTOM_ICON_IN_USE");
let (clear_status, _) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"update_project_settings",
json!({ "projectId": project_id, "icon": null }),
)
.await;
assert_eq!(clear_status, StatusCode::OK);
let (status, deleted, _) = response_json(
request(
Arc::clone(&state),
Method::DELETE,
&format!("/api/project-icon-assets/{asset_id}"),
json!({}),
&[("cookie", &cookie)],
)
.await,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(deleted["assetId"], asset_id);
}
#[tokio::test]
async fn custom_project_icon_import_rejects_svg_content() {
let state = state();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let (status, error, _) = response_json(
request(
state,
Method::POST,
"/api/project-icon-assets",
json!({
"originalFilename": "unsafe.svg",
"mediaType": "image/svg+xml",
"dataBase64": STANDARD.encode(b"<svg xmlns='http://www.w3.org/2000/svg'/>")
}),
&[("cookie", &cookie)],
)
.await,
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(error["code"], "INVALID");
}
#[tokio::test]
async fn authorized_project_file_gets_allow_missing_origin_header() {
let state = state();
let project_id = create_project_for_test(&state, "Project Files No Origin").await;
let project = resolve_project_readonly(&project_id, &state.app)
.await
.expect("project resolves");
std::fs::create_dir_all(format!("{}/src", project.root.as_str())).unwrap();
std::fs::write(
format!("{}/src/main.rs", project.root.as_str()),
"fn main() {}\n",
)
.unwrap();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let (list_status, list_body, _) = response_json(
request_without_origin(
Arc::clone(&state),
Method::GET,
&format!("/api/project-files?projectId={project_id}&path="),
json!({}),
&[("cookie", &cookie)],
)
.await,
)
.await;
let (file_status, file_body, _) = response_bytes(
request_without_origin(
Arc::clone(&state),
Method::GET,
&format!(
"/api/project-files/download-file?projectId={project_id}&path=src/main.rs"
),
json!({}),
&[("cookie", &cookie)],
)
.await,
)
.await;
let (directory_status, directory_body, _) = response_bytes(
request_without_origin(
Arc::clone(&state),
Method::GET,
&format!("/api/project-files/download-directory?projectId={project_id}&path=src"),
json!({}),
&[("cookie", &cookie)],
)
.await,
)
.await;
assert_eq!(list_status, StatusCode::OK);
assert!(list_body["entries"]
.as_array()
.unwrap()
.iter()
.any(|entry| entry["path"] == "src"));
assert_eq!(file_status, StatusCode::OK);
assert_eq!(file_body, Bytes::from_static(b"fn main() {}\n"));
assert_eq!(directory_status, StatusCode::OK);
assert!(directory_body.starts_with(b"PK"));
}
#[tokio::test]
async fn non_get_project_file_route_still_requires_origin_header() {
let state = state();
let project_id = create_project_for_test(&state, "Project Files POST No Origin").await;
let (status, body, _) = response_json(
request_without_origin(
state,
Method::POST,
&format!("/api/project-files?projectId={project_id}&path="),
json!({}),
&[],
)
.await,
)
.await;
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body["message"], "missing Origin header");
}
#[tokio::test]
async fn authorized_project_files_rejects_traversal_and_hidden_paths() {
let state = state();
let project_id = create_project_for_test(&state, "Project Files Security").await;
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let (traversal_status, traversal_body, _) = response_json(
request(
Arc::clone(&state),
Method::GET,
&format!("/api/project-files?projectId={project_id}&path=../"),
json!({}),
&[("cookie", &cookie)],
)
.await,
)
.await;
let (hidden_status, hidden_body, _) = response_json(
request(
Arc::clone(&state),
Method::GET,
&format!("/api/project-files?projectId={project_id}&path=.ideai"),
json!({}),
&[("cookie", &cookie)],
)
.await,
)
.await;
assert_eq!(traversal_status, StatusCode::BAD_REQUEST);
assert_eq!(traversal_body["code"], "INVALID");
assert_eq!(hidden_status, StatusCode::BAD_REQUEST);
assert_eq!(hidden_body["code"], "INVALID");
}
#[tokio::test]
async fn authorized_project_file_download_requires_file_and_returns_binary() {
let state = state();
let project_id = create_project_for_test(&state, "Project File Download").await;
let project = resolve_project_readonly(&project_id, &state.app)
.await
.expect("project resolves");
std::fs::create_dir_all(format!("{}/src", project.root.as_str())).unwrap();
std::fs::write(
format!("{}/src/main.rs", project.root.as_str()),
"fn main() {}\n",
)
.unwrap();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let (status, body, headers) = response_bytes(
request(
Arc::clone(&state),
Method::GET,
&format!(
"/api/project-files/download-file?projectId={project_id}&path=src/main.rs"
),
json!({}),
&[("cookie", &cookie)],
)
.await,
)
.await;
let (dir_status, dir_body, _) = response_json(
request(
Arc::clone(&state),
Method::GET,
&format!("/api/project-files/download-file?projectId={project_id}&path=src"),
json!({}),
&[("cookie", &cookie)],
)
.await,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(body, Bytes::from_static(b"fn main() {}\n"));
assert!(headers
.get(CONTENT_DISPOSITION)
.unwrap()
.to_str()
.unwrap()
.contains("main.rs"));
assert_eq!(dir_status, StatusCode::BAD_REQUEST);
assert_eq!(dir_body["code"], "INVALID");
}
#[tokio::test]
async fn authorized_project_directory_download_returns_zip_and_requires_directory() {
let state = state();
let project_id = create_project_for_test(&state, "Project Directory Download").await;
let project = resolve_project_readonly(&project_id, &state.app)
.await
.expect("project resolves");
std::fs::create_dir_all(format!("{}/src/nested", project.root.as_str())).unwrap();
std::fs::write(
format!("{}/src/nested/lib.rs", project.root.as_str()),
"pub fn x() {}\n",
)
.unwrap();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let (status, body, headers) = response_bytes(
request(
Arc::clone(&state),
Method::GET,
&format!("/api/project-files/download-directory?projectId={project_id}&path=src"),
json!({}),
&[("cookie", &cookie)],
)
.await,
)
.await;
let (file_status, file_body, _) = response_json(
request(
Arc::clone(&state),
Method::GET,
&format!("/api/project-files/download-directory?projectId={project_id}&path=src/nested/lib.rs"),
json!({}),
&[("cookie", &cookie)],
)
.await,
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(body.starts_with(b"PK"));
assert_eq!(
headers.get(CONTENT_TYPE).unwrap(),
HeaderValue::from_static("application/zip")
);
assert_eq!(file_status, StatusCode::BAD_REQUEST);
assert_eq!(file_body["code"], "INVALID");
}
#[cfg(unix)]
#[tokio::test]
async fn authorized_project_files_rejects_symlink_escaping_root() {
let state = state();
let project_id = create_project_for_test(&state, "Project Files Symlink").await;
let project = resolve_project_readonly(&project_id, &state.app)
.await
.expect("project resolves");
let outside = std::env::temp_dir().join(format!("idea-outside-{}", Uuid::new_v4()));
std::fs::write(&outside, "outside").unwrap();
std::os::unix::fs::symlink(&outside, format!("{}/outside-link", project.root.as_str()))
.unwrap();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let (status, body, _) = response_json(
request(
Arc::clone(&state),
Method::GET,
&format!(
"/api/project-files/download-file?projectId={project_id}&path=outside-link"
),
json!({}),
&[("cookie", &cookie)],
)
.await,
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["code"], "INVALID");
let _ = std::fs::remove_file(outside);
}
#[tokio::test]
async fn authorized_get_project_work_state_returns_tauri_contract() {
let state = state();
let project_id = create_project_for_test(&state, "Readonly Work").await;
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let response = request(
state,
Method::POST,
"/api/invoke",
json!({ "command": "get_project_work_state", "args": { "projectId": project_id } }),
&[("cookie", &cookie)],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::OK);
assert!(body["agents"].as_array().is_some());
assert!(body["conversations"].as_array().is_some());
}
#[tokio::test]
async fn authorized_list_background_tasks_returns_tauri_contract() {
let state = state();
let project_id = create_project_for_test(&state, "Background Snapshot").await;
let (task_id, owner) =
create_background_task_for_test(&state, &project_id, "web background").await;
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let response = request(
state,
Method::POST,
"/api/invoke",
json!({
"command": "list_background_tasks",
"args": { "projectId": project_id, "agentId": owner.to_string() }
}),
&[("cookie", &cookie)],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::OK);
let tasks = body.as_array().expect("background task list is an array");
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0]["taskId"], task_id.to_string());
assert_eq!(tasks[0]["ownerAgentId"], owner.to_string());
assert_eq!(tasks[0]["projectId"], project_id);
assert_eq!(tasks[0]["kind"], "command");
assert_eq!(tasks[0]["state"], "queued");
}
#[tokio::test]
async fn authorized_scheduled_task_invokes_use_camel_case_contract() {
let state = state();
let project_id = create_project_for_test(&state, "Scheduled HTTP").await;
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let (status, created) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"create_scheduled_project_task",
json!({
"request": {
"projectId": project_id,
"name": "cleanup",
"command": { "command": "true", "args": [], "cwd": "." },
"schedule": { "kind": "interval", "everyMinutes": 15 },
"timezone": "UTC",
"enabled": false
}
}),
)
.await;
assert_eq!(status, StatusCode::OK, "create failed: {created}");
assert_eq!(created["projectId"], project_id);
assert_eq!(created["approval"], "approved");
assert_eq!(created["schedule"]["everyMinutes"], 15);
assert!(created.get("scheduled_task_id").is_none());
let (status, listed) = invoke_request_for_test(
state,
&cookie,
"list_scheduled_project_tasks",
json!({ "projectId": project_id }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(listed.as_array().map(Vec::len), Some(1));
assert_eq!(listed[0]["id"], created["id"]);
}
#[tokio::test]
async fn authorized_settings_invoke_round_trips_user_and_project_contracts() {
let state = state();
let project_id = create_project_for_test(&state, "Settings Round Trip").await;
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let (status, initial_user) =
invoke_request_for_test(Arc::clone(&state), &cookie, "read_user_settings", json!({}))
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(initial_user["defaultAgentTerminalInterface"], Value::Null);
assert_eq!(initial_user["language"], Value::Null);
let (status, saved_user) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"update_user_settings",
json!({ "request": { "defaultAgentTerminalInterface": "ideaCli", "language": "en" } }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(saved_user["defaultAgentTerminalInterface"], "ideaCli");
assert_eq!(saved_user["language"], "en");
let (status, initial_project) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"read_project_settings",
json!({ "projectId": project_id }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(initial_project["themeColor"], Value::Null);
assert_eq!(initial_project["icon"], Value::Null);
let (status, saved_project) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"update_project_settings",
json!({
"request": {
"projectId": project_id,
"themeColor": "#AA3366",
"icon": { "set": "devicon", "name": "rust" }
}
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(saved_project["themeColor"], "#AA3366");
assert_eq!(saved_project["icon"]["set"], "devicon");
assert_eq!(saved_project["icon"]["name"], "rust");
let (status, unchanged_project) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"update_project_settings",
json!({ "request": { "projectId": project_id } }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(unchanged_project["themeColor"], "#AA3366");
assert_eq!(unchanged_project["icon"]["name"], "rust");
let (status, reset_project) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"update_project_settings",
json!({ "request": { "projectId": project_id, "themeColor": null } }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(reset_project["themeColor"], Value::Null);
assert_eq!(reset_project["icon"]["name"], "rust");
let (status, cleared_icon) = invoke_request_for_test(
state,
&cookie,
"update_project_settings",
json!({ "request": { "projectId": project_id, "icon": null } }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(cleared_icon["themeColor"], Value::Null);
assert_eq!(cleared_icon["icon"], Value::Null);
}
#[tokio::test]
async fn ticket_and_sprint_commands_require_authenticated_invoke() {
let state = state();
for command in [
"ticket_list",
"ticket_create",
"sprint_list",
"sprint_create",
] {
let response = request(
Arc::clone(&state),
Method::POST,
"/api/invoke",
json!({ "command": command, "args": {} }),
&[],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(body["code"], "UNAUTHORIZED");
}
}
#[tokio::test]
async fn web_invoke_routes_context_commands() {
let state = state();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
assert_invoke_commands_are_routed(
&state,
&cookie,
&["read_project_context", "update_project_context"],
)
.await;
}
#[tokio::test]
async fn web_invoke_routes_layout_commands() {
let state = state();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
assert_invoke_commands_are_routed(
&state,
&cookie,
&[
"load_layout",
"mutate_layout",
"list_layouts",
"create_layout",
"rename_layout",
"delete_layout",
"set_active_layout",
],
)
.await;
}
#[tokio::test]
async fn web_invoke_routes_profile_commands() {
let state = state();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
assert_invoke_commands_are_routed(
&state,
&cookie,
&[
"first_run_state",
"reference_profiles",
"detect_profiles",
"list_profiles",
"save_profile",
"list_opencode_providers",
"list_claude_models",
"list_codex_models",
"save_opencode_provider_profile",
"delete_profile",
"configure_profiles",
"clone_opencode_profile_from_seed",
],
)
.await;
}
#[tokio::test]
async fn web_invoke_routes_agent_commands() {
let state = state();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
assert_invoke_commands_are_routed(
&state,
&cookie,
&[
"list_agents",
"list_live_agents",
"list_resumable_agents",
"attach_live_agent",
"stop_live_agent",
"restart_cell_agent_runtime",
"create_agent",
"change_agent_profile",
"read_agent_context",
"update_agent_context",
"delete_agent",
"inspect_conversation",
"read_conversation_page",
],
)
.await;
}
#[tokio::test]
async fn web_invoke_routes_template_commands() {
let state = state();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
assert_invoke_commands_are_routed(
&state,
&cookie,
&[
"list_templates",
"create_template",
"update_template",
"delete_template",
"create_agent_from_template",
"detect_agent_drift",
"sync_agent_with_template",
],
)
.await;
}
#[tokio::test]
async fn web_invoke_routes_skill_commands() {
let state = state();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
assert_invoke_commands_are_routed(
&state,
&cookie,
&[
"list_skills",
"create_skill",
"update_skill",
"delete_skill",
"assign_skill_to_agent",
"unassign_skill_from_agent",
],
)
.await;
}
#[tokio::test]
async fn web_invoke_routes_permission_commands() {
let state = state();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
assert_invoke_commands_are_routed(
&state,
&cookie,
&[
"get_project_permissions",
"update_project_permissions",
"update_agent_permissions",
"update_agent_effort",
"update_agent_contactable",
"resolve_agent_permissions",
"get_project_system_permissions",
"update_project_system_permissions",
"update_agent_system_permissions",
"resolve_agent_system_permissions",
"get_mcp_tool_permissions",
"update_project_mcp_tool_permissions",
"update_agent_mcp_tool_permissions",
],
)
.await;
}
#[tokio::test]
async fn web_invoke_routes_memory_and_embedder_commands() {
let state = state();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
assert_invoke_commands_are_routed(
&state,
&cookie,
&[
"list_memories",
"get_memory",
"create_memory",
"update_memory",
"delete_memory",
"read_memory_index",
"resolve_memory_links",
"recall_memory",
"list_embedder_profiles",
"save_embedder_profile",
"delete_embedder_profile",
"describe_embedder_engines",
],
)
.await;
}
#[tokio::test]
async fn web_invoke_routes_git_commands() {
let state = state();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
assert_invoke_commands_are_routed(
&state,
&cookie,
&[
"git_status",
"git_stage",
"git_unstage",
"git_commit",
"git_branches",
"git_checkout",
"git_log",
"git_init",
"git_graph",
],
)
.await;
}
#[tokio::test]
async fn authorized_ticket_invoke_routes_ticket_carnet_link_and_assign_commands() {
let state = state();
let project_id = create_project_for_test(&state, "Web Tickets").await;
let agent_id = create_agent_for_project_for_test(&state, &project_id, "Ticket owner").await;
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let (status, created) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_create",
json!({
"request": {
"projectId": project_id,
"title": "Wire ticket",
"description": "Created over /api/invoke",
"priority": "high",
"status": "open"
}
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(created["title"], "Wire ticket");
assert_eq!(created["priority"], "high");
let ticket_ref = created["ref"].as_str().unwrap().to_owned();
let mut ticket_version = created["version"].as_u64().unwrap();
let (status, read) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_read",
json!({ "request": { "projectId": project_id, "ref": ticket_ref, "includeCarnet": true } }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(read["ref"], ticket_ref);
assert!(read["carnet"].is_string());
let project = state
.app
.open_project
.execute(OpenProjectInput {
project_id: ProjectId::from_uuid(Uuid::parse_str(&project_id).unwrap()),
})
.await
.expect("test project is readable")
.project;
std::fs::remove_file(
std::path::PathBuf::from(project.root.as_str())
.join(".ideai")
.join("tickets")
.join(ticket_ref.trim_start_matches('#'))
.join("carnet.md"),
)
.expect("test removes ticket carnet");
let (status, read_without_carnet_file) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_read",
json!({ "request": { "projectId": project_id, "ref": ticket_ref, "includeCarnet": true } }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(read_without_carnet_file["ref"], ticket_ref);
assert_eq!(read_without_carnet_file["carnet"], "");
let (status, list) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_list",
json!({ "request": { "projectId": project_id, "statuses": ["open"] } }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(list["items"].as_array().unwrap().len(), 1);
assert_eq!(list["items"][0]["ref"], ticket_ref);
let (status, updated) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_update",
json!({
"request": {
"projectId": project_id,
"ref": ticket_ref,
"title": "Wire ticket updated",
"status": "inProgress",
"expectedVersion": ticket_version
}
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(updated["title"], "Wire ticket updated");
assert_eq!(updated["status"], "inProgress");
let stale_version = ticket_version;
let (status, conflict) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_update",
json!({
"request": {
"projectId": project_id,
"ref": ticket_ref,
"title": "Stale update",
"expectedVersion": stale_version
}
}),
)
.await;
assert_eq!(status, StatusCode::CONFLICT);
assert_eq!(conflict["code"], "CONFLICT");
let (status, carnet) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_read_carnet",
json!({ "request": { "projectId": project_id, "ref": ticket_ref } }),
)
.await;
assert_eq!(status, StatusCode::OK);
let carnet_version = carnet["version"].as_u64().unwrap();
let (status, after_carnet_update) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_update_carnet",
json!({
"request": {
"projectId": project_id,
"ref": ticket_ref,
"carnet": "## QA\nCarnet updated through web transport.",
"expectedVersion": carnet_version
}
}),
)
.await;
assert_eq!(status, StatusCode::OK);
ticket_version = after_carnet_update["version"].as_u64().unwrap();
let (status, target) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_create",
json!({ "request": { "projectId": project_id, "title": "Linked target" } }),
)
.await;
assert_eq!(status, StatusCode::OK, "target create failed: {target}");
let target_ref = target["ref"].as_str().unwrap().to_owned();
let (status, linked) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_link",
json!({
"request": {
"projectId": project_id,
"ref": ticket_ref,
"targetRef": target_ref,
"kind": "blocks",
"expectedVersion": ticket_version
}
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(linked["links"].as_array().unwrap().len(), 1);
ticket_version = linked["version"].as_u64().unwrap();
let (status, unlinked) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_unlink",
json!({
"request": {
"projectId": project_id,
"ref": ticket_ref,
"targetRef": target_ref,
"kind": "blocks",
"expectedVersion": ticket_version
}
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(unlinked["links"].as_array().unwrap().is_empty());
ticket_version = unlinked["version"].as_u64().unwrap();
let (status, assigned) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_assign",
json!({
"request": {
"projectId": project_id,
"ref": ticket_ref,
"agentId": agent_id,
"assigned": true,
"expectedVersion": ticket_version
}
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(assigned["assignedAgentIds"][0], agent_id);
let (status, deleted) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_delete",
json!({ "request": { "projectId": project_id, "ref": target_ref } }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(deleted, Value::Null);
}
#[tokio::test]
async fn authorized_ticket_invoke_routes_attachment_commands() {
let state = state();
let project_id = create_project_for_test(&state, "Web Ticket Attachments").await;
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let (status, created) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_create",
json!({
"request": {
"projectId": project_id,
"title": "Wire attachment ticket"
}
}),
)
.await;
assert_eq!(status, StatusCode::OK);
let ticket_ref = created["ref"].as_str().unwrap().to_owned();
let ticket_version = created["version"].as_u64().unwrap();
let project = state
.app
.open_project
.execute(OpenProjectInput {
project_id: ProjectId::from_uuid(Uuid::parse_str(&project_id).unwrap()),
})
.await
.expect("test project is readable")
.project;
let source = std::path::PathBuf::from(project.root.as_str()).join("web-attachment.txt");
std::fs::write(&source, b"web attachment payload").expect("test writes source attachment");
let (status, attached) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_attachment_add",
json!({
"request": {
"projectId": project_id,
"ref": ticket_ref,
"path": source.to_string_lossy(),
"mime": "text/plain",
"expectedVersion": ticket_version
}
}),
)
.await;
assert_eq!(status, StatusCode::OK);
let attachment = &attached["attachments"].as_array().unwrap()[0];
assert_eq!(attachment["filename"], "web-attachment.txt");
assert_eq!(attachment["mime"], "text/plain");
assert_eq!(attachment["summarizedInCarnet"], false);
let attachment_id = attachment["id"].as_str().unwrap().to_owned();
let updated_version = attached["version"].as_u64().unwrap();
let (status, content) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_attachment_read",
json!({
"request": {
"projectId": project_id,
"ref": ticket_ref,
"attachmentId": attachment_id
}
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(content["attachment"]["filename"], "web-attachment.txt");
assert_eq!(
content["contentBase64"],
base64::engine::general_purpose::STANDARD_NO_PAD.encode(b"web attachment payload")
);
let (status, summarized) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_attachment_mark_summarized",
json!({
"request": {
"projectId": project_id,
"ref": ticket_ref,
"attachmentId": content["attachment"]["id"],
"expectedVersion": updated_version
}
}),
)
.await;
assert_eq!(status, StatusCode::OK);
let summarized_attachment = &summarized["attachments"].as_array().unwrap()[0];
assert_eq!(summarized_attachment["summarizedInCarnet"], true);
assert_eq!(summarized_attachment["summarizedBy"]["kind"], "user");
assert!(summarized_attachment["summarizedAt"].as_u64().is_some());
}
#[tokio::test]
async fn authorized_sprint_invoke_routes_sprint_and_ticket_sprint_commands() {
let state = state();
let project_id = create_project_for_test(&state, "Web Sprints").await;
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let (status, ticket) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_create",
json!({ "request": { "projectId": project_id, "title": "Sprint ticket" } }),
)
.await;
assert_eq!(status, StatusCode::OK);
let ticket_ref = ticket["ref"].as_str().unwrap().to_owned();
let mut ticket_version = ticket["version"].as_u64().unwrap();
let (status, sprint_a) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"sprint_create",
json!({ "request": { "projectId": project_id, "name": "Alpha", "status": "planned" } }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(sprint_a["name"], "Alpha");
let sprint_a_id = sprint_a["id"].as_str().unwrap().to_owned();
let sprint_a_version = sprint_a["version"].as_u64().unwrap();
let (status, sprint_b) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"sprint_create",
json!({ "request": { "projectId": project_id, "name": "Beta" } }),
)
.await;
assert_eq!(status, StatusCode::OK);
let sprint_b_id = sprint_b["id"].as_str().unwrap().to_owned();
let (status, list) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"sprint_list",
json!({ "request": { "projectId": project_id } }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(list["items"].as_array().unwrap().len(), 2);
let (status, renamed) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"sprint_rename",
json!({
"request": {
"projectId": project_id,
"sprintId": sprint_a_id,
"name": "Alpha renamed",
"expectedVersion": sprint_a_version
}
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(renamed["name"], "Alpha renamed");
let (status, reordered) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"sprint_reorder",
json!({ "request": { "projectId": project_id, "orderedIds": [sprint_b_id, sprint_a_id] } }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(reordered["items"][0]["id"], sprint_b_id);
let (status, assigned) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_assign_sprint",
json!({
"request": {
"projectId": project_id,
"ref": ticket_ref,
"sprintId": sprint_a_id,
"expectedVersion": ticket_version
}
}),
)
.await;
assert_eq!(status, StatusCode::OK, "sprint assign failed: {assigned}");
assert_eq!(assigned["sprintId"], sprint_a_id);
ticket_version = assigned["version"].as_u64().unwrap();
let (status, unassigned) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"ticket_unassign_sprint",
json!({
"request": {
"projectId": project_id,
"ref": ticket_ref,
"expectedVersion": ticket_version
}
}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(unassigned["sprintId"].is_null());
let (status, deleted) = invoke_request_for_test(
Arc::clone(&state),
&cookie,
"sprint_delete",
json!({ "request": { "projectId": project_id, "sprintId": sprint_b_id } }),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(deleted, Value::Null);
}
#[tokio::test]
async fn background_actions_are_allowlisted_with_auth_gate() {
let state = state();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let bad_task_id = "not-a-task-id";
for command in ["cancel_background_task", "retry_background_task"] {
let response = request(
Arc::clone(&state),
Method::POST,
"/api/invoke",
json!({ "command": command, "args": { "taskId": bad_task_id } }),
&[("cookie", &cookie)],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(
body["code"], "INVALID",
"{command} must be an explicit action, not UNKNOWN_COMMAND"
);
}
}
#[tokio::test]
async fn mutation_and_pty_commands_stay_out_of_readonly_allowlist() {
let state = state();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
for command in ["create_project", "open_terminal", "launch_agent"] {
let response = request(
Arc::clone(&state),
Method::POST,
"/api/invoke",
json!({ "command": command, "args": {} }),
&[("cookie", &cookie)],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(
body["code"], "UNKNOWN_COMMAND",
"{command} must stay blocked"
);
}
}
#[tokio::test]
async fn invalid_project_id_is_mapped_to_error_dto() {
let state = state();
let cookie = pair_and_cookie(Arc::clone(&state)).await;
let response = request(
state,
Method::POST,
"/api/invoke",
json!({ "command": "open_project", "args": { "projectId": "nope" } }),
&[("cookie", &cookie)],
)
.await;
let (status, body, _) = response_json(response).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["code"], "INVALID");
}
#[test]
fn direct_project_id_parser_still_rejects_invalid_ids() {
assert!(parse_project_id("not-a-uuid").is_err());
}
}