From 386eedbf7a84369e4ceef93f5fcdd0a987e62f77 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:17:42 +0300 Subject: [PATCH 001/416] feat: add remote protocol contract crate --- src-tauri/Cargo.lock | 8 + src-tauri/Cargo.toml | 2 +- .../crates/ralphx-remote-protocol/Cargo.toml | 11 + .../crates/ralphx-remote-protocol/src/lib.rs | 382 ++++++++++++ .../tests/protocol_contract.rs | 133 ++++ .../snapshots/event-classifications.json | 584 ++++++++++++++++++ .../tests/snapshots/frames.json | 12 + .../tests/snapshots/vocabulary.json | 8 + 8 files changed, 1139 insertions(+), 1 deletion(-) create mode 100644 src-tauri/crates/ralphx-remote-protocol/Cargo.toml create mode 100644 src-tauri/crates/ralphx-remote-protocol/src/lib.rs create mode 100644 src-tauri/crates/ralphx-remote-protocol/tests/protocol_contract.rs create mode 100644 src-tauri/crates/ralphx-remote-protocol/tests/snapshots/event-classifications.json create mode 100644 src-tauri/crates/ralphx-remote-protocol/tests/snapshots/frames.json create mode 100644 src-tauri/crates/ralphx-remote-protocol/tests/snapshots/vocabulary.json diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cb5276496b..040998f928 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3683,6 +3683,14 @@ dependencies = [ "uuid", ] +[[package]] +name = "ralphx-remote-protocol" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "ralphx-workflow-runner" version = "0.1.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 29f52f2aac..0f9bbb9830 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -6,7 +6,7 @@ authors = ["RalphX Team"] edition = "2021" [workspace] -members = ["crates/ralphx-domain", "crates/ralphx-events", "crates/ralphx-workflow-runner"] +members = ["crates/ralphx-domain", "crates/ralphx-events", "crates/ralphx-remote-protocol", "crates/ralphx-workflow-runner"] resolver = "2" [workspace.dependencies] diff --git a/src-tauri/crates/ralphx-remote-protocol/Cargo.toml b/src-tauri/crates/ralphx-remote-protocol/Cargo.toml new file mode 100644 index 0000000000..a231df98bf --- /dev/null +++ b/src-tauri/crates/ralphx-remote-protocol/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "ralphx-remote-protocol" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +serde_json = { workspace = true } diff --git a/src-tauri/crates/ralphx-remote-protocol/src/lib.rs b/src-tauri/crates/ralphx-remote-protocol/src/lib.rs new file mode 100644 index 0000000000..9bafdade07 --- /dev/null +++ b/src-tauri/crates/ralphx-remote-protocol/src/lib.rs @@ -0,0 +1,382 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const PROTOCOL_VERSION: u32 = 1; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnvironmentDescriptor { + pub environment_id: String, + pub app_version: String, + pub protocol_version: u32, + pub min_client_protocol: u32, + pub platform: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Scope { + #[serde(rename = "ui:read")] + UiRead, + #[serde(rename = "ui:operate")] + UiOperate, + #[serde(rename = "ui:agent")] + UiAgent, + #[serde(rename = "ui:elevated")] + UiElevated, +} + +pub const SCOPES: &[Scope] = &[ + Scope::UiRead, + Scope::UiOperate, + Scope::UiAgent, + Scope::UiElevated, +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RiskClass { + Read, + Operate, + PathScoped, + AgentControl, + Elevated, + Denied, +} + +pub const RISK_CLASSES: &[RiskClass] = &[ + RiskClass::Read, + RiskClass::Operate, + RiskClass::PathScoped, + RiskClass::AgentControl, + RiskClass::Elevated, + RiskClass::Denied, +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Capability { + SpawnsProcess, + WritesArbitraryPath, + MutatesWorkingDirectory, + ConfiguresFutureProcessAuthority, + TouchesCredentials, + PtyControl, + AgentControl, + SeedsSpawnTriggeringState, + MutatesAgentConsumedContent, + HostManagement, + DeletesEntity, +} + +pub const CAPABILITIES: &[Capability] = &[ + Capability::SpawnsProcess, + Capability::WritesArbitraryPath, + Capability::MutatesWorkingDirectory, + Capability::ConfiguresFutureProcessAuthority, + Capability::TouchesCredentials, + Capability::PtyControl, + Capability::AgentControl, + Capability::SeedsSpawnTriggeringState, + Capability::MutatesAgentConsumedContent, + Capability::HostManagement, + Capability::DeletesEntity, +]; + +/// Compile-time class/capability consistency gate used by the remote registry. +/// +/// ```compile_fail +/// use ralphx_remote_protocol::{class_permits, Capability, RiskClass}; +/// const _: () = assert!(class_permits( +/// RiskClass::Operate, +/// &[Capability::SeedsSpawnTriggeringState], +/// )); +/// ``` +pub const fn class_permits(class: RiskClass, capabilities: &[Capability]) -> bool { + let mut index = 0; + while index < capabilities.len() { + let permitted = match class { + RiskClass::Read | RiskClass::Operate | RiskClass::Denied => false, + RiskClass::PathScoped => matches!(capabilities[index], Capability::WritesArbitraryPath), + RiskClass::AgentControl => matches!( + capabilities[index], + Capability::AgentControl + | Capability::SeedsSpawnTriggeringState + | Capability::MutatesAgentConsumedContent + ), + RiskClass::Elevated => true, + }; + if !permitted { + return false; + } + index += 1; + } + !matches!(class, RiskClass::Denied) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ResetReason { + #[serde(rename = "cursor_pruned")] + CursorPruned, + #[serde(rename = "epoch_changed")] + EpochChanged, + #[serde(rename = "after_seq_gt_max")] + AfterSeqGtMax, + #[serde(rename = "read_error")] + ReadError, + #[serde(rename = "revoked")] + Revoked, + #[serde(rename = "host_disabled")] + HostDisabled, +} +pub const RESET_REASONS: &[ResetReason] = &[ + ResetReason::CursorPruned, + ResetReason::EpochChanged, + ResetReason::AfterSeqGtMax, + ResetReason::ReadError, + ResetReason::Revoked, + ResetReason::HostDisabled, +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ErrorCode { + #[serde(rename = "REMOTE_COMMAND_UNAVAILABLE")] + RemoteCommandUnavailable, + #[serde(rename = "REMOTE_FORBIDDEN")] + RemoteForbidden, + #[serde(rename = "REMOTE_UNAUTHORIZED")] + RemoteUnauthorized, + #[serde(rename = "REMOTE_UNREACHABLE")] + RemoteUnreachable, + #[serde(rename = "REMOTE_VERSION_MISMATCH")] + RemoteVersionMismatch, + #[serde(rename = "REMOTE_TIMEOUT_UNKNOWN")] + RemoteTimeoutUnknown, + #[serde(rename = "REMOTE_REQUEST_IN_PROGRESS")] + RemoteRequestInProgress, + #[serde(rename = "REMOTE_REQUEST_ID_REUSED")] + RemoteRequestIdReused, +} +pub const ERROR_CODES: &[ErrorCode] = &[ + ErrorCode::RemoteCommandUnavailable, + ErrorCode::RemoteForbidden, + ErrorCode::RemoteUnauthorized, + ErrorCode::RemoteUnreachable, + ErrorCode::RemoteVersionMismatch, + ErrorCode::RemoteTimeoutUnknown, + ErrorCode::RemoteRequestInProgress, + ErrorCode::RemoteRequestIdReused, +]; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum ServerFrame { + Hello { + protocol_version: u32, + environment_id: String, + stream_epoch: String, + server_version: String, + max_seq: u64, + heartbeat_secs: u32, + }, + Event { + seq: Option, + name: String, + payload: Value, + }, + ReplayDone { + through_seq: u64, + }, + Reset { + reason: ResetReason, + }, + Heartbeat { + t: u64, + }, + Error { + code: ErrorCode, + message: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "type", + rename_all = "camelCase", + rename_all_fields = "camelCase" +)] +pub enum ClientFrame { + Subscribe { + after_seq: u64, + stream_epoch: String, + }, + CursorAck { + seq: u64, + }, + HeartbeatAck { + t: u64, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum EventDelivery { + Durable, + Transient, + LocalOnly, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum EventOrigin { + Backend, + Webview, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct EventClassification { + pub name: &'static str, + pub delivery: EventDelivery, + pub origin: EventOrigin, + pub excluded_from_v1: bool, +} + +impl EventClassification { + pub fn find(name: &str) -> Option<&'static Self> { + EVENT_CLASSIFICATIONS + .iter() + .find(|entry| entry.name == name) + } +} + +const fn backend(name: &'static str, delivery: EventDelivery) -> EventClassification { + EventClassification { + name, + delivery, + origin: EventOrigin::Backend, + excluded_from_v1: false, + } +} +const fn webview(name: &'static str) -> EventClassification { + EventClassification { + name, + delivery: EventDelivery::LocalOnly, + origin: EventOrigin::Webview, + excluded_from_v1: false, + } +} + +// Exact names only. PR 0.1 mechanically audits this table against live emit and subscribe sites. +// PR 0.2 decision: render deltas (tool_call/message/hook) are transient; queue and recovery +// lifecycle invalidations are durable because clients must refetch authoritative state after replay. +pub const EVENT_CLASSIFICATIONS: &[EventClassification] = &[ + backend("task:created", EventDelivery::Durable), + backend("task:deleted", EventDelivery::Durable), + backend("task:status_changed", EventDelivery::Durable), + backend("task:merge_progress", EventDelivery::Durable), + backend("task:merge_phases", EventDelivery::Durable), + backend("task:archived", EventDelivery::Durable), + backend("task:restored", EventDelivery::Durable), + backend("notification:created", EventDelivery::Durable), + backend("notification:updated", EventDelivery::Durable), + backend("agent:run_started", EventDelivery::Durable), + backend("agent:run_completed", EventDelivery::Durable), + backend("agent:turn_completed", EventDelivery::Durable), + backend("agent:message_created", EventDelivery::Durable), + backend("agent:task_started", EventDelivery::Durable), + backend("agent:task_completed", EventDelivery::Durable), + backend("agent:error", EventDelivery::Durable), + backend("agent:queue_sent", EventDelivery::Durable), + backend("agent:message_queued", EventDelivery::Durable), + backend("agent:session_recovered", EventDelivery::Durable), + backend("team:message", EventDelivery::Durable), + backend("team:status_changed", EventDelivery::Durable), + backend("automation:updated", EventDelivery::Durable), + backend("automation:run_updated", EventDelivery::Durable), + backend("automation:deleted", EventDelivery::Durable), + backend("ticketing:cache_invalidated", EventDelivery::Durable), + backend("task_validation:event", EventDelivery::Durable), + backend("proposal:created", EventDelivery::Durable), + backend("step:created", EventDelivery::Durable), + backend("plan_artifact:created", EventDelivery::Durable), + backend("execution:status_changed", EventDelivery::Durable), + backend("agent:conversation_created", EventDelivery::Durable), + backend("agent:conversation_forked", EventDelivery::Durable), + backend("agent:conversation_title_updated", EventDelivery::Durable), + backend("agent:question_resolved", EventDelivery::Durable), + backend("agent:stopped", EventDelivery::Durable), + backend("agent:workspace_changed", EventDelivery::Durable), + backend("automation:run:updated", EventDelivery::Durable), + backend("dependency:added", EventDelivery::Durable), + backend("dependency:removed", EventDelivery::Durable), + backend("execution:error", EventDelivery::Durable), + backend("execution:queue_changed", EventDelivery::Durable), + backend("file:change", EventDelivery::Durable), + backend("ideation:child_session_created", EventDelivery::Durable), + backend( + "ideation:finalize_pending_confirmation", + EventDelivery::Durable, + ), + backend("ideation:session_accepted", EventDelivery::Durable), + backend("ideation:session_created", EventDelivery::Durable), + backend("ideation:session_title_updated", EventDelivery::Durable), + backend("merge:validation_start", EventDelivery::Durable), + backend("merge:validation_step", EventDelivery::Durable), + backend("notification:desktop_activated", EventDelivery::Durable), + backend("persona:applied", EventDelivery::Durable), + backend("persona:draft_updated", EventDelivery::Durable), + backend("persona:injection_skipped", EventDelivery::Durable), + backend("plan:merge_complete", EventDelivery::Durable), + backend("plan_artifact:updated", EventDelivery::Durable), + backend("plan_verification:status_changed", EventDelivery::Durable), + backend("pr_review_artifact:created", EventDelivery::Durable), + backend("pr_review_artifact:updated", EventDelivery::Durable), + backend("project:analysis_complete", EventDelivery::Durable), + backend("project:analysis_failed", EventDelivery::Durable), + backend("proposal:deleted", EventDelivery::Durable), + backend("proposal:priority_assessed", EventDelivery::Durable), + backend("proposal:updated", EventDelivery::Durable), + backend("proposals:reordered", EventDelivery::Durable), + backend("qa:prep", EventDelivery::Durable), + backend("qa:test", EventDelivery::Durable), + backend("recovery:prompt", EventDelivery::Durable), + backend("review:update", EventDelivery::Durable), + backend("session:priorities_assessed", EventDelivery::Durable), + backend("step:deleted", EventDelivery::Durable), + backend("step:status_changed", EventDelivery::Durable), + backend("step:updated", EventDelivery::Durable), + backend("steps:reordered", EventDelivery::Durable), + backend("supervisor:alert", EventDelivery::Durable), + backend("supervisor:event", EventDelivery::Durable), + backend("task:event", EventDelivery::Durable), + backend("task:provider_error_paused", EventDelivery::Durable), + backend("workspace_review_artifact:created", EventDelivery::Durable), + backend("workspace_review_artifact:updated", EventDelivery::Durable), + backend("agent:chunk", EventDelivery::Transient), + backend("agent:usage_updated", EventDelivery::Transient), + backend("agent:tool_call", EventDelivery::Transient), + backend("agent:message", EventDelivery::Transient), + backend("agent:hook", EventDelivery::Transient), + backend("agent:ask_user_question", EventDelivery::Transient), + backend("agent:heartbeat", EventDelivery::Transient), + backend("agent:startup_progress", EventDelivery::Transient), + backend("agent:workflow_progress", EventDelivery::Transient), + backend("execution:stderr", EventDelivery::Transient), + backend("permission:expired", EventDelivery::Transient), + backend("permission:request", EventDelivery::Transient), + EventClassification { + name: "agent_terminal:event", + delivery: EventDelivery::Transient, + origin: EventOrigin::Backend, + excluded_from_v1: true, + }, + webview("task:updated"), + webview("my:event"), + webview("window:focus"), + webview("dock:updated"), + webview("updater:status"), +]; diff --git a/src-tauri/crates/ralphx-remote-protocol/tests/protocol_contract.rs b/src-tauri/crates/ralphx-remote-protocol/tests/protocol_contract.rs new file mode 100644 index 0000000000..9cebcb93fd --- /dev/null +++ b/src-tauri/crates/ralphx-remote-protocol/tests/protocol_contract.rs @@ -0,0 +1,133 @@ +use ralphx_remote_protocol::{ + Capability, ClientFrame, EnvironmentDescriptor, ErrorCode, EventClassification, EventDelivery, + EventOrigin, ResetReason, RiskClass, ServerFrame, CAPABILITIES, ERROR_CODES, + EVENT_CLASSIFICATIONS, PROTOCOL_VERSION, RESET_REASONS, RISK_CLASSES, SCOPES, +}; +use serde::Serialize; +use serde_json::{json, Value}; + +fn value(input: T) -> Value { + serde_json::to_value(input).expect("protocol values serialize") +} + +#[test] +fn frame_json_contract_is_camel_case_and_preserves_null_seq() { + let frames = vec![ + value(ServerFrame::Hello { + protocol_version: PROTOCOL_VERSION, + environment_id: "env-1".into(), + stream_epoch: "epoch-1".into(), + server_version: "0.81.0".into(), + max_seq: 18_234, + heartbeat_secs: 20, + }), + value(ServerFrame::Event { + seq: Some(18_235), + name: "task:status_changed".into(), + payload: json!({"taskId": "task-1"}), + }), + value(ServerFrame::Event { + seq: None, + name: "agent:chunk".into(), + payload: json!({"text": "hello"}), + }), + value(ServerFrame::ReplayDone { + through_seq: 18_235, + }), + value(ServerFrame::Reset { + reason: ResetReason::CursorPruned, + }), + value(ServerFrame::Heartbeat { t: 1_626_354_000 }), + value(ServerFrame::Error { + code: ErrorCode::RemoteForbidden, + message: "scope required".into(), + }), + value(ClientFrame::Subscribe { + after_seq: 18_100, + stream_epoch: "epoch-1".into(), + }), + value(ClientFrame::CursorAck { seq: 18_230 }), + value(ClientFrame::HeartbeatAck { t: 1_626_354_000 }), + ]; + assert_eq!( + value(frames.clone()), + serde_json::from_str::(include_str!("snapshots/frames.json")).unwrap() + ); + assert_eq!(frames[2]["seq"], Value::Null); +} + +#[test] +fn descriptor_and_wire_enums_match_the_closed_contract() { + let snapshot = json!({ + "descriptor": value(EnvironmentDescriptor { + environment_id: "env-1".into(), app_version: "0.81.0".into(), + protocol_version: PROTOCOL_VERSION, min_client_protocol: 1, + platform: "macos".into(), + }), + "scopes": value(SCOPES), "riskClasses": value(RISK_CLASSES), + "capabilities": value(CAPABILITIES), "resetReasons": value(RESET_REASONS), + "errorCodes": value(ERROR_CODES), + }); + assert_eq!( + snapshot, + serde_json::from_str::(include_str!("snapshots/vocabulary.json")).unwrap() + ); + assert_eq!(SCOPES.len(), 4); + assert_eq!(RISK_CLASSES.len(), 6); + assert_eq!(CAPABILITIES.len(), 11); + assert_eq!(RESET_REASONS.len(), 6); + assert_eq!(ERROR_CODES.len(), 8); +} + +#[test] +fn class_permits_rejects_every_capability_for_read_and_operate() { + for capability in CAPABILITIES { + assert!(!ralphx_remote_protocol::class_permits( + RiskClass::Read, + &[*capability] + )); + assert!(!ralphx_remote_protocol::class_permits( + RiskClass::Operate, + &[*capability] + )); + } + assert!(!ralphx_remote_protocol::class_permits( + RiskClass::Operate, + &[Capability::SeedsSpawnTriggeringState] + )); +} + +#[test] +fn event_classification_is_exact_and_snapshotted() { + assert_eq!( + value(EVENT_CLASSIFICATIONS), + serde_json::from_str::(include_str!("snapshots/event-classifications.json")) + .unwrap() + ); + assert!(EventClassification::find("agent:chunk:suffix").is_none()); + assert_eq!( + EventClassification::find("agent:chunk").unwrap().delivery, + EventDelivery::Transient + ); + assert_eq!( + EventClassification::find("agent_terminal:event") + .unwrap() + .excluded_from_v1, + true + ); + assert_eq!( + EventClassification::find("task:updated").unwrap().origin, + EventOrigin::Webview + ); + assert_eq!( + EventClassification::find("notification:created") + .unwrap() + .delivery, + EventDelivery::Durable + ); +} + +const _: () = assert!(!ralphx_remote_protocol::class_permits( + RiskClass::Operate, + &[Capability::SeedsSpawnTriggeringState], +)); diff --git a/src-tauri/crates/ralphx-remote-protocol/tests/snapshots/event-classifications.json b/src-tauri/crates/ralphx-remote-protocol/tests/snapshots/event-classifications.json new file mode 100644 index 0000000000..1f76043633 --- /dev/null +++ b/src-tauri/crates/ralphx-remote-protocol/tests/snapshots/event-classifications.json @@ -0,0 +1,584 @@ +[ + { + "name": "task:created", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "task:deleted", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "task:status_changed", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "task:merge_progress", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "task:merge_phases", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "task:archived", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "task:restored", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "notification:created", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "notification:updated", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "agent:run_started", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "agent:run_completed", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "agent:turn_completed", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "agent:message_created", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "agent:task_started", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "agent:task_completed", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "agent:error", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "agent:queue_sent", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "agent:message_queued", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "agent:session_recovered", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "team:message", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "team:status_changed", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "automation:updated", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "automation:run_updated", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "automation:deleted", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "ticketing:cache_invalidated", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "task_validation:event", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "proposal:created", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "step:created", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "plan_artifact:created", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "execution:status_changed", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "agent:conversation_created", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "agent:conversation_forked", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "agent:conversation_title_updated", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "agent:question_resolved", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "agent:stopped", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "agent:workspace_changed", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "automation:run:updated", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "dependency:added", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "dependency:removed", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "execution:error", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "execution:queue_changed", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "file:change", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "ideation:child_session_created", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "ideation:finalize_pending_confirmation", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "ideation:session_accepted", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "ideation:session_created", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "ideation:session_title_updated", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "merge:validation_start", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "merge:validation_step", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "notification:desktop_activated", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "persona:applied", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "persona:draft_updated", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "persona:injection_skipped", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "plan:merge_complete", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "plan_artifact:updated", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "plan_verification:status_changed", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "pr_review_artifact:created", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "pr_review_artifact:updated", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "project:analysis_complete", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "project:analysis_failed", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "proposal:deleted", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "proposal:priority_assessed", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "proposal:updated", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "proposals:reordered", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "qa:prep", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "qa:test", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "recovery:prompt", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "review:update", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "session:priorities_assessed", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "step:deleted", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "step:status_changed", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "step:updated", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "steps:reordered", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "supervisor:alert", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "supervisor:event", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "task:event", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "task:provider_error_paused", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "workspace_review_artifact:created", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "workspace_review_artifact:updated", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "agent:chunk", + "delivery": "transient", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "agent:usage_updated", + "delivery": "transient", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "agent:tool_call", + "delivery": "transient", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "agent:message", + "delivery": "transient", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "agent:hook", + "delivery": "transient", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "agent:ask_user_question", + "delivery": "transient", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "agent:heartbeat", + "delivery": "transient", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "agent:startup_progress", + "delivery": "transient", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "agent:workflow_progress", + "delivery": "transient", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "execution:stderr", + "delivery": "transient", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "permission:expired", + "delivery": "transient", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "permission:request", + "delivery": "transient", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "agent_terminal:event", + "delivery": "transient", + "origin": "backend", + "excludedFromV1": true + }, + { + "name": "task:updated", + "delivery": "localOnly", + "origin": "webview", + "excludedFromV1": false + }, + { + "name": "my:event", + "delivery": "localOnly", + "origin": "webview", + "excludedFromV1": false + }, + { + "name": "window:focus", + "delivery": "localOnly", + "origin": "webview", + "excludedFromV1": false + }, + { + "name": "dock:updated", + "delivery": "localOnly", + "origin": "webview", + "excludedFromV1": false + }, + { + "name": "updater:status", + "delivery": "localOnly", + "origin": "webview", + "excludedFromV1": false + } +] diff --git a/src-tauri/crates/ralphx-remote-protocol/tests/snapshots/frames.json b/src-tauri/crates/ralphx-remote-protocol/tests/snapshots/frames.json new file mode 100644 index 0000000000..d0a3699c9c --- /dev/null +++ b/src-tauri/crates/ralphx-remote-protocol/tests/snapshots/frames.json @@ -0,0 +1,12 @@ +[ + {"type":"hello","protocolVersion":1,"environmentId":"env-1","streamEpoch":"epoch-1","serverVersion":"0.81.0","maxSeq":18234,"heartbeatSecs":20}, + {"type":"event","seq":18235,"name":"task:status_changed","payload":{"taskId":"task-1"}}, + {"type":"event","seq":null,"name":"agent:chunk","payload":{"text":"hello"}}, + {"type":"replayDone","throughSeq":18235}, + {"type":"reset","reason":"cursor_pruned"}, + {"type":"heartbeat","t":1626354000}, + {"type":"error","code":"REMOTE_FORBIDDEN","message":"scope required"}, + {"type":"subscribe","afterSeq":18100,"streamEpoch":"epoch-1"}, + {"type":"cursorAck","seq":18230}, + {"type":"heartbeatAck","t":1626354000} +] diff --git a/src-tauri/crates/ralphx-remote-protocol/tests/snapshots/vocabulary.json b/src-tauri/crates/ralphx-remote-protocol/tests/snapshots/vocabulary.json new file mode 100644 index 0000000000..f9f90063ed --- /dev/null +++ b/src-tauri/crates/ralphx-remote-protocol/tests/snapshots/vocabulary.json @@ -0,0 +1,8 @@ +{ + "descriptor":{"environmentId":"env-1","appVersion":"0.81.0","protocolVersion":1,"minClientProtocol":1,"platform":"macos"}, + "scopes":["ui:read","ui:operate","ui:agent","ui:elevated"], + "riskClasses":["read","operate","pathScoped","agentControl","elevated","denied"], + "capabilities":["spawnsProcess","writesArbitraryPath","mutatesWorkingDirectory","configuresFutureProcessAuthority","touchesCredentials","ptyControl","agentControl","seedsSpawnTriggeringState","mutatesAgentConsumedContent","hostManagement","deletesEntity"], + "resetReasons":["cursor_pruned","epoch_changed","after_seq_gt_max","read_error","revoked","host_disabled"], + "errorCodes":["REMOTE_COMMAND_UNAVAILABLE","REMOTE_FORBIDDEN","REMOTE_UNAUTHORIZED","REMOTE_UNREACHABLE","REMOTE_VERSION_MISMATCH","REMOTE_TIMEOUT_UNKNOWN","REMOTE_REQUEST_IN_PROGRESS","REMOTE_REQUEST_ID_REUSED"] +} From 4bffc861c63d0d9065452e2d3c29af40df726e5a Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:28:54 +0300 Subject: [PATCH 002/416] feat: add remote event capture bank --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 1 + src-tauri/src/application/app_setup.rs | 3 + src-tauri/src/lib.rs | 1 + src-tauri/src/remote_server/capture.rs | 125 +++++++++++++++++++ src-tauri/src/remote_server/capture_tests.rs | 100 +++++++++++++++ src-tauri/src/remote_server/mod.rs | 1 + 7 files changed, 232 insertions(+) create mode 100644 src-tauri/src/remote_server/capture.rs create mode 100644 src-tauri/src/remote_server/capture_tests.rs create mode 100644 src-tauri/src/remote_server/mod.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 040998f928..07d6021e65 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3618,6 +3618,7 @@ dependencies = [ "portable-pty", "ralphx-domain", "ralphx-events", + "ralphx-remote-protocol", "rand 0.8.6", "regex", "rusqlite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0f9bbb9830..442a77937f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -85,6 +85,7 @@ toml = "0.9" regex = { version = "1", default-features = false, features = ["std", "perf"] } ralphx-domain = { path = "crates/ralphx-domain" } ralphx-events = { path = "crates/ralphx-events" } +ralphx-remote-protocol = { path = "crates/ralphx-remote-protocol" } hyper = { version = "1", features = ["client", "http1"] } hyper-util = { version = "0.1", features = ["client-legacy", "tokio"] } hyper-rustls = "0.27" diff --git a/src-tauri/src/application/app_setup.rs b/src-tauri/src/application/app_setup.rs index f3697bded8..a0dfabcb93 100644 --- a/src-tauri/src/application/app_setup.rs +++ b/src-tauri/src/application/app_setup.rs @@ -177,6 +177,9 @@ pub(crate) fn run_app_setup( ) -> Result<(), Box> { let app_handle = app.handle().clone(); + // PR 1.1 replaces this constant-false seam with the persisted remote_host setting. + crate::remote_server::capture::install_if_host_mode_configured(app_handle.clone(), false); + configure_bundled_runtime_env(app); // The native window must be visible before SQLite open/migration work begins. diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 868a45cad5..08255db0bb 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -28,6 +28,7 @@ pub mod domain; pub mod error; pub mod http_server; pub mod infrastructure; +pub mod remote_server; pub mod shell; pub mod testing; pub mod utils; diff --git a/src-tauri/src/remote_server/capture.rs b/src-tauri/src/remote_server/capture.rs new file mode 100644 index 0000000000..7cf9012d75 --- /dev/null +++ b/src-tauri/src/remote_server/capture.rs @@ -0,0 +1,125 @@ +use ralphx_remote_protocol::{EventDelivery, EventOrigin, EVENT_CLASSIFICATIONS}; +use serde_json::Value; +use std::sync::mpsc::{self, Receiver, SyncSender, TrySendError}; +use tauri::{Listener, Runtime}; + +#[cfg(test)] +#[path = "capture_tests.rs"] +mod tests; + +#[derive(Debug, Clone, PartialEq)] +pub struct CapturedEvent { + pub name: &'static str, + pub payload: Value, +} + +#[derive(Clone)] +pub struct CaptureFeed { + durable: SyncSender, + transient: mpsc::Sender, +} + +pub struct CaptureReceivers { + pub durable: Receiver, + pub transient: Receiver, +} + +impl CaptureFeed { + pub fn channels(durable_capacity: usize) -> (Self, CaptureReceivers) { + let (durable, durable_rx) = mpsc::sync_channel(durable_capacity); + let (transient, transient_rx) = mpsc::channel(); + ( + Self { durable, transient }, + CaptureReceivers { + durable: durable_rx, + transient: transient_rx, + }, + ) + } +} + +pub trait EventRegistrar: Clone + Send + Sync + 'static { + fn listen(&self, name: &'static str, handler: Box); +} + +struct TauriRegistrar(tauri::AppHandle); + +impl Clone for TauriRegistrar { + fn clone(&self) -> Self { + Self(self.0.clone()) + } +} + +impl EventRegistrar for TauriRegistrar { + fn listen(&self, name: &'static str, handler: Box) { + self.0 + .listen_any(name, move |event| handler(event.payload())); + } +} + +pub struct RemoteEventCapture; + +impl RemoteEventCapture { + pub fn install(app_handle: tauri::AppHandle, feed: CaptureFeed) { + Self::install_with_registrar(TauriRegistrar(app_handle), feed); + } + + pub fn install_with_registrar(registrar: R, feed: CaptureFeed) { + for entry in EVENT_CLASSIFICATIONS + .iter() + .filter(|entry| entry.origin == EventOrigin::Backend && !entry.excluded_from_v1) + { + let name = entry.name; + let delivery = entry.delivery; + let feed = feed.clone(); + registrar.listen( + name, + Box::new(move |raw_payload| { + let Ok(payload) = serde_json::from_str(raw_payload) else { + tracing::warn!( + event_name = name, + "Remote event capture dropped malformed JSON payload" + ); + return; + }; + let event = CapturedEvent { name, payload }; + match delivery { + EventDelivery::Durable => match feed.durable.try_send(event) { + Ok(()) => {} + Err(TrySendError::Full(_)) => tracing::warn!( + event_name = name, + "Remote durable capture feed is full" + ), + Err(TrySendError::Disconnected(_)) => tracing::warn!( + event_name = name, + "Remote durable capture feed is disconnected" + ), + }, + EventDelivery::Transient => { + if feed.transient.send(event).is_err() { + tracing::warn!( + event_name = name, + "Remote transient capture feed is disconnected" + ); + } + } + EventDelivery::LocalOnly => unreachable!("local events are not registered"), + } + }), + ); + } + } +} + +pub fn install_if_host_mode_configured( + app_handle: tauri::AppHandle, + configured: bool, +) { + if !configured { + return; + } + let (feed, receivers) = CaptureFeed::channels(1_024); + RemoteEventCapture::install(app_handle, feed); + std::thread::spawn(move || for _ in receivers.durable {}); + std::thread::spawn(move || for _ in receivers.transient {}); +} diff --git a/src-tauri/src/remote_server/capture_tests.rs b/src-tauri/src/remote_server/capture_tests.rs new file mode 100644 index 0000000000..bc59716cf5 --- /dev/null +++ b/src-tauri/src/remote_server/capture_tests.rs @@ -0,0 +1,100 @@ +use super::{CaptureFeed, CapturedEvent, EventRegistrar, RemoteEventCapture}; +use ralphx_remote_protocol::{EventClassification, EventDelivery, EVENT_CLASSIFICATIONS}; +use serde_json::json; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +type Handler = Box; + +#[derive(Clone, Default)] +struct RecordingRegistrar(Arc>>>); + +impl EventRegistrar for RecordingRegistrar { + fn listen(&self, name: &'static str, handler: Handler) { + self.0 + .lock() + .unwrap() + .entry(name) + .or_default() + .push(handler); + } +} + +impl RecordingRegistrar { + fn emit(&self, name: &'static str, payload: &str) { + if let Some(handlers) = self.0.lock().unwrap().get(name) { + for handler in handlers { + handler(payload); + } + } + } + fn count(&self, name: &str) -> usize { + self.0.lock().unwrap().get(name).map_or(0, Vec::len) + } +} + +#[test] +fn installs_once_for_each_backend_non_excluded_event_only() { + let registrar = RecordingRegistrar::default(); + let (feed, _receivers) = CaptureFeed::channels(16); + RemoteEventCapture::install_with_registrar(registrar.clone(), feed); + + for entry in EVENT_CLASSIFICATIONS { + let expected = usize::from( + entry.origin == ralphx_remote_protocol::EventOrigin::Backend && !entry.excluded_from_v1, + ); + assert_eq!(registrar.count(entry.name), expected, "{}", entry.name); + } + assert_eq!(registrar.count("agent_terminal:event"), 0); + assert_eq!(registrar.count("task:updated"), 0); +} + +#[test] +fn routes_parsed_payloads_to_the_classified_sync_channel() { + let registrar = RecordingRegistrar::default(); + let (feed, receivers) = CaptureFeed::channels(16); + RemoteEventCapture::install_with_registrar(registrar.clone(), feed); + + registrar.emit("notification:created", r#"{"id":"n-1"}"#); + registrar.emit("agent:chunk", r#"{"text":"hi"}"#); + + assert_eq!( + receivers.durable.try_recv().unwrap(), + CapturedEvent { + name: "notification:created", + payload: json!({"id":"n-1"}) + } + ); + assert_eq!( + receivers.transient.try_recv().unwrap(), + CapturedEvent { + name: "agent:chunk", + payload: json!({"text":"hi"}) + } + ); +} + +#[test] +fn malformed_payload_and_full_durable_channel_fail_closed_without_blocking() { + let registrar = RecordingRegistrar::default(); + let (feed, receivers) = CaptureFeed::channels(1); + RemoteEventCapture::install_with_registrar(registrar.clone(), feed); + registrar.emit("notification:created", "not-json"); + assert!(receivers.durable.try_recv().is_err()); + registrar.emit("notification:created", "{}"); + registrar.emit("notification:created", "{}"); + assert!(receivers.durable.try_recv().is_ok()); + assert!(receivers.durable.try_recv().is_err()); +} + +#[test] +fn table_has_no_duplicate_names_and_local_entries_are_not_backend_origin() { + let mut names = std::collections::HashSet::new(); + for entry in EVENT_CLASSIFICATIONS { + assert!(names.insert(entry.name), "duplicate {}", entry.name); + if entry.delivery == EventDelivery::LocalOnly { + assert_ne!(entry.origin, ralphx_remote_protocol::EventOrigin::Backend); + } + } + assert!(EventClassification::find("notification:created").is_some()); +} diff --git a/src-tauri/src/remote_server/mod.rs b/src-tauri/src/remote_server/mod.rs new file mode 100644 index 0000000000..8833300b0b --- /dev/null +++ b/src-tauri/src/remote_server/mod.rs @@ -0,0 +1 @@ +pub mod capture; From 06673d6ba96e63971e5091cd61e960c266c6f359 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:41:24 +0300 Subject: [PATCH 003/416] fix: classify remote permission and plan approval events --- src-tauri/crates/ralphx-remote-protocol/src/lib.rs | 2 ++ .../tests/protocol_contract.rs | 12 ++++++++++++ .../tests/snapshots/event-classifications.json | 12 ++++++++++++ 3 files changed, 26 insertions(+) diff --git a/src-tauri/crates/ralphx-remote-protocol/src/lib.rs b/src-tauri/crates/ralphx-remote-protocol/src/lib.rs index 9bafdade07..840164deb4 100644 --- a/src-tauri/crates/ralphx-remote-protocol/src/lib.rs +++ b/src-tauri/crates/ralphx-remote-protocol/src/lib.rs @@ -303,6 +303,7 @@ pub const EVENT_CLASSIFICATIONS: &[EventClassification] = &[ backend("proposal:created", EventDelivery::Durable), backend("step:created", EventDelivery::Durable), backend("plan_artifact:created", EventDelivery::Durable), + backend("plan_artifact:approved", EventDelivery::Durable), backend("execution:status_changed", EventDelivery::Durable), backend("agent:conversation_created", EventDelivery::Durable), backend("agent:conversation_forked", EventDelivery::Durable), @@ -368,6 +369,7 @@ pub const EVENT_CLASSIFICATIONS: &[EventClassification] = &[ backend("execution:stderr", EventDelivery::Transient), backend("permission:expired", EventDelivery::Transient), backend("permission:request", EventDelivery::Transient), + backend("permission:resolved", EventDelivery::Transient), EventClassification { name: "agent_terminal:event", delivery: EventDelivery::Transient, diff --git a/src-tauri/crates/ralphx-remote-protocol/tests/protocol_contract.rs b/src-tauri/crates/ralphx-remote-protocol/tests/protocol_contract.rs index 9cebcb93fd..8d212fc94b 100644 --- a/src-tauri/crates/ralphx-remote-protocol/tests/protocol_contract.rs +++ b/src-tauri/crates/ralphx-remote-protocol/tests/protocol_contract.rs @@ -125,6 +125,18 @@ fn event_classification_is_exact_and_snapshotted() { .delivery, EventDelivery::Durable ); + assert_eq!( + EventClassification::find("permission:resolved") + .unwrap() + .delivery, + EventDelivery::Transient + ); + assert_eq!( + EventClassification::find("plan_artifact:approved") + .unwrap() + .delivery, + EventDelivery::Durable + ); } const _: () = assert!(!ralphx_remote_protocol::class_permits( diff --git a/src-tauri/crates/ralphx-remote-protocol/tests/snapshots/event-classifications.json b/src-tauri/crates/ralphx-remote-protocol/tests/snapshots/event-classifications.json index 1f76043633..cacb6b35dc 100644 --- a/src-tauri/crates/ralphx-remote-protocol/tests/snapshots/event-classifications.json +++ b/src-tauri/crates/ralphx-remote-protocol/tests/snapshots/event-classifications.json @@ -173,6 +173,12 @@ "origin": "backend", "excludedFromV1": false }, + { + "name": "plan_artifact:approved", + "delivery": "durable", + "origin": "backend", + "excludedFromV1": false + }, { "name": "execution:status_changed", "delivery": "durable", @@ -545,6 +551,12 @@ "origin": "backend", "excludedFromV1": false }, + { + "name": "permission:resolved", + "delivery": "transient", + "origin": "backend", + "excludedFromV1": false + }, { "name": "agent_terminal:event", "delivery": "transient", From 87a9eb12d2f8d8205496d582bd436a5cd56ee740 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:44:41 +0300 Subject: [PATCH 004/416] fix: remove stale remote event classifications --- .../crates/ralphx-remote-protocol/src/lib.rs | 4 ---- .../tests/protocol_contract.rs | 11 +++++++++ .../snapshots/event-classifications.json | 24 ------------------- 3 files changed, 11 insertions(+), 28 deletions(-) diff --git a/src-tauri/crates/ralphx-remote-protocol/src/lib.rs b/src-tauri/crates/ralphx-remote-protocol/src/lib.rs index 840164deb4..8df929510c 100644 --- a/src-tauri/crates/ralphx-remote-protocol/src/lib.rs +++ b/src-tauri/crates/ralphx-remote-protocol/src/lib.rs @@ -275,7 +275,6 @@ const fn webview(name: &'static str) -> EventClassification { // lifecycle invalidations are durable because clients must refetch authoritative state after replay. pub const EVENT_CLASSIFICATIONS: &[EventClassification] = &[ backend("task:created", EventDelivery::Durable), - backend("task:deleted", EventDelivery::Durable), backend("task:status_changed", EventDelivery::Durable), backend("task:merge_progress", EventDelivery::Durable), backend("task:merge_phases", EventDelivery::Durable), @@ -293,10 +292,7 @@ pub const EVENT_CLASSIFICATIONS: &[EventClassification] = &[ backend("agent:queue_sent", EventDelivery::Durable), backend("agent:message_queued", EventDelivery::Durable), backend("agent:session_recovered", EventDelivery::Durable), - backend("team:message", EventDelivery::Durable), - backend("team:status_changed", EventDelivery::Durable), backend("automation:updated", EventDelivery::Durable), - backend("automation:run_updated", EventDelivery::Durable), backend("automation:deleted", EventDelivery::Durable), backend("ticketing:cache_invalidated", EventDelivery::Durable), backend("task_validation:event", EventDelivery::Durable), diff --git a/src-tauri/crates/ralphx-remote-protocol/tests/protocol_contract.rs b/src-tauri/crates/ralphx-remote-protocol/tests/protocol_contract.rs index 8d212fc94b..2ced8c4288 100644 --- a/src-tauri/crates/ralphx-remote-protocol/tests/protocol_contract.rs +++ b/src-tauri/crates/ralphx-remote-protocol/tests/protocol_contract.rs @@ -137,6 +137,17 @@ fn event_classification_is_exact_and_snapshotted() { .delivery, EventDelivery::Durable ); + for stale_name in [ + "task:deleted", + "team:message", + "team:status_changed", + "automation:run_updated", + ] { + assert!( + EventClassification::find(stale_name).is_none(), + "{stale_name} has no production emitter or UI consumer and must not reserve a remote event classification" + ); + } } const _: () = assert!(!ralphx_remote_protocol::class_permits( diff --git a/src-tauri/crates/ralphx-remote-protocol/tests/snapshots/event-classifications.json b/src-tauri/crates/ralphx-remote-protocol/tests/snapshots/event-classifications.json index cacb6b35dc..21e2d0ba21 100644 --- a/src-tauri/crates/ralphx-remote-protocol/tests/snapshots/event-classifications.json +++ b/src-tauri/crates/ralphx-remote-protocol/tests/snapshots/event-classifications.json @@ -5,12 +5,6 @@ "origin": "backend", "excludedFromV1": false }, - { - "name": "task:deleted", - "delivery": "durable", - "origin": "backend", - "excludedFromV1": false - }, { "name": "task:status_changed", "delivery": "durable", @@ -113,30 +107,12 @@ "origin": "backend", "excludedFromV1": false }, - { - "name": "team:message", - "delivery": "durable", - "origin": "backend", - "excludedFromV1": false - }, - { - "name": "team:status_changed", - "delivery": "durable", - "origin": "backend", - "excludedFromV1": false - }, { "name": "automation:updated", "delivery": "durable", "origin": "backend", "excludedFromV1": false }, - { - "name": "automation:run_updated", - "delivery": "durable", - "origin": "backend", - "excludedFromV1": false - }, { "name": "automation:deleted", "delivery": "durable", From 260fa47ab220a7937a6547554939efc66b67335b Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:51:35 +0300 Subject: [PATCH 005/416] feat: add event manifest scanner guard --- .github/workflows/ci.yml | 21 + scripts/event-manifest-scanner/.gitignore | 1 + scripts/event-manifest-scanner/Cargo.lock | 412 ++++ scripts/event-manifest-scanner/Cargo.toml | 20 + scripts/event-manifest-scanner/src/lib.rs | 1193 ++++++++++ scripts/event-manifest-scanner/src/main.rs | 33 + .../tests/fixtures/dynamic_emit.rs | 3 + .../tests/fixtures/dynamic_subscription.ts | 3 + .../tests/fixtures/receiver_shapes.rs | 60 + .../tests/fixtures/subscriptions.tsx | 10 + .../tests/fixtures/unregistered_wrapper.rs | 7 + .../event-manifest-scanner/tests/scanner.rs | 98 + scripts/event-manifest.json | 2111 +++++++++++++++++ 13 files changed, 3972 insertions(+) create mode 100644 scripts/event-manifest-scanner/.gitignore create mode 100644 scripts/event-manifest-scanner/Cargo.lock create mode 100644 scripts/event-manifest-scanner/Cargo.toml create mode 100644 scripts/event-manifest-scanner/src/lib.rs create mode 100644 scripts/event-manifest-scanner/src/main.rs create mode 100644 scripts/event-manifest-scanner/tests/fixtures/dynamic_emit.rs create mode 100644 scripts/event-manifest-scanner/tests/fixtures/dynamic_subscription.ts create mode 100644 scripts/event-manifest-scanner/tests/fixtures/receiver_shapes.rs create mode 100644 scripts/event-manifest-scanner/tests/fixtures/subscriptions.tsx create mode 100644 scripts/event-manifest-scanner/tests/fixtures/unregistered_wrapper.rs create mode 100644 scripts/event-manifest-scanner/tests/scanner.rs create mode 100644 scripts/event-manifest.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0e7ce2e682..748660ff28 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,8 @@ jobs: - 'scripts/test-rust-fast.sh' - 'scripts/tests/test-ci-rust-full-integration-targets.sh' - 'scripts/tests/test-coverage-rust-shards.sh' + - 'scripts/event-manifest-scanner/**' + - 'scripts/event-manifest.json' - 'scripts/build-prod-release.sh' - 'scripts/render-homebrew-cask.sh' - 'scripts/reconcile-homebrew-cask.sh' @@ -190,6 +192,25 @@ jobs: - name: Check JavaScript and Rust Tauri package alignment run: node scripts/check-tauri-package-alignment.mjs . + event-manifest: + name: Event Manifest + needs: changes + if: needs.changes.outputs.run_automation == 'true' || needs.changes.outputs.run_rust == 'true' || needs.changes.outputs.run_frontend == 'true' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 + with: + toolchain: 1.91.0 + + - name: Test event manifest scanner + run: cargo test --manifest-path scripts/event-manifest-scanner/Cargo.toml --test scanner + + - name: Verify event manifest is current + run: cargo run --manifest-path scripts/event-manifest-scanner/Cargo.toml -- --check + rust-ipc-contracts: name: Rust IPC Contracts needs: changes diff --git a/scripts/event-manifest-scanner/.gitignore b/scripts/event-manifest-scanner/.gitignore new file mode 100644 index 0000000000..b83d22266a --- /dev/null +++ b/scripts/event-manifest-scanner/.gitignore @@ -0,0 +1 @@ +/target/ diff --git a/scripts/event-manifest-scanner/Cargo.lock b/scripts/event-manifest-scanner/Cargo.lock new file mode 100644 index 0000000000..5f65438d3b --- /dev/null +++ b/scripts/event-manifest-scanner/Cargo.lock @@ -0,0 +1,412 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "event-manifest-scanner" +version = "0.1.0" +dependencies = [ + "anyhow", + "proc-macro2", + "ralphx-remote-protocol", + "serde", + "serde_json", + "syn 2.0.119", + "tempfile", + "thiserror", + "tree-sitter", + "tree-sitter-typescript", + "walkdir", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "ralphx-remote-protocol" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tree-sitter" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f873475d258561b06f1c595d93308a7ed124d9977cb26b148c2084a4a3cc87" +dependencies = [ + "cc", + "regex", + "regex-syntax", + "serde_json", + "streaming-iterator", + "tree-sitter-language", +] + +[[package]] +name = "tree-sitter-language" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "009994f150cc0cd50ff54917d5bc8bffe8cad10ca10d81c34da2ec421ae61782" + +[[package]] +name = "tree-sitter-typescript" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5f76ed8d947a75cc446d5fccd8b602ebf0cde64ccf2ffa434d873d7a575eff" +dependencies = [ + "cc", + "tree-sitter-language", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/scripts/event-manifest-scanner/Cargo.toml b/scripts/event-manifest-scanner/Cargo.toml new file mode 100644 index 0000000000..3aaf0ef180 --- /dev/null +++ b/scripts/event-manifest-scanner/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "event-manifest-scanner" +version = "0.1.0" +edition = "2021" +publish = false + +[dependencies] +anyhow = "1" +proc-macro2 = { version = "1", features = ["span-locations"] } +ralphx-remote-protocol = { path = "../../src-tauri/crates/ralphx-remote-protocol" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +syn = { version = "2", features = ["full", "visit"] } +thiserror = "2" +tree-sitter = "0.25" +tree-sitter-typescript = "0.23" +walkdir = "2" + +[dev-dependencies] +tempfile = "3" diff --git a/scripts/event-manifest-scanner/src/lib.rs b/scripts/event-manifest-scanner/src/lib.rs new file mode 100644 index 0000000000..cb7bd2d9c8 --- /dev/null +++ b/scripts/event-manifest-scanner/src/lib.rs @@ -0,0 +1,1193 @@ +use anyhow::{bail, Context, Result}; +use ralphx_remote_protocol::{EventClassification, EVENT_CLASSIFICATIONS}; +use serde::Serialize; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; +use syn::spanned::Spanned; +use syn::visit::{self, Visit}; +use syn::{Expr, ExprCall, ExprMethodCall, File, ImplItem, Item, ItemConst, ItemFn, Lit, Pat}; +use tree_sitter::{Node, Parser}; +use tree_sitter_typescript::LANGUAGE_TSX; +use walkdir::WalkDir; + +/// Event abstraction functions/methods which are allowed to forward an event-name parameter. +/// The list is deliberately explicit: a newly introduced forwarding wrapper is a CI failure. +const WRAPPERS: &[Wrapper] = &[ + Wrapper::function("emit_app_event", Some(1)), + Wrapper::function("emit_http_event", Some(1)), + Wrapper::function("emit_serialized", Some(1)), + Wrapper::function("emit_serialized_http_event", Some(1)), + Wrapper::function("emit_queue_changed", None), + Wrapper::function("emit_task_lifecycle_event", Some(1)), + Wrapper::function("emit_ticketing_operation_event", None), + Wrapper::method("TauriEventSink::emit", Some(1)), + Wrapper::method("ThrottledEmitter::new", None), + Wrapper::method("ThrottledEmitter::emit", Some(0)), + Wrapper::method("AppChatService::emit_event", Some(0)), + Wrapper::method("EnrichedEventEmitter::emit", Some(0)), + Wrapper::method("EnrichedEventEmitter::emit_with_payload", Some(0)), +]; + +/// Non-Tauri `.emit` sites that the AST scanner intentionally over-approximates. +/// Every entry names one stable receiver/function shape and records why it cannot carry a Tauri +/// event name. Additions are reviewed source changes rather than silent scanner exclusions. +const FALSE_POSITIVE_ALLOWLIST: &[FalsePositive] = &[ + FalsePositive { + function: "TauriTicketingEventSink::emit_ticketing_operation_event", + reason: "the event argument is a TicketingOperationEvent payload; the inner AppHandle emit uses TICKETING_OPERATION_EVENT", + }, + FalsePositive { + function: "RecordingEventSink::emit_ticketing_operation_event", + reason: "test double records a typed TicketingOperationEvent payload and does not emit to Tauri", + }, +]; + +/// Exact source/receiver pairs for typed internal event buses. These calls do not emit a Tauri +/// event themselves; their `AutomationEvent` is translated by `AutomationEventEmitter`. +const RECEIVER_FALSE_POSITIVE_ALLOWLIST: &[ReceiverFalsePositive] = &[ + ReceiverFalsePositive { + file: "src-tauri/src/application/automation/provisioning.rs", + receiver: "self.event_emitter", + reason: "typed AutomationEvent bus; the Tauri adapter emits a fixed classified name", + }, + ReceiverFalsePositive { + file: "src-tauri/src/application/automation/service.rs", + receiver: "self.event_emitter", + reason: "typed AutomationEvent bus; the Tauri adapter emits a fixed classified name", + }, + ReceiverFalsePositive { + file: "src-tauri/src/application/automation/transition.rs", + receiver: "self.event_emitter", + reason: "typed AutomationEvent bus; the Tauri adapter emits a fixed classified name", + }, +]; + +/// Functions whose closed, literal/const return vocabulary is intentionally used as an event +/// name. This is visible in the manifest rather than treating the value as a dynamic exemption. +const STATIC_EVENT_FUNCTIONS: &[StaticEventFunction] = &[StaticEventFunction { + function: "menu_event_name_for_id", + names: &["ralphx://check-for-updates", "ralphx://show-release-notes"], + reason: "native-menu ID mapping returns one of two local chrome event constants", +}]; + +/// Reviewed exceptions for names classified for remote delivery but which have no Tauri emit +/// producer in the current application. This is deliberately a closed list: either adding a +/// classification or landing its source emit requires a corresponding reviewed ledger update. +const UNMATCHED_EVENT_GAPS: &[ReviewedUnmatchedEvent] = &[ + ReviewedUnmatchedEvent::new("execution:error", "no-tauri-emitter", "execution error state is surfaced through query invalidation, not a Tauri emit"), + ReviewedUnmatchedEvent::new("file:change", "no-tauri-emitter", "file changes are consumed from watcher state without a Tauri emit"), + ReviewedUnmatchedEvent::new("proposal:deleted", "no-tauri-emitter", "proposal deletion has no current Tauri event producer"), + ReviewedUnmatchedEvent::new("qa:prep", "no-tauri-emitter", "QA preparation state has no current Tauri event producer"), + ReviewedUnmatchedEvent::new("qa:test", "no-tauri-emitter", "QA test state has no current Tauri event producer"), + ReviewedUnmatchedEvent::new("step:deleted", "no-tauri-emitter", "step deletion has no current Tauri event producer"), + ReviewedUnmatchedEvent::new("step:status_changed", "no-tauri-emitter", "step status changes have no current Tauri event producer"), + ReviewedUnmatchedEvent::new("steps:reordered", "no-tauri-emitter", "step reordering has no current Tauri event producer"), + ReviewedUnmatchedEvent::new("supervisor:alert", "no-tauri-emitter", "supervisor alerts have no current Tauri event producer"), + ReviewedUnmatchedEvent::new("supervisor:event", "no-tauri-emitter", "supervisor events have no current Tauri event producer"), + ReviewedUnmatchedEvent::new("execution:stderr", "no-tauri-emitter", "execution stderr is consumed from process state without a Tauri emit"), +]; + +#[derive(Clone, Copy)] +struct Wrapper { + name: &'static str, + event_arg: Option, +} + +impl Wrapper { + const fn function(name: &'static str, event_arg: Option) -> Self { + Self { name, event_arg } + } + + const fn method(name: &'static str, event_arg: Option) -> Self { + Self { name, event_arg } + } +} + +#[derive(Clone, Copy)] +struct FalsePositive { + function: &'static str, + reason: &'static str, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +pub struct ReceiverFalsePositive { + file: &'static str, + receiver: &'static str, + reason: &'static str, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +pub struct StaticEventFunction { + function: &'static str, + names: &'static [&'static str], + reason: &'static str, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)] +pub struct ReviewedUnmatchedEvent { + name: &'static str, + reason_code: &'static str, + reason: &'static str, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ManifestFalsePositive { + kind: String, + target: String, + reason: String, +} + +impl ReviewedUnmatchedEvent { + const fn new(name: &'static str, reason_code: &'static str, reason: &'static str) -> Self { + Self { name, reason_code, reason } + } + + pub fn name(&self) -> &'static str { + self.name + } +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq, PartialOrd, Ord)] +pub struct EmitSite { + pub name: String, + pub file: String, + pub line: usize, + pub kind: &'static str, +} + +#[derive(Debug, Serialize, PartialEq, Eq)] +pub struct Manifest { + pub schema_version: u8, + pub emitted: Vec, + pub consumed: Vec, + pub classified: Vec, + pub false_positive_allowlist: Vec, + pub static_event_functions: Vec, + pub unmatched_classified_events: Vec, +} + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum ScanError { + #[error("{file}:{line}: unresolved event name in {function}")] + UnresolvedEmit { + file: String, + line: usize, + function: String, + }, + #[error("{file}:{line}: wrapper {function} forwards an event-name parameter but is absent from WRAPPERS")] + UnregisteredWrapper { + file: String, + line: usize, + function: String, + }, + #[error("{file}:{line}: wrapper {function} is called with an unresolved event-name argument")] + UnresolvedWrapperCall { + file: String, + line: usize, + function: String, + }, + #[error("{file}:{line}: emit has no event-name argument")] + MissingEventArgument { file: String, line: usize }, + #[error("{0}")] + Parse(String), +} + +pub fn scan_rust_source(file: impl Into, source: &str) -> Result, ScanError> { + let file = file.into(); + let syntax = syn::parse_file(source).map_err(|error| ScanError::Parse(format!("{file}: {error}")))?; + let constants = collect_constants_from_file(&syntax); + let functions = collect_functions(&syntax); + verify_wrapper_contract(&file, &syntax, &constants, &functions)?; + let mut visitor = EmitVisitor { + file, + constants: &constants, + current_function: None, + current_locals: BTreeMap::new(), + sites: Vec::new(), + error: None, + }; + visitor.visit_file(&syntax); + visitor.error.map_or_else(|| Ok(visitor.sites), Err) +} + +pub fn build_manifest(root: &Path) -> Result { + let rust_root = root.join("src-tauri/src"); + let constants = collect_rust_constants(&rust_root)?; + let mut emitted = Vec::new(); + for path in files_with_extension(&rust_root, "rs")? { + let source = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + let syntax = syn::parse_file(&source).with_context(|| format!("parse {}", path.display()))?; + let functions = collect_functions(&syntax); + let file = relative(root, &path); + verify_wrapper_contract(&file, &syntax, &constants, &functions).map_err(anyhow::Error::new)?; + let mut visitor = EmitVisitor { + file, + constants: &constants, + current_function: None, + current_locals: BTreeMap::new(), + sites: Vec::new(), + error: None, + }; + visitor.visit_file(&syntax); + if let Some(error) = visitor.error { + return Err(error.into()); + } + emitted.extend(visitor.sites); + } + emitted.sort(); + emitted.dedup(); + + let consumed = consumed_names(&root.join("frontend/src"))?; + let classified = EVENT_CLASSIFICATIONS + .iter() + .map(|entry| entry.name.to_owned()) + .collect::>(); + verify_manifest(&emitted, &consumed, EVENT_CLASSIFICATIONS)?; + Ok(Manifest { + schema_version: 1, + emitted, + consumed, + classified, + false_positive_allowlist: manifest_false_positives(), + static_event_functions: STATIC_EVENT_FUNCTIONS.to_vec(), + unmatched_classified_events: reviewed_unmatched_events(), + }) +} + +fn verify_manifest( + emitted: &[EmitSite], + consumed: &[String], + classifications: &[EventClassification], +) -> Result<()> { + let classified = classifications + .iter() + .map(|entry| entry.name) + .collect::>(); + let missing_classifications = consumed + .iter() + .filter(|name| !classified.contains(name.as_str())) + .cloned() + .collect::>(); + if !missing_classifications.is_empty() { + bail!("UI-consumed event names are unclassified: {}", missing_classifications.join(", ")); + } + + let emitted_names = emitted.iter().map(|site| site.name.as_str()).collect::>(); + let missing_emits = classifications + .iter() + .filter(|entry| !matches!(entry.delivery, ralphx_remote_protocol::EventDelivery::LocalOnly)) + .filter(|entry| !emitted_names.contains(entry.name)) + .map(|entry| entry.name) + .collect::>(); + verify_unmatched_event_coverage(&missing_emits)?; + Ok(()) +} + +pub fn reviewed_unmatched_events() -> Vec { + UNMATCHED_EVENT_GAPS.to_vec() +} + +fn manifest_false_positives() -> Vec { + let mut entries = FALSE_POSITIVE_ALLOWLIST + .iter() + .map(|entry| ManifestFalsePositive { + kind: "function".to_owned(), + target: entry.function.to_owned(), + reason: entry.reason.to_owned(), + }) + .chain(RECEIVER_FALSE_POSITIVE_ALLOWLIST.iter().map(|entry| ManifestFalsePositive { + kind: "receiver".to_owned(), + target: format!("{}::{}", entry.file, entry.receiver), + reason: entry.reason.to_owned(), + })) + .collect::>(); + entries.sort_by(|left, right| left.target.cmp(&right.target)); + entries +} + +pub fn verify_unmatched_event_coverage(missing: &[&str]) -> Result<()> { + let expected = UNMATCHED_EVENT_GAPS + .iter() + .map(|gap| gap.name) + .collect::>(); + let actual = missing.iter().copied().collect::>(); + let unexpected = actual.difference(&expected).copied().collect::>(); + let resolved = expected.difference(&actual).copied().collect::>(); + if !unexpected.is_empty() { + bail!( + "classified Durable/Transient event names have no resolved Rust emit site and no reviewed gap entry: {}", + unexpected.join(", ") + ); + } + if !resolved.is_empty() { + bail!( + "reviewed unmatched event entries now have a resolved Rust emit site; remove them from the ledger: {}", + resolved.join(", ") + ); + } + Ok(()) +} + +fn collect_rust_constants(root: &Path) -> Result> { + let mut values = BTreeMap::new(); + for path in files_with_extension(root, "rs")? { + let source = fs::read_to_string(&path)?; + let syntax = syn::parse_file(&source)?; + merge_constants(&mut values, collect_constants_from_file(&syntax)); + } + Ok(values) +} + +fn collect_constants_from_file(file: &File) -> BTreeMap { + struct Constants(BTreeMap); + impl<'ast> Visit<'ast> for Constants { + fn visit_item_const(&mut self, node: &'ast ItemConst) { + if let Some(value) = literal(&node.expr) { + self.0.insert(node.ident.to_string(), value); + } + visit::visit_item_const(self, node); + } + } + let mut visitor = Constants(BTreeMap::new()); + visitor.visit_file(file); + visitor.0 +} + +fn merge_constants(into: &mut BTreeMap, next: BTreeMap) { + for (name, value) in next { + if let Some(previous) = into.get(&name) { + if previous != &value { + into.remove(&name); + } + } else { + into.insert(name, value); + } + } +} + +#[derive(Debug, Clone)] +struct FunctionInfo { + name: String, + simple_name: String, + emitted_param_indexes: BTreeSet, +} + +fn collect_functions(file: &File) -> Vec { + let mut functions = Vec::new(); + for item in &file.items { + match item { + Item::Fn(function) => functions.push(function_info(function, None)), + Item::Impl(implementation) => { + let type_name = impl_type_name(&implementation.self_ty); + for member in &implementation.items { + if let ImplItem::Fn(function) = member { + functions.push(impl_function_info(function, type_name.as_deref())); + } + } + } + _ => {} + } + } + functions +} + +fn function_info(function: &ItemFn, owner: Option<&str>) -> FunctionInfo { + function_info_from_parts( + owner, + function.sig.ident.to_string(), + &function.sig.inputs, + &function.block, + function.span().start().line, + ) +} + +fn impl_function_info(function: &syn::ImplItemFn, owner: Option<&str>) -> FunctionInfo { + function_info_from_parts( + owner, + function.sig.ident.to_string(), + &function.sig.inputs, + &function.block, + function.span().start().line, + ) +} + +fn function_info_from_parts( + owner: Option<&str>, + simple_name: String, + inputs: &syn::punctuated::Punctuated, + body: &syn::Block, + line: usize, +) -> FunctionInfo { + let param_names = inputs + .iter() + .map(|input| match input { + syn::FnArg::Receiver(_) => None, + syn::FnArg::Typed(argument) => match peel_pat(&argument.pat) { + Pat::Ident(identifier) => Some(identifier.ident.to_string()), + _ => None, + }, + }) + .collect::>(); + let param_set = param_names.iter().flatten().cloned().collect::>(); + let mut flow = ParameterEmitFlow { + params: ¶m_set, + names: BTreeSet::new(), + }; + flow.visit_block(body); + let emitted_param_indexes = param_names + .iter() + .enumerate() + .filter_map(|(index, name)| name.as_ref().filter(|name| flow.names.contains(*name)).map(|_| index)) + .collect(); + let name = owner.map_or_else(|| simple_name.clone(), |owner| format!("{owner}::{simple_name}")); + let _ = line; + FunctionInfo { name, simple_name, emitted_param_indexes } +} + +fn impl_type_name(ty: &syn::Type) -> Option { + match ty { + syn::Type::Path(path) => path.path.segments.last().map(|segment| segment.ident.to_string()), + _ => None, + } +} + +struct ParameterEmitFlow<'a> { + params: &'a BTreeSet, + names: BTreeSet, +} + +impl<'ast> Visit<'ast> for ParameterEmitFlow<'_> { + fn visit_expr_method_call(&mut self, node: &'ast ExprMethodCall) { + if node.method == "emit" { + if let Some(Expr::Path(path)) = node.args.first().map(peel) { + if let Some(identifier) = path.path.get_ident() { + if self.params.contains(&identifier.to_string()) { + self.names.insert(identifier.to_string()); + } + } + } + } + visit::visit_expr_method_call(self, node); + } +} + +fn verify_wrapper_contract( + file: &str, + syntax: &File, + constants: &BTreeMap, + functions: &[FunctionInfo], +) -> Result<(), ScanError> { + let mut calls = Vec::new(); + { + let mut collector = CallCollector { calls: &mut calls }; + collector.visit_file(syntax); + } + for function in functions.iter().filter(|function| !function.emitted_param_indexes.is_empty()) { + for call in calls.iter().filter(|call| call.name == function.simple_name) { + for index in &function.emitted_param_indexes { + let Some(argument) = call.args.get(*index) else { continue }; + let line = call.line; + if wrapper_for_function(function).is_some() { + continue; + } + if resolve_name(argument, constants).is_none() { + return Err(ScanError::UnresolvedWrapperCall { + file: file.to_owned(), + line, + function: function.name.clone(), + }); + } + if !is_false_positive(function) { + return Err(ScanError::UnregisteredWrapper { + file: file.to_owned(), + line, + function: function.name.clone(), + }); + } + } + } + } + Ok(()) +} + +struct CallSite { + name: String, + args: Vec, + line: usize, +} + +struct CallCollector<'a> { + calls: &'a mut Vec, +} + +impl<'ast> Visit<'ast> for CallCollector<'ast> { + fn visit_expr_call(&mut self, node: &'ast ExprCall) { + if let Expr::Path(path) = peel(&node.func) { + if let Some(segment) = path.path.segments.last() { + self.calls.push(CallSite { + name: segment.ident.to_string(), + args: node.args.iter().cloned().collect(), + line: node.span().start().line, + }); + } + } + visit::visit_expr_call(self, node); + } +} + +fn wrapper_for_method(method: &str, current_function: Option<&str>) -> Option<&'static Wrapper> { + WRAPPERS.iter().find(|wrapper| { + wrapper + .name + .rsplit_once("::") + .is_some_and(|(_, name)| name == method) + && (method != "emit_event" + || current_function.is_some_and(|function| function.starts_with("AppChatService::"))) + }) +} + +fn wrapper_for_function(function: &FunctionInfo) -> Option<&'static Wrapper> { + WRAPPERS.iter().find(|wrapper| wrapper.name == function.name || wrapper.name == function.simple_name) +} + +fn is_false_positive(function: &FunctionInfo) -> bool { + FALSE_POSITIVE_ALLOWLIST.iter().any(|entry| { + debug_assert!(!entry.reason.is_empty()); + entry.function == function.name + }) +} + +struct EmitVisitor<'a> { + file: String, + constants: &'a BTreeMap, + current_function: Option, + current_locals: BTreeMap>, + sites: Vec, + error: Option, +} + +impl EmitVisitor<'_> { + fn record( + &mut self, + expr: &Expr, + receiver: Option<&Expr>, + span: proc_macro2::Span, + kind: &'static str, + ) { + if self.error.is_some() { + return; + } + let names = resolve_names(expr, self.constants, &self.current_locals); + if !names.is_empty() { + for name in names { + self.sites.push(EmitSite { + name, + file: self.file.clone(), + line: span.start().line, + kind, + }); + } + } else if !self.current_function_is_known_wrapper_or_false_positive() + && !receiver.is_some_and(|receiver| self.is_allowlisted_receiver(receiver)) + { + self.error = Some(ScanError::UnresolvedEmit { + file: self.file.clone(), + line: span.start().line, + function: self.current_function.clone().unwrap_or_else(|| "".into()), + }); + } + } + + fn current_function_is_known_wrapper_or_false_positive(&self) -> bool { + self.current_function.as_deref().is_some_and(|name| { + WRAPPERS.iter().any(|wrapper| wrapper.name == name) + || FALSE_POSITIVE_ALLOWLIST.iter().any(|entry| entry.function == name && !entry.reason.is_empty()) + }) + } + + fn is_allowlisted_receiver(&self, receiver: &Expr) -> bool { + let Some(identity) = receiver_identity(receiver) else { + return false; + }; + RECEIVER_FALSE_POSITIVE_ALLOWLIST.iter().any(|entry| { + debug_assert!(!entry.reason.is_empty()); + entry.file == self.file && entry.receiver == identity + }) + } + + fn enter_function( + &mut self, + name: String, + locals: BTreeMap>, + visit: impl FnOnce(&mut Self), + ) { + let previous = self.current_function.replace(name); + let previous_locals = std::mem::replace(&mut self.current_locals, locals); + visit(self); + self.current_function = previous; + self.current_locals = previous_locals; + } +} + +impl<'ast> Visit<'ast> for EmitVisitor<'_> { + fn visit_item_fn(&mut self, node: &'ast ItemFn) { + self.enter_function( + node.sig.ident.to_string(), + static_local_names(&node.block, self.constants), + |visitor| visit::visit_item_fn(visitor, node), + ); + } + + fn visit_item_impl(&mut self, node: &'ast syn::ItemImpl) { + let owner = impl_type_name(&node.self_ty); + for member in &node.items { + if let ImplItem::Fn(function) = member { + let name = owner.as_ref().map_or_else( + || function.sig.ident.to_string(), + |owner| format!("{owner}::{}", function.sig.ident), + ); + self.enter_function( + name, + static_local_names(&function.block, self.constants), + |visitor| visit::visit_impl_item_fn(visitor, function), + ); + } else { + self.visit_impl_item(member); + } + } + } + + fn visit_expr_method_call(&mut self, node: &'ast ExprMethodCall) { + if node.method == "emit" { + if let Some(event) = node.args.first() { + self.record(event, Some(&node.receiver), node.span(), "method"); + } else if self.error.is_none() { + self.error = Some(ScanError::MissingEventArgument { + file: self.file.clone(), + line: node.span().start().line, + }); + } + } else if let Some(wrapper) = + wrapper_for_method(&node.method.to_string(), self.current_function.as_deref()) + { + if let Some(index) = wrapper.event_arg { + if let Some(event) = node.args.iter().nth(index) { + self.record(event, Some(&node.receiver), node.span(), "wrapper_method"); + } else if self.error.is_none() { + self.error = Some(ScanError::MissingEventArgument { + file: self.file.clone(), + line: node.span().start().line, + }); + } + } + } + visit::visit_expr_method_call(self, node); + } + + fn visit_expr_call(&mut self, node: &'ast ExprCall) { + if let Expr::Path(path) = peel(&node.func) { + if let Some(name) = path.path.segments.last().map(|segment| segment.ident.to_string()) { + if let Some(wrapper) = WRAPPERS.iter().find(|wrapper| wrapper.name == name) { + if let Some(index) = wrapper.event_arg { + if let Some(event) = node.args.iter().nth(index) { + self.record(event, None, node.span(), "wrapper"); + } else if self.error.is_none() { + self.error = Some(ScanError::MissingEventArgument { + file: self.file.clone(), + line: node.span().start().line, + }); + } + } + } + } + } + visit::visit_expr_call(self, node); + } +} + +fn resolve_name(expr: &Expr, constants: &BTreeMap) -> Option { + literal(expr).or_else(|| match peel(expr) { + Expr::Path(path) => path + .path + .segments + .last() + .and_then(|segment| constants.get(&segment.ident.to_string())) + .cloned(), + Expr::Reference(reference) => resolve_name(&reference.expr, constants), + _ => None, + }) +} + +fn resolve_names( + expr: &Expr, + constants: &BTreeMap, + locals: &BTreeMap>, +) -> BTreeSet { + if let Expr::Path(path) = peel(expr) { + if let Some(identifier) = path.path.get_ident() { + if let Some(names) = locals.get(&identifier.to_string()) { + return names.clone(); + } + } + } + resolve_name(expr, constants).into_iter().collect() +} + +fn static_local_names( + block: &syn::Block, + constants: &BTreeMap, +) -> BTreeMap> { + struct Locals { + bindings: Vec<(Pat, Expr)>, + } + impl<'ast> Visit<'ast> for Locals { + fn visit_local(&mut self, node: &'ast syn::Local) { + if let Some(initializer) = &node.init { + self.bindings.push((node.pat.clone(), (*initializer.expr).clone())); + } + visit::visit_local(self, node); + } + + fn visit_expr_let(&mut self, node: &'ast syn::ExprLet) { + self.bindings.push(((*node.pat).clone(), (*node.expr).clone())); + visit::visit_expr_let(self, node); + } + } + let mut collector = Locals { bindings: Vec::new() }; + collector.visit_block(block); + let mut values = BTreeMap::new(); + for _ in 0..collector.bindings.len() { + let mut changed = false; + for (pattern, expression) in &collector.bindings { + for (identifier, names) in static_pattern_bindings(pattern, expression, constants, &values) { + if names.is_empty() { + continue; + } + changed |= values.get(&identifier) != Some(&names); + values.insert(identifier, names.clone()); + } + } + if !changed { + break; + } + } + values +} + +fn static_expression_names( + expression: &Expr, + constants: &BTreeMap, + locals: &BTreeMap>, +) -> BTreeSet { + match peel(expression) { + Expr::Lit(_) | Expr::Reference(_) | Expr::Path(_) => resolve_names(expression, constants, locals), + Expr::If(expression) => { + let mut names = static_block_names(&expression.then_branch, constants, locals); + if let Some((_, otherwise)) = &expression.else_branch { + names.extend(static_expression_names(otherwise, constants, locals)); + } + names + } + Expr::Block(expression) => static_block_names(&expression.block, constants, locals), + Expr::Match(expression) => expression + .arms + .iter() + .flat_map(|arm| static_expression_names(&arm.body, constants, locals)) + .collect(), + Expr::Tuple(expression) => expression + .elems + .first() + .map(|element| static_expression_names(element, constants, locals)) + .unwrap_or_default(), + Expr::Call(expression) if expression.args.len() == 1 => { + let function = match peel(&expression.func) { + Expr::Path(path) => path.path.segments.last().map(|segment| segment.ident.to_string()), + _ => None, + }; + if function.as_deref() == Some("Some") { + static_expression_names(expression.args.first().expect("one checked argument"), constants, locals) + } else if let Some(output) = STATIC_EVENT_FUNCTIONS + .iter() + .find(|output| Some(output.function) == function.as_deref()) + { + debug_assert!(!output.reason.is_empty()); + output.names.iter().map(|name| (*name).to_owned()).collect() + } else { + BTreeSet::new() + } + } + Expr::MethodCall(expression) if expression.method == "map" && expression.args.len() == 1 => { + static_expression_names(expression.args.first().expect("one checked argument"), constants, locals) + } + Expr::Closure(expression) => static_expression_names(&expression.body, constants, locals), + _ => BTreeSet::new(), + } +} + +fn static_block_names( + block: &syn::Block, + constants: &BTreeMap, + locals: &BTreeMap>, +) -> BTreeSet { + block.stmts.last().map_or_else(BTreeSet::new, |statement| match statement { + syn::Stmt::Expr(expression, _) => static_expression_names(expression, constants, locals), + _ => BTreeSet::new(), + }) +} + +fn static_pattern_bindings( + pattern: &Pat, + expression: &Expr, + constants: &BTreeMap, + locals: &BTreeMap>, +) -> Vec<(String, BTreeSet)> { + match pattern { + Pat::Ident(identifier) => vec![( + identifier.ident.to_string(), + static_expression_names(expression, constants, locals), + )], + Pat::Type(pattern) => static_pattern_bindings(&pattern.pat, expression, constants, locals), + Pat::Paren(pattern) => static_pattern_bindings(&pattern.pat, expression, constants, locals), + Pat::Reference(pattern) => static_pattern_bindings(&pattern.pat, expression, constants, locals), + Pat::Or(pattern) => pattern + .cases + .iter() + .flat_map(|case| static_pattern_bindings(case, expression, constants, locals)) + .collect(), + Pat::Tuple(tuple) => match peel(expression) { + Expr::Tuple(values) => tuple + .elems + .iter() + .zip(values.elems.iter()) + .flat_map(|(pattern, value)| static_pattern_bindings(pattern, value, constants, locals)) + .collect(), + _ => tuple + .elems + .iter() + .flat_map(|pattern| static_pattern_bindings(pattern, expression, constants, locals)) + .collect(), + }, + Pat::TupleStruct(tuple) => match peel(expression) { + Expr::Tuple(values) => tuple + .elems + .iter() + .zip(values.elems.iter()) + .flat_map(|(pattern, value)| static_pattern_bindings(pattern, value, constants, locals)) + .collect(), + _ => tuple + .elems + .iter() + .flat_map(|pattern| static_pattern_bindings(pattern, expression, constants, locals)) + .collect(), + }, + _ => Vec::new(), + } +} + +fn peel(expr: &Expr) -> &Expr { + match expr { + Expr::Paren(expression) => peel(&expression.expr), + Expr::Group(expression) => peel(&expression.expr), + _ => expr, + } +} + +fn peel_pat(pattern: &Pat) -> &Pat { + match pattern { + Pat::Type(pattern) => peel_pat(&pattern.pat), + _ => pattern, + } +} + +fn receiver_identity(expr: &Expr) -> Option<&'static str> { + match peel(expr) { + Expr::Field(field) + if matches!(peel(&field.base), Expr::Path(path) if path.path.is_ident("self")) + && matches!(&field.member, syn::Member::Named(name) if name == "event_emitter") => + { + Some("self.event_emitter") + } + _ => None, + } +} + +fn literal(expr: &Expr) -> Option { + match peel(expr) { + Expr::Lit(expression) => match &expression.lit { + Lit::Str(value) => Some(value.value()), + _ => None, + }, + _ => None, + } +} + +fn files_with_extension(root: &Path, extension: &str) -> Result> { + let mut paths = WalkDir::new(root) + .into_iter() + .filter_map(|entry| entry.ok()) + .filter(|entry| entry.file_type().is_file()) + .filter(|entry| entry.path().extension().is_some_and(|value| value == extension)) + .map(|entry| entry.into_path()) + .collect::>(); + paths.sort(); + Ok(paths) +} + +fn relative(root: &Path, path: &Path) -> String { + path.strip_prefix(root).unwrap_or(path).display().to_string() +} + +fn consumed_names(root: &Path) -> Result> { + let mut names = BTreeSet::new(); + let shared_constants = frontend_event_constants(root)?; + for extension in ["ts", "tsx"] { + for path in files_with_extension(root, extension)? { + if path.file_name().is_some_and(|name| name.to_string_lossy().contains(".test.")) { + continue; + } + let source = fs::read_to_string(&path)?; + if !source.contains(".subscribe") { + continue; + } + names.extend( + scan_consumed_source_with_constants(&path.display().to_string(), &source, &shared_constants) + .with_context(|| format!("scan {}", path.display()))?, + ); + } + } + Ok(names.into_iter().collect()) +} + +pub fn scan_consumed_source(file: &str, source: &str) -> Result> { + scan_consumed_source_with_constants(file, source, &BTreeMap::new()) +} + +fn scan_consumed_source_with_constants( + file: &str, + source: &str, + shared_constants: &BTreeMap>, +) -> Result> { + let mut parser = Parser::new(); + parser + .set_language(&LANGUAGE_TSX.into()) + .map_err(|error| anyhow::anyhow!("{file}: configure TSX parser: {error}"))?; + let tree = parser + .parse(source, None) + .ok_or_else(|| anyhow::anyhow!("{file}: TSX parser returned no tree"))?; + if tree.root_node().has_error() { + bail!("{file}: invalid TS/TSX source") + } + let mut constants = shared_constants.clone(); + constants.extend(collect_ts_constants(tree.root_node(), source)); + let mut values = BTreeSet::new(); + collect_subscriptions( + tree.root_node(), + source, + file, + &constants, + &BTreeMap::new(), + &mut values, + )?; + Ok(values.into_iter().collect()) +} + +fn frontend_event_constants(root: &Path) -> Result>> { + let path = root.join("lib/events.ts"); + let source = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + let mut parser = Parser::new(); + parser + .set_language(&LANGUAGE_TSX.into()) + .map_err(|error| anyhow::anyhow!("configure TSX parser: {error}"))?; + let tree = parser + .parse(&source, None) + .ok_or_else(|| anyhow::anyhow!("parse {} returned no tree", path.display()))?; + if tree.root_node().has_error() { + bail!("{}: invalid TypeScript event constants", path.display()); + } + Ok(collect_ts_constants(tree.root_node(), &source)) +} + +fn collect_ts_constants(root: Node<'_>, source: &str) -> BTreeMap> { + let mut values = BTreeMap::new(); + let mut cursor = root.walk(); + for child in root.children(&mut cursor) { + collect_ts_constants_inner(child, source, &mut values); + } + values +} + +fn collect_ts_constants_inner( + node: Node<'_>, + source: &str, + values: &mut BTreeMap>, +) { + if node.kind() == "variable_declarator" { + if let (Some(name), Some(value)) = (node.child_by_field_name("name"), node.child_by_field_name("value")) { + if name.kind() == "identifier" { + if let Some(events) = static_event_values(value, source) { + values.insert(node_text(name, source).to_owned(), events); + } + } + } + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + collect_ts_constants_inner(child, source, values); + } +} + +fn collect_subscriptions( + node: Node<'_>, + source: &str, + file: &str, + constants: &BTreeMap>, + mapped_values: &BTreeMap>, + output: &mut BTreeSet, +) -> Result<()> { + if node.kind() == "call_expression" { + if let Some((list_name, callback)) = static_map_call(node, source) { + if !contains_event_bus_subscribe(callback, source) { + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + collect_subscriptions(child, source, file, constants, mapped_values, output)?; + } + return Ok(()); + } + let events = constants.get(list_name).ok_or_else(|| { + anyhow::anyhow!("{file}:{} mapped event list `{list_name}` is not a static literal array", node.start_position().row + 1) + })?; + let parameter = callback_parameter(callback, source).ok_or_else(|| { + anyhow::anyhow!("{file}:{} mapped event callback must declare one identifier parameter", callback.start_position().row + 1) + })?; + let mut mapped = mapped_values.clone(); + mapped.insert(parameter, events.clone()); + let mut cursor = callback.walk(); + for child in callback.children(&mut cursor) { + collect_subscriptions(child, source, file, constants, &mapped, output)?; + } + return Ok(()); + } + if is_event_bus_subscribe(node, source) { + let argument = first_argument(node).ok_or_else(|| { + anyhow::anyhow!("{file}:{} EventBus.subscribe requires an event-name argument", node.start_position().row + 1) + })?; + let events = event_values(argument, source, constants, mapped_values).ok_or_else(|| { + anyhow::anyhow!( + "{file}:{} unresolved EventBus subscription argument `{}`", + node.start_position().row + 1, + node_text(argument, source) + ) + })?; + output.extend(events); + } + } + + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + collect_subscriptions(child, source, file, constants, mapped_values, output)?; + } + Ok(()) +} + +fn contains_event_bus_subscribe(node: Node<'_>, source: &str) -> bool { + if node.kind() == "call_expression" && is_event_bus_subscribe(node, source) { + return true; + } + let mut cursor = node.walk(); + let contains = node + .children(&mut cursor) + .any(|child| contains_event_bus_subscribe(child, source)); + contains +} + +fn static_map_call<'a>(node: Node<'a>, source: &'a str) -> Option<(&'a str, Node<'a>)> { + let function = node.child_by_field_name("function")?; + if function.kind() != "member_expression" || member_property(function, source) != Some("map") { + return None; + } + let object = function.child_by_field_name("object")?; + if object.kind() != "identifier" { + return None; + } + let arguments = node.child_by_field_name("arguments")?; + let callback = named_children(arguments).into_iter().find(|child| child.kind() == "arrow_function")?; + Some((node_text(object, source), callback)) +} + +fn callback_parameter(callback: Node<'_>, source: &str) -> Option { + let parameters = callback.child_by_field_name("parameters")?; + let parameter = named_children(parameters).into_iter().next()?; + let pattern = if parameter.kind() == "required_parameter" { + parameter.child_by_field_name("pattern")? + } else { + parameter + }; + (pattern.kind() == "identifier").then(|| node_text(pattern, source).to_owned()) +} + +fn is_event_bus_subscribe(node: Node<'_>, source: &str) -> bool { + let Some(function) = node.child_by_field_name("function") else { return false }; + if function.kind() != "member_expression" || member_property(function, source) != Some("subscribe") { + return false; + } + let Some(object) = function.child_by_field_name("object") else { return false }; + matches!(node_text(object, source), "bus" | "eventBus") +} + +fn member_property<'a>(node: Node<'a>, source: &'a str) -> Option<&'a str> { + node.child_by_field_name("property").map(|property| node_text(property, source)) +} + +fn first_argument(node: Node<'_>) -> Option> { + let arguments = node.child_by_field_name("arguments")?; + named_children(arguments).into_iter().next() +} + +fn event_values( + argument: Node<'_>, + source: &str, + constants: &BTreeMap>, + mapped_values: &BTreeMap>, +) -> Option> { + static_event_values(argument, source).or_else(|| { + (argument.kind() == "identifier").then(|| { + let name = node_text(argument, source); + mapped_values.get(name).or_else(|| constants.get(name)).cloned() + })? + }) +} + +fn static_event_values(node: Node<'_>, source: &str) -> Option> { + match node.kind() { + "string" => string_value(node, source).map(|value| vec![value]), + "array" => named_children(node) + .into_iter() + .map(|child| string_value(child, source)) + .collect(), + "as_expression" | "parenthesized_expression" => named_children(node) + .into_iter() + .next() + .and_then(|expression| static_event_values(expression, source)), + _ => None, + } +} + +fn string_value(node: Node<'_>, source: &str) -> Option { + if node.kind() != "string" { + return None; + } + let text = node_text(node, source); + let quote = text.chars().next()?; + let end = text.strip_prefix(quote)?.strip_suffix(quote)?; + (!end.contains("${")).then(|| end.to_owned()) +} + +fn named_children(node: Node<'_>) -> Vec> { + let mut cursor = node.walk(); + node.named_children(&mut cursor).collect() +} + +fn node_text<'a>(node: Node<'_>, source: &'a str) -> &'a str { + &source[node.byte_range()] +} diff --git a/scripts/event-manifest-scanner/src/main.rs b/scripts/event-manifest-scanner/src/main.rs new file mode 100644 index 0000000000..76b38b3b82 --- /dev/null +++ b/scripts/event-manifest-scanner/src/main.rs @@ -0,0 +1,33 @@ +use anyhow::{bail, Context, Result}; +use event_manifest_scanner::build_manifest; +use std::env; +use std::fs; +use std::path::PathBuf; + +fn main() -> Result<()> { + let mut args = env::args().skip(1); + let mode = args.next().unwrap_or_else(|| "--check".into()); + let root = args.next().map(PathBuf::from).unwrap_or(env::current_dir()?); + if args.next().is_some() { + bail!("usage: event-manifest-scanner [--check|--write] [repository-root]"); + } + + let output = root.join("scripts/event-manifest.json"); + let rendered = serde_json::to_string_pretty(&build_manifest(&root)?)? + "\n"; + match mode.as_str() { + "--write" => fs::write(&output, rendered).with_context(|| format!("write {}", output.display())), + "--check" => { + let checked = fs::read_to_string(&output) + .with_context(|| format!("read {}; run with --write", output.display()))?; + if checked == rendered { + Ok(()) + } else { + bail!( + "{} is stale; regenerate with: cargo run --manifest-path scripts/event-manifest-scanner/Cargo.toml -- --write", + output.display() + ); + } + } + _ => bail!("usage: event-manifest-scanner [--check|--write] [repository-root]"), + } +} diff --git a/scripts/event-manifest-scanner/tests/fixtures/dynamic_emit.rs b/scripts/event-manifest-scanner/tests/fixtures/dynamic_emit.rs new file mode 100644 index 0000000000..b816dbf2c5 --- /dev/null +++ b/scripts/event-manifest-scanner/tests/fixtures/dynamic_emit.rs @@ -0,0 +1,3 @@ +fn dynamic(app: &App, event: &str) { + app.emit(event, ()).unwrap(); +} diff --git a/scripts/event-manifest-scanner/tests/fixtures/dynamic_subscription.ts b/scripts/event-manifest-scanner/tests/fixtures/dynamic_subscription.ts new file mode 100644 index 0000000000..bf15a66358 --- /dev/null +++ b/scripts/event-manifest-scanner/tests/fixtures/dynamic_subscription.ts @@ -0,0 +1,3 @@ +function subscribe(bus: EventBus, event: string) { + bus.subscribe(event, () => {}); +} diff --git a/scripts/event-manifest-scanner/tests/fixtures/receiver_shapes.rs b/scripts/event-manifest-scanner/tests/fixtures/receiver_shapes.rs new file mode 100644 index 0000000000..a1d6c340bc --- /dev/null +++ b/scripts/event-manifest-scanner/tests/fixtures/receiver_shapes.rs @@ -0,0 +1,60 @@ +const AGENT_RUN_COMPLETED: &str = "agent:run_completed"; + +fn emit_app_event(app: &App, event: &str, payload: Payload) { + app.emit(event, payload).unwrap(); +} + +fn emit_http_event(app: &App, event: &str, payload: Payload) { + emit_app_event(app, event, payload); +} + +fn emit_queue_changed(app: &App) { + app.emit("execution:queue_changed", ()).unwrap(); +} + +fn emit_ticketing_operation_event(app: &App, payload: TicketingOperationEvent) { + app.emit("ticketing:cache_invalidated", payload).unwrap(); +} + +fn emit_serialized(sink: &Sink, event: &str, payload: Payload) { + sink.emit(event, payload); +} + +struct ThrottledEmitter; +impl ThrottledEmitter { + fn emit(&self, event: &str, payload: Payload) { + self.sink.emit(event, payload); + } +} + +fn sites(app: &App, app_handle: &AppHandle, sink: &Sink, state: &State, throttled: &ThrottledEmitter) { + app.emit("task:created", ()).unwrap(); + app_handle.emit(AGENT_RUN_COMPLETED, ()).unwrap(); + self.app.emit("task:deleted", ()).unwrap(); + sink.emit("agent:chunk", ()).unwrap(); + let chained = app + .clone() + .manager(); + chained + .emit("notification:created", ()) + .unwrap(); + emit_app_event(app, "task:status_changed", ()); + emit_http_event(app, "task:archived", ()); + emit_queue_changed(app); + emit_ticketing_operation_event(app, TicketingOperationEvent::Changed); + emit_serialized(sink, "task:merge_progress", ()); + throttled.emit("task:created", ()); + state.events.emit("task:restored", ()); +} + +struct AppChatService; +impl AppChatService { + fn emit_event(&self, event: &str, payload: Payload) { + self.handle.emit(event, payload).unwrap(); + } + + fn sites(&self, emitter: &EventEmitter) { + self.emit_event("agent:message_queued", ()); + emitter.emit_with_payload("review:update", "task", "{}"); + } +} diff --git a/scripts/event-manifest-scanner/tests/fixtures/subscriptions.tsx b/scripts/event-manifest-scanner/tests/fixtures/subscriptions.tsx new file mode 100644 index 0000000000..cbc98fbd72 --- /dev/null +++ b/scripts/event-manifest-scanner/tests/fixtures/subscriptions.tsx @@ -0,0 +1,10 @@ +const ATTENTION_INVALIDATION_EVENTS = [ + "agent:run_started", + "agent:run_completed", +] as const; +const DIRECT_EVENT = "notification:created"; + +function subscribe(bus: EventBus) { + bus.subscribe(DIRECT_EVENT, () => {}); + ATTENTION_INVALIDATION_EVENTS.map((event) => bus.subscribe(event, () => {})); +} diff --git a/scripts/event-manifest-scanner/tests/fixtures/unregistered_wrapper.rs b/scripts/event-manifest-scanner/tests/fixtures/unregistered_wrapper.rs new file mode 100644 index 0000000000..ad91158ebe --- /dev/null +++ b/scripts/event-manifest-scanner/tests/fixtures/unregistered_wrapper.rs @@ -0,0 +1,7 @@ +fn unregistered(app: &App, event: &str) { + app.emit(event, ()).unwrap(); +} + +fn call(app: &App) { + unregistered(app, "task:created"); +} diff --git a/scripts/event-manifest-scanner/tests/scanner.rs b/scripts/event-manifest-scanner/tests/scanner.rs new file mode 100644 index 0000000000..5dfbfbad2f --- /dev/null +++ b/scripts/event-manifest-scanner/tests/scanner.rs @@ -0,0 +1,98 @@ +use event_manifest_scanner::{ + reviewed_unmatched_events, scan_consumed_source, scan_rust_source, + verify_unmatched_event_coverage, ScanError, +}; + +fn names(source: &str) -> Vec { + scan_rust_source("fixture.rs", source) + .expect("fixture scans") + .into_iter() + .map(|site| site.name) + .collect() +} + +#[test] +fn resolves_all_required_receiver_and_wrapper_shapes() { + let names = names(include_str!("fixtures/receiver_shapes.rs")); + for expected in [ + "task:created", + "agent:run_completed", + "task:deleted", + "agent:chunk", + "notification:created", + "task:status_changed", + "task:archived", + "execution:queue_changed", + "ticketing:cache_invalidated", + "task:restored", + "task:merge_progress", + "agent:message_queued", + "review:update", + ] { + assert!(names.iter().any(|name| name == expected), "missing {expected}"); + } +} + +#[test] +fn rejects_dynamic_emit_names() { + let error = scan_rust_source("dynamic.rs", include_str!("fixtures/dynamic_emit.rs")) + .expect_err("dynamic event name must fail closed"); + assert!(matches!(error, ScanError::UnresolvedEmit { .. })); +} + +#[test] +fn rejects_unregistered_wrappers_called_with_event_names() { + let error = scan_rust_source( + "unregistered.rs", + include_str!("fixtures/unregistered_wrapper.rs"), + ) + .expect_err("wrapper contract must fail"); + assert!(matches!(error, ScanError::UnregisteredWrapper { .. })); +} + +#[test] +fn resolves_direct_and_static_mapped_event_bus_subscriptions() { + let names = scan_consumed_source( + "subscriptions.tsx", + include_str!("fixtures/subscriptions.tsx"), + ) + .expect("static subscriptions scan"); + assert_eq!( + names, + vec![ + "agent:run_completed".to_owned(), + "agent:run_started".to_owned(), + "notification:created".to_owned(), + ] + ); +} + +#[test] +fn rejects_dynamic_event_bus_subscriptions() { + let error = scan_consumed_source( + "dynamic_subscription.ts", + include_str!("fixtures/dynamic_subscription.ts"), + ) + .expect_err("dynamic subscription must fail closed"); + assert!(error.to_string().contains("unresolved EventBus subscription")); +} + +#[test] +fn renders_reason_coded_reviewed_unmatched_event_gaps() { + let gaps = reviewed_unmatched_events(); + assert_eq!(gaps.len(), 11); + assert!(gaps.iter().any(|gap| gap.name() == "execution:stderr")); + assert!(serde_json::to_value(&gaps) + .expect("gaps serialize") + .as_array() + .expect("gap list") + .iter() + .all(|gap| gap.get("reason_code").is_some() && gap.get("reason").is_some())); +} + +#[test] +fn rejects_new_unreviewed_unmatched_classification() { + let error = verify_unmatched_event_coverage(&["new:event"]) + .expect_err("unknown unmatched event must fail CI"); + assert!(error.to_string().contains("no reviewed gap entry")); +} diff --git a/scripts/event-manifest.json b/scripts/event-manifest.json new file mode 100644 index 0000000000..473c01cfec --- /dev/null +++ b/scripts/event-manifest.json @@ -0,0 +1,2111 @@ +{ + "schema_version": 1, + "emitted": [ + { + "name": "agent:ask_user_question", + "file": "src-tauri/src/http_server/handlers/questions.rs", + "line": 78, + "kind": "wrapper" + }, + { + "name": "agent:chunk", + "file": "src-tauri/src/application/chat_service/chat_service_streaming.rs", + "line": 1721, + "kind": "method" + }, + { + "name": "agent:chunk", + "file": "src-tauri/src/application/chat_service/chat_service_streaming.rs", + "line": 3519, + "kind": "method" + }, + { + "name": "agent:chunk", + "file": "src-tauri/src/remote_server/capture_tests.rs", + "line": 59, + "kind": "method" + }, + { + "name": "agent:conversation_created", + "file": "src-tauri/src/application/agent_conversation_start_service/finish_flow.rs", + "line": 475, + "kind": "method" + }, + { + "name": "agent:conversation_created", + "file": "src-tauri/src/application/chat_service/mod.rs", + "line": 7737, + "kind": "wrapper_method" + }, + { + "name": "agent:conversation_created", + "file": "src-tauri/src/commands/unified_chat_commands/mod.rs", + "line": 2141, + "kind": "method" + }, + { + "name": "agent:conversation_forked", + "file": "src-tauri/src/commands/unified_chat_commands/mod.rs", + "line": 2149, + "kind": "method" + }, + { + "name": "agent:conversation_title_updated", + "file": "src-tauri/src/application/session_namer_agent.rs", + "line": 622, + "kind": "method" + }, + { + "name": "agent:conversation_title_updated", + "file": "src-tauri/src/http_server/handlers/ideation/proposals.rs", + "line": 552, + "kind": "wrapper" + }, + { + "name": "agent:error", + "file": "src-tauri/src/application/chat_service/chat_service_handlers.rs", + "line": 3894, + "kind": "method" + }, + { + "name": "agent:error", + "file": "src-tauri/src/application/chat_service/chat_service_queue.rs", + "line": 643, + "kind": "method" + }, + { + "name": "agent:error", + "file": "src-tauri/src/application/chat_service/chat_service_queue.rs", + "line": 1221, + "kind": "method" + }, + { + "name": "agent:error", + "file": "src-tauri/src/application/chat_service/chat_service_queue.rs", + "line": 1256, + "kind": "method" + }, + { + "name": "agent:error", + "file": "src-tauri/src/application/chat_service/chat_service_queue.rs", + "line": 1286, + "kind": "method" + }, + { + "name": "agent:error", + "file": "src-tauri/src/application/chat_service/chat_service_queue.rs", + "line": 1310, + "kind": "method" + }, + { + "name": "agent:error", + "file": "src-tauri/src/application/chat_service/chat_service_queue.rs", + "line": 1419, + "kind": "method" + }, + { + "name": "agent:error", + "file": "src-tauri/src/application/chat_service/chat_service_queue.rs", + "line": 1720, + "kind": "method" + }, + { + "name": "agent:error", + "file": "src-tauri/src/application/chat_service/chat_service_queue.rs", + "line": 1765, + "kind": "method" + }, + { + "name": "agent:error", + "file": "src-tauri/src/application/chat_service/chat_service_queue.rs", + "line": 2561, + "kind": "method" + }, + { + "name": "agent:error", + "file": "src-tauri/src/application/chat_service/chat_service_queue.rs", + "line": 2745, + "kind": "method" + }, + { + "name": "agent:error", + "file": "src-tauri/src/application/chat_service/chat_service_queue.rs", + "line": 2790, + "kind": "method" + }, + { + "name": "agent:error", + "file": "src-tauri/src/application/chat_service/mod.rs", + "line": 3406, + "kind": "wrapper_method" + }, + { + "name": "agent:error", + "file": "src-tauri/src/application/reconciliation/recovery_queue.rs", + "line": 421, + "kind": "method" + }, + { + "name": "agent:heartbeat", + "file": "src-tauri/src/application/chat_service/chat_service_streaming.rs", + "line": 4068, + "kind": "method" + }, + { + "name": "agent:hook", + "file": "src-tauri/src/application/chat_service/chat_service_streaming.rs", + "line": 2556, + "kind": "method" + }, + { + "name": "agent:hook", + "file": "src-tauri/src/application/chat_service/chat_service_streaming.rs", + "line": 2584, + "kind": "method" + }, + { + "name": "agent:hook", + "file": "src-tauri/src/application/chat_service/chat_service_streaming.rs", + "line": 2605, + "kind": "method" + }, + { + "name": "agent:message", + "file": "src-tauri/src/application/chat_service/chat_service_streaming.rs", + "line": 1740, + "kind": "method" + }, + { + "name": "agent:message", + "file": "src-tauri/src/application/chat_service/chat_service_streaming.rs", + "line": 1781, + "kind": "method" + }, + { + "name": "agent:message", + "file": "src-tauri/src/application/chat_service/chat_service_streaming.rs", + "line": 1971, + "kind": "method" + }, + { + "name": "agent:message", + "file": "src-tauri/src/application/chat_service/chat_service_streaming.rs", + "line": 2712, + "kind": "method" + }, + { + "name": "agent:message_created", + "file": "src-tauri/src/application/chat_service/chat_service_queue.rs", + "line": 1912, + "kind": "method" + }, + { + "name": "agent:message_created", + "file": "src-tauri/src/application/chat_service/chat_service_send_background.rs", + "line": 656, + "kind": "method" + }, + { + "name": "agent:message_created", + "file": "src-tauri/src/application/chat_service/chat_service_send_background.rs", + "line": 752, + "kind": "method" + }, + { + "name": "agent:message_created", + "file": "src-tauri/src/application/chat_service/mod.rs", + "line": 3381, + "kind": "wrapper_method" + }, + { + "name": "agent:message_created", + "file": "src-tauri/src/application/chat_service/mod.rs", + "line": 4089, + "kind": "wrapper_method" + }, + { + "name": "agent:message_created", + "file": "src-tauri/src/application/chat_service/mod.rs", + "line": 5702, + "kind": "wrapper_method" + }, + { + "name": "agent:message_created", + "file": "src-tauri/src/application/chat_service/mod.rs", + "line": 6625, + "kind": "wrapper_method" + }, + { + "name": "agent:message_created", + "file": "src-tauri/src/application/chat_service/mod.rs", + "line": 7590, + "kind": "wrapper_method" + }, + { + "name": "agent:message_queued", + "file": "src-tauri/src/application/chat_service/mod.rs", + "line": 2310, + "kind": "wrapper_method" + }, + { + "name": "agent:question_resolved", + "file": "src-tauri/src/commands/question_commands.rs", + "line": 443, + "kind": "method" + }, + { + "name": "agent:question_resolved", + "file": "src-tauri/src/http_server/handlers/questions.rs", + "line": 233, + "kind": "wrapper" + }, + { + "name": "agent:queue_sent", + "file": "src-tauri/src/application/chat_service/chat_service_queue.rs", + "line": 1329, + "kind": "method" + }, + { + "name": "agent:queue_sent", + "file": "src-tauri/src/application/chat_service/mod.rs", + "line": 2112, + "kind": "wrapper_method" + }, + { + "name": "agent:queue_sent", + "file": "src-tauri/src/application/chat_service/mod.rs", + "line": 7613, + "kind": "wrapper_method" + }, + { + "name": "agent:run_completed", + "file": "src-tauri/src/application/chat_service/chat_service_handlers.rs", + "line": 2385, + "kind": "method" + }, + { + "name": "agent:run_completed", + "file": "src-tauri/src/application/chat_service/chat_service_handlers.rs", + "line": 2462, + "kind": "method" + }, + { + "name": "agent:run_completed", + "file": "src-tauri/src/application/chat_service/chat_service_handlers.rs", + "line": 2970, + "kind": "method" + }, + { + "name": "agent:run_completed", + "file": "src-tauri/src/application/chat_service/chat_service_send_background.rs", + "line": 1829, + "kind": "method" + }, + { + "name": "agent:run_completed", + "file": "src-tauri/src/application/chat_service/chat_service_send_background.rs", + "line": 1943, + "kind": "method" + }, + { + "name": "agent:run_completed", + "file": "src-tauri/src/application/chat_service/chat_service_send_background.rs", + "line": 2160, + "kind": "method" + }, + { + "name": "agent:run_completed", + "file": "src-tauri/src/application/chat_service/mod.rs", + "line": 7828, + "kind": "wrapper_method" + }, + { + "name": "agent:run_completed", + "file": "src-tauri/src/application/chat_service/mod.rs", + "line": 7857, + "kind": "wrapper_method" + }, + { + "name": "agent:run_completed", + "file": "src-tauri/src/application/task_cleanup_service.rs", + "line": 463, + "kind": "method" + }, + { + "name": "agent:run_completed", + "file": "src-tauri/src/application/task_transition_service/tests.rs", + "line": 46, + "kind": "method" + }, + { + "name": "agent:run_completed", + "file": "src-tauri/src/application/throttled_emitter_tests.rs", + "line": 48, + "kind": "method" + }, + { + "name": "agent:run_completed", + "file": "src-tauri/src/commands/agent_workspace_auto_publish_tests.rs", + "line": 341, + "kind": "method" + }, + { + "name": "agent:run_completed", + "file": "src-tauri/src/commands/agent_workspace_auto_review_tests.rs", + "line": 274, + "kind": "method" + }, + { + "name": "agent:run_completed", + "file": "src-tauri/src/http_server/handlers/ideation/verification/lifecycle.rs", + "line": 183, + "kind": "wrapper" + }, + { + "name": "agent:run_started", + "file": "src-tauri/src/application/chat_service/chat_service_queue.rs", + "line": 1683, + "kind": "method" + }, + { + "name": "agent:run_started", + "file": "src-tauri/src/application/chat_service/mod.rs", + "line": 5759, + "kind": "wrapper_method" + }, + { + "name": "agent:run_started", + "file": "src-tauri/src/application/chat_service/mod.rs", + "line": 7071, + "kind": "wrapper_method" + }, + { + "name": "agent:session_recovered", + "file": "src-tauri/src/application/chat_service/chat_service_handlers.rs", + "line": 2638, + "kind": "method" + }, + { + "name": "agent:session_recovered", + "file": "src-tauri/src/application/chat_service/chat_service_send_background.rs", + "line": 1239, + "kind": "method" + }, + { + "name": "agent:session_recovered", + "file": "src-tauri/src/application/reconciliation/recovery_queue.rs", + "line": 335, + "kind": "method" + }, + { + "name": "agent:session_recovered", + "file": "src-tauri/src/application/startup_jobs.rs", + "line": 1545, + "kind": "method" + }, + { + "name": "agent:startup_progress", + "file": "src-tauri/src/application/agent_conversation_start_service/helpers/spawn_glue.rs", + "line": 104, + "kind": "method" + }, + { + "name": "agent:stopped", + "file": "src-tauri/src/application/chat_service/chat_service_handlers.rs", + "line": 2536, + "kind": "method" + }, + { + "name": "agent:stopped", + "file": "src-tauri/src/application/chat_service/mod.rs", + "line": 7802, + "kind": "wrapper_method" + }, + { + "name": "agent:stopped", + "file": "src-tauri/src/application/chat_service/mod.rs", + "line": 7848, + "kind": "wrapper_method" + }, + { + "name": "agent:stopped", + "file": "src-tauri/src/application/task_cleanup_service.rs", + "line": 454, + "kind": "method" + }, + { + "name": "agent:stopped", + "file": "src-tauri/src/http_server/handlers/ideation/verification/lifecycle.rs", + "line": 165, + "kind": "wrapper" + }, + { + "name": "agent:task_completed", + "file": "src-tauri/src/application/chat_service/chat_service_streaming.rs", + "line": 2506, + "kind": "method" + }, + { + "name": "agent:task_completed", + "file": "src-tauri/src/http_server/handlers/coordination/native_delegation.rs", + "line": 472, + "kind": "wrapper" + }, + { + "name": "agent:task_started", + "file": "src-tauri/src/application/chat_service/chat_service_streaming.rs", + "line": 2445, + "kind": "method" + }, + { + "name": "agent:task_started", + "file": "src-tauri/src/http_server/handlers/coordination/native_delegation.rs", + "line": 1221, + "kind": "wrapper" + }, + { + "name": "agent:tool_call", + "file": "src-tauri/src/application/chat_service/chat_service_streaming.rs", + "line": 1834, + "kind": "method" + }, + { + "name": "agent:tool_call", + "file": "src-tauri/src/application/chat_service/chat_service_streaming.rs", + "line": 1939, + "kind": "method" + }, + { + "name": "agent:tool_call", + "file": "src-tauri/src/application/chat_service/chat_service_streaming.rs", + "line": 2685, + "kind": "method" + }, + { + "name": "agent:tool_call", + "file": "src-tauri/src/application/chat_service/chat_service_streaming.rs", + "line": 3629, + "kind": "method" + }, + { + "name": "agent:turn_completed", + "file": "src-tauri/src/application/chat_service/chat_service_streaming.rs", + "line": 2336, + "kind": "method" + }, + { + "name": "agent:turn_completed", + "file": "src-tauri/src/commands/agent_workspace_auto_publish_tests.rs", + "line": 349, + "kind": "method" + }, + { + "name": "agent:turn_completed", + "file": "src-tauri/src/commands/agent_workspace_auto_review_tests.rs", + "line": 276, + "kind": "method" + }, + { + "name": "agent:usage_updated", + "file": "src-tauri/src/application/chat_service/chat_service_streaming.rs", + "line": 369, + "kind": "method" + }, + { + "name": "agent:workflow_progress", + "file": "src-tauri/src/http_server/handlers/agent_workflows.rs", + "line": 45, + "kind": "method" + }, + { + "name": "agent:workspace_changed", + "file": "src-tauri/src/application/agent_workspace_external_pr_reconciliation.rs", + "line": 640, + "kind": "method" + }, + { + "name": "agent:workspace_changed", + "file": "src-tauri/src/application/agent_workspace_pr_supervision_recovery.rs", + "line": 1016, + "kind": "method" + }, + { + "name": "agent:workspace_changed", + "file": "src-tauri/src/application/chat_service/mod.rs", + "line": 3978, + "kind": "wrapper_method" + }, + { + "name": "agent:workspace_changed", + "file": "src-tauri/src/commands/agent_workspace_auto_publish.rs", + "line": 550, + "kind": "method" + }, + { + "name": "agent:workspace_changed", + "file": "src-tauri/src/commands/agent_workspace_auto_review.rs", + "line": 206, + "kind": "method" + }, + { + "name": "agent:workspace_changed", + "file": "src-tauri/src/commands/question_commands.rs", + "line": 295, + "kind": "method" + }, + { + "name": "agent:workspace_changed", + "file": "src-tauri/src/commands/unified_chat_commands/mod.rs", + "line": 977, + "kind": "method" + }, + { + "name": "agent:workspace_changed", + "file": "src-tauri/src/commands/unified_chat_commands/mod.rs", + "line": 4157, + "kind": "method" + }, + { + "name": "agent:workspace_changed", + "file": "src-tauri/src/commands/unified_chat_commands/mod.rs", + "line": 6177, + "kind": "method" + }, + { + "name": "agent:workspace_changed", + "file": "src-tauri/src/commands/unified_chat_commands/mod.rs", + "line": 6255, + "kind": "method" + }, + { + "name": "agent_terminal:event", + "file": "src-tauri/src/application/agent_terminal.rs", + "line": 794, + "kind": "method" + }, + { + "name": "artifact:archived", + "file": "src-tauri/src/commands/artifact_commands.rs", + "line": 371, + "kind": "method" + }, + { + "name": "automation:deleted", + "file": "src-tauri/src/application/automation/transition.rs", + "line": 138, + "kind": "method" + }, + { + "name": "automation:run:updated", + "file": "src-tauri/src/application/automation/delete.rs", + "line": 172, + "kind": "method" + }, + { + "name": "automation:run:updated", + "file": "src-tauri/src/application/automation/reopen.rs", + "line": 232, + "kind": "method" + }, + { + "name": "automation:run:updated", + "file": "src-tauri/src/application/automation/transition.rs", + "line": 124, + "kind": "method" + }, + { + "name": "automation:updated", + "file": "src-tauri/src/application/automation/delete.rs", + "line": 176, + "kind": "method" + }, + { + "name": "automation:updated", + "file": "src-tauri/src/application/automation/reopen.rs", + "line": 241, + "kind": "method" + }, + { + "name": "automation:updated", + "file": "src-tauri/src/application/automation/transition.rs", + "line": 110, + "kind": "method" + }, + { + "name": "dependency:added", + "file": "src-tauri/src/http_server/helpers.rs", + "line": 192, + "kind": "wrapper" + }, + { + "name": "dependency:removed", + "file": "src-tauri/src/commands/ideation_commands/ideation_commands_dependencies.rs", + "line": 37, + "kind": "method" + }, + { + "name": "event", + "file": "src-tauri/src/domain/state_machine/mocks_tests.rs", + "line": 126, + "kind": "method" + }, + { + "name": "event", + "file": "src-tauri/src/tests/event_sink_coverage_tests.rs", + "line": 17, + "kind": "method" + }, + { + "name": "event", + "file": "src-tauri/src/tests/event_sink_coverage_tests.rs", + "line": 42, + "kind": "method" + }, + { + "name": "event", + "file": "src-tauri/src/tests/event_sink_coverage_tests.rs", + "line": 53, + "kind": "method" + }, + { + "name": "event1", + "file": "src-tauri/src/domain/state_machine/mocks_tests.rs", + "line": 108, + "kind": "method" + }, + { + "name": "event2", + "file": "src-tauri/src/domain/state_machine/mocks_tests.rs", + "line": 109, + "kind": "method" + }, + { + "name": "execution:active_project_changed", + "file": "src-tauri/src/commands/execution_commands/settings.rs", + "line": 237, + "kind": "method" + }, + { + "name": "execution:completed", + "file": "src-tauri/src/application/task_transition_service.rs", + "line": 1846, + "kind": "method" + }, + { + "name": "execution:completed", + "file": "src-tauri/src/application/task_transition_service.rs", + "line": 1848, + "kind": "method" + }, + { + "name": "execution:queue_changed", + "file": "src-tauri/src/commands/task_commands/helpers.rs", + "line": 55, + "kind": "method" + }, + { + "name": "execution:queue_changed", + "file": "src-tauri/src/http_server/handlers/ideation/proposals.rs", + "line": 288, + "kind": "wrapper" + }, + { + "name": "execution:spawn_blocked", + "file": "src-tauri/src/infrastructure/agents/spawner.rs", + "line": 800, + "kind": "method" + }, + { + "name": "execution:status_changed", + "file": "src-tauri/src/application/chat_service/chat_service_handlers.rs", + "line": 1290, + "kind": "method" + }, + { + "name": "execution:status_changed", + "file": "src-tauri/src/commands/execution_commands/lifecycle.rs", + "line": 115, + "kind": "method" + }, + { + "name": "execution:status_changed", + "file": "src-tauri/src/commands/execution_commands/lifecycle.rs", + "line": 300, + "kind": "method" + }, + { + "name": "execution:status_changed", + "file": "src-tauri/src/commands/execution_commands/lifecycle.rs", + "line": 559, + "kind": "method" + }, + { + "name": "execution:status_changed", + "file": "src-tauri/src/commands/execution_commands/settings.rs", + "line": 246, + "kind": "method" + }, + { + "name": "execution:status_changed", + "file": "src-tauri/src/commands/execution_commands/state.rs", + "line": 602, + "kind": "method" + }, + { + "name": "execution:status_changed", + "file": "src-tauri/src/commands/execution_commands/state.rs", + "line": 610, + "kind": "method" + }, + { + "name": "external-mcp:status", + "file": "src-tauri/src/infrastructure/external_mcp_supervisor.rs", + "line": 656, + "kind": "method" + }, + { + "name": "gh-auth:login_prompt", + "file": "src-tauri/src/commands/project_commands.rs", + "line": 994, + "kind": "method" + }, + { + "name": "git-auth:startup_preflight", + "file": "src-tauri/src/application/startup_git_auth_preflight.rs", + "line": 280, + "kind": "method" + }, + { + "name": "ideation:child_session_created", + "file": "src-tauri/src/http_server/handlers/session_linking/create.rs", + "line": 454, + "kind": "wrapper" + }, + { + "name": "ideation:finalize_pending_confirmation", + "file": "src-tauri/src/http_server/helpers.rs", + "line": 994, + "kind": "wrapper" + }, + { + "name": "ideation:plan_created", + "file": "src-tauri/src/http_server/handlers/artifacts/create.rs", + "line": 212, + "kind": "wrapper" + }, + { + "name": "ideation:session_accepted", + "file": "src-tauri/src/commands/ideation_commands/ideation_commands_restart.rs", + "line": 896, + "kind": "method" + }, + { + "name": "ideation:session_accepted", + "file": "src-tauri/src/http_server/handlers/ideation/proposals.rs", + "line": 249, + "kind": "wrapper" + }, + { + "name": "ideation:session_created", + "file": "src-tauri/src/commands/ideation_commands/ideation_commands_cross_project.rs", + "line": 239, + "kind": "method" + }, + { + "name": "ideation:session_created", + "file": "src-tauri/src/commands/ideation_commands/ideation_commands_export.rs", + "line": 58, + "kind": "method" + }, + { + "name": "ideation:session_created", + "file": "src-tauri/src/commands/ideation_commands/ideation_commands_session.rs", + "line": 113, + "kind": "method" + }, + { + "name": "ideation:session_created", + "file": "src-tauri/src/http_server/handlers/external/ideation_start/start.rs", + "line": 864, + "kind": "wrapper" + }, + { + "name": "ideation:session_imported", + "file": "src-tauri/src/commands/ideation_commands/ideation_commands_export.rs", + "line": 61, + "kind": "method" + }, + { + "name": "ideation:session_reopened", + "file": "src-tauri/src/commands/ideation_commands/ideation_commands_session.rs", + "line": 509, + "kind": "method" + }, + { + "name": "ideation:session_title_updated", + "file": "src-tauri/src/application/session_namer_agent.rs", + "line": 587, + "kind": "method" + }, + { + "name": "ideation:session_title_updated", + "file": "src-tauri/src/application/session_namer_agent.rs", + "line": 613, + "kind": "method" + }, + { + "name": "ideation:session_title_updated", + "file": "src-tauri/src/commands/ideation_commands/ideation_commands_session.rs", + "line": 556, + "kind": "method" + }, + { + "name": "ideation:session_title_updated", + "file": "src-tauri/src/http_server/handlers/ideation/proposals.rs", + "line": 488, + "kind": "wrapper" + }, + { + "name": "ideation:session_title_updated", + "file": "src-tauri/src/http_server/handlers/ideation/proposals.rs", + "line": 543, + "kind": "wrapper" + }, + { + "name": "issue:updated", + "file": "src-tauri/src/http_server/handlers/issues.rs", + "line": 113, + "kind": "wrapper" + }, + { + "name": "issue:updated", + "file": "src-tauri/src/http_server/handlers/issues.rs", + "line": 159, + "kind": "wrapper" + }, + { + "name": "merge:completed", + "file": "src-tauri/src/domain/state_machine/transition_handler/merge_completion.rs", + "line": 384, + "kind": "method" + }, + { + "name": "merge:completed", + "file": "src-tauri/src/http_server/handlers/git.rs", + "line": 499, + "kind": "wrapper" + }, + { + "name": "merge:conflict", + "file": "src-tauri/src/http_server/handlers/git.rs", + "line": 668, + "kind": "wrapper" + }, + { + "name": "merge:incomplete", + "file": "src-tauri/src/http_server/handlers/git.rs", + "line": 819, + "kind": "wrapper" + }, + { + "name": "merge:validation_start", + "file": "src-tauri/src/application/chat_service/chat_service_merge.rs", + "line": 802, + "kind": "method" + }, + { + "name": "merge:validation_start", + "file": "src-tauri/src/application/chat_service/chat_service_merge.rs", + "line": 809, + "kind": "method" + }, + { + "name": "merge:validation_step", + "file": "src-tauri/src/domain/state_machine/transition_handler/merge_validation/install.rs", + "line": 105, + "kind": "method" + }, + { + "name": "merge:validation_step", + "file": "src-tauri/src/domain/state_machine/transition_handler/merge_validation/install.rs", + "line": 216, + "kind": "method" + }, + { + "name": "merge:validation_step", + "file": "src-tauri/src/domain/state_machine/transition_handler/merge_validation/install.rs", + "line": 283, + "kind": "method" + }, + { + "name": "merge:validation_step", + "file": "src-tauri/src/domain/state_machine/transition_handler/merge_validation/setup.rs", + "line": 339, + "kind": "method" + }, + { + "name": "merge:validation_step", + "file": "src-tauri/src/domain/state_machine/transition_handler/merge_validation/setup.rs", + "line": 384, + "kind": "method" + }, + { + "name": "merge:validation_step", + "file": "src-tauri/src/domain/state_machine/transition_handler/merge_validation/setup.rs", + "line": 415, + "kind": "method" + }, + { + "name": "merge:validation_step", + "file": "src-tauri/src/domain/state_machine/transition_handler/merge_validation/setup.rs", + "line": 531, + "kind": "method" + }, + { + "name": "merge:validation_step", + "file": "src-tauri/src/domain/state_machine/transition_handler/merge_validation/validate.rs", + "line": 54, + "kind": "method" + }, + { + "name": "merge:validation_step", + "file": "src-tauri/src/domain/state_machine/transition_handler/merge_validation/validate.rs", + "line": 169, + "kind": "method" + }, + { + "name": "merge:validation_step", + "file": "src-tauri/src/domain/state_machine/transition_handler/merge_validation/validate.rs", + "line": 200, + "kind": "method" + }, + { + "name": "merge:validation_step", + "file": "src-tauri/src/domain/state_machine/transition_handler/merge_validation/validate.rs", + "line": 489, + "kind": "method" + }, + { + "name": "notification:created", + "file": "src-tauri/src/application/notification_service.rs", + "line": 311, + "kind": "method" + }, + { + "name": "notification:created", + "file": "src-tauri/src/remote_server/capture_tests.rs", + "line": 58, + "kind": "method" + }, + { + "name": "notification:created", + "file": "src-tauri/src/remote_server/capture_tests.rs", + "line": 82, + "kind": "method" + }, + { + "name": "notification:created", + "file": "src-tauri/src/remote_server/capture_tests.rs", + "line": 84, + "kind": "method" + }, + { + "name": "notification:created", + "file": "src-tauri/src/remote_server/capture_tests.rs", + "line": 85, + "kind": "method" + }, + { + "name": "notification:desktop_activated", + "file": "src-tauri/src/application/desktop_notification.rs", + "line": 37, + "kind": "method" + }, + { + "name": "notification:updated", + "file": "src-tauri/src/application/notification_service.rs", + "line": 316, + "kind": "method" + }, + { + "name": "permission:expired", + "file": "src-tauri/src/http_server/handlers/permissions.rs", + "line": 119, + "kind": "wrapper" + }, + { + "name": "permission:request", + "file": "src-tauri/src/http_server/handlers/permissions.rs", + "line": 75, + "kind": "wrapper" + }, + { + "name": "permission:resolved", + "file": "src-tauri/src/commands/permission_commands.rs", + "line": 58, + "kind": "method" + }, + { + "name": "permission:resolved", + "file": "src-tauri/src/http_server/handlers/permissions.rs", + "line": 211, + "kind": "wrapper" + }, + { + "name": "persona:applied", + "file": "src-tauri/src/application/chat_service/mod.rs", + "line": 771, + "kind": "method" + }, + { + "name": "persona:draft_applied", + "file": "src-tauri/src/commands/persona_commands.rs", + "line": 251, + "kind": "method" + }, + { + "name": "persona:draft_updated", + "file": "src-tauri/src/commands/persona_commands.rs", + "line": 454, + "kind": "method" + }, + { + "name": "persona:draft_updated", + "file": "src-tauri/src/http_server/handlers/personas.rs", + "line": 97, + "kind": "method" + }, + { + "name": "persona:injection_skipped", + "file": "src-tauri/src/application/chat_service/mod.rs", + "line": 771, + "kind": "method" + }, + { + "name": "plan:merge_complete", + "file": "src-tauri/src/domain/state_machine/transition_handler/side_effects/transitions.rs", + "line": 219, + "kind": "method" + }, + { + "name": "plan:proposals_may_need_update", + "file": "src-tauri/src/http_server/handlers/artifacts/events.rs", + "line": 55, + "kind": "wrapper" + }, + { + "name": "plan_artifact:approved", + "file": "src-tauri/src/http_server/handlers/artifacts/approval.rs", + "line": 73, + "kind": "wrapper" + }, + { + "name": "plan_artifact:created", + "file": "src-tauri/src/http_server/handlers/artifacts/create.rs", + "line": 164, + "kind": "wrapper" + }, + { + "name": "plan_artifact:updated", + "file": "src-tauri/src/http_server/handlers/artifacts/events.rs", + "line": 30, + "kind": "wrapper" + }, + { + "name": "plan_complexity:assessed", + "file": "src-tauri/src/application/plan_complexity_assessment.rs", + "line": 372, + "kind": "method" + }, + { + "name": "plan_verification:status_changed", + "file": "src-tauri/src/application/verification_event_emitters.rs", + "line": 51, + "kind": "method" + }, + { + "name": "pr_review_artifact:created", + "file": "src-tauri/src/http_server/handlers/agent_workspaces/pr_review/proposal.rs", + "line": 127, + "kind": "wrapper" + }, + { + "name": "pr_review_artifact:updated", + "file": "src-tauri/src/http_server/handlers/agent_workspaces/pr_review/proposal.rs", + "line": 127, + "kind": "wrapper" + }, + { + "name": "project:analysis_complete", + "file": "src-tauri/src/http_server/handlers/projects.rs", + "line": 240, + "kind": "wrapper" + }, + { + "name": "project:analysis_failed", + "file": "src-tauri/src/commands/project_commands.rs", + "line": 647, + "kind": "method" + }, + { + "name": "project:archived", + "file": "src-tauri/src/commands/project_commands.rs", + "line": 387, + "kind": "method" + }, + { + "name": "project:created", + "file": "src-tauri/src/commands/ideation_commands/ideation_commands_cross_project.rs", + "line": 91, + "kind": "method" + }, + { + "name": "proposal:archived", + "file": "src-tauri/src/http_server/helpers.rs", + "line": 860, + "kind": "wrapper" + }, + { + "name": "proposal:created", + "file": "src-tauri/src/http_server/helpers.rs", + "line": 367, + "kind": "wrapper" + }, + { + "name": "proposal:priority_assessed", + "file": "src-tauri/src/commands/ideation_commands/ideation_commands_proposals.rs", + "line": 359, + "kind": "method" + }, + { + "name": "proposal:updated", + "file": "src-tauri/src/commands/ideation_commands/ideation_commands_proposals.rs", + "line": 217, + "kind": "method" + }, + { + "name": "proposal:updated", + "file": "src-tauri/src/commands/ideation_commands/ideation_commands_proposals.rs", + "line": 263, + "kind": "method" + }, + { + "name": "proposal:updated", + "file": "src-tauri/src/http_server/helpers.rs", + "line": 610, + "kind": "wrapper" + }, + { + "name": "proposals:reordered", + "file": "src-tauri/src/commands/ideation_commands/ideation_commands_proposals.rs", + "line": 312, + "kind": "method" + }, + { + "name": "qa_failed", + "file": "src-tauri/src/domain/state_machine/transition_handler/on_enter_states/qa.rs", + "line": 73, + "kind": "method" + }, + { + "name": "qa_passed", + "file": "src-tauri/src/domain/state_machine/transition_handler/on_enter_states/qa.rs", + "line": 64, + "kind": "method" + }, + { + "name": "ralphx://check-for-updates", + "file": "src-tauri/src/application/native_menu.rs", + "line": 20, + "kind": "method" + }, + { + "name": "ralphx://show-release-notes", + "file": "src-tauri/src/application/native_menu.rs", + "line": 20, + "kind": "method" + }, + { + "name": "recovery:prompt", + "file": "src-tauri/src/application/reconciliation/events.rs", + "line": 399, + "kind": "method" + }, + { + "name": "review:action_failed", + "file": "src-tauri/src/commands/review_commands.rs", + "line": 695, + "kind": "method" + }, + { + "name": "review:ai_approved", + "file": "src-tauri/src/domain/state_machine/transition_handler/on_enter_states/review.rs", + "line": 393, + "kind": "method" + }, + { + "name": "review:completed", + "file": "src-tauri/src/http_server/handlers/reviews/complete.rs", + "line": 566, + "kind": "wrapper" + }, + { + "name": "review:escalated", + "file": "src-tauri/src/domain/state_machine/transition_handler/on_enter_states/review.rs", + "line": 428, + "kind": "method" + }, + { + "name": "review:human_approved", + "file": "src-tauri/src/commands/review_commands.rs", + "line": 453, + "kind": "method" + }, + { + "name": "review:human_approved", + "file": "src-tauri/src/http_server/handlers/reviews/human.rs", + "line": 94, + "kind": "wrapper" + }, + { + "name": "review:human_changes_requested", + "file": "src-tauri/src/commands/review_commands.rs", + "line": 516, + "kind": "method" + }, + { + "name": "review:human_changes_requested", + "file": "src-tauri/src/commands/review_commands.rs", + "line": 742, + "kind": "method" + }, + { + "name": "review:human_changes_requested", + "file": "src-tauri/src/http_server/handlers/reviews/human.rs", + "line": 180, + "kind": "wrapper" + }, + { + "name": "review:re_review_requested", + "file": "src-tauri/src/commands/review_commands.rs", + "line": 591, + "kind": "method" + }, + { + "name": "review:state_exited", + "file": "src-tauri/src/domain/state_machine/transition_handler/mod.rs", + "line": 429, + "kind": "method" + }, + { + "name": "review:update", + "file": "src-tauri/src/domain/state_machine/transition_handler/on_enter_states/review.rs", + "line": 21, + "kind": "wrapper_method" + }, + { + "name": "review:update", + "file": "src-tauri/src/domain/state_machine/transition_handler/on_enter_states/review.rs", + "line": 33, + "kind": "wrapper_method" + }, + { + "name": "session:priorities_assessed", + "file": "src-tauri/src/commands/ideation_commands/ideation_commands_proposals.rs", + "line": 422, + "kind": "method" + }, + { + "name": "settings:execution:updated", + "file": "src-tauri/src/commands/execution_commands/settings.rs", + "line": 155, + "kind": "method" + }, + { + "name": "settings:global_execution:updated", + "file": "src-tauri/src/commands/execution_commands/settings.rs", + "line": 363, + "kind": "method" + }, + { + "name": "step:created", + "file": "src-tauri/src/http_server/handlers/steps.rs", + "line": 417, + "kind": "wrapper" + }, + { + "name": "step:updated", + "file": "src-tauri/src/commands/task_step_commands.rs", + "line": 19, + "kind": "method" + }, + { + "name": "step:updated", + "file": "src-tauri/src/domain/state_machine/transition_handler/on_enter_states/execution.rs", + "line": 284, + "kind": "method" + }, + { + "name": "step:updated", + "file": "src-tauri/src/domain/state_machine/transition_handler/on_enter_states/execution.rs", + "line": 304, + "kind": "method" + }, + { + "name": "step:updated", + "file": "src-tauri/src/domain/state_machine/transition_handler/on_enter_states/outcomes.rs", + "line": 146, + "kind": "method" + }, + { + "name": "step:updated", + "file": "src-tauri/src/http_server/handlers/steps.rs", + "line": 97, + "kind": "wrapper" + }, + { + "name": "step:updated", + "file": "src-tauri/src/http_server/handlers/steps.rs", + "line": 150, + "kind": "wrapper" + }, + { + "name": "step:updated", + "file": "src-tauri/src/http_server/handlers/steps.rs", + "line": 251, + "kind": "wrapper" + }, + { + "name": "step:updated", + "file": "src-tauri/src/http_server/handlers/steps.rs", + "line": 352, + "kind": "wrapper" + }, + { + "name": "task:archived", + "file": "src-tauri/src/application/task_cleanup_service.rs", + "line": 814, + "kind": "method" + }, + { + "name": "task:archived", + "file": "src-tauri/src/commands/task_commands/mutation.rs", + "line": 630, + "kind": "wrapper" + }, + { + "name": "task:archived", + "file": "src-tauri/src/commands/task_commands/mutation.rs", + "line": 913, + "kind": "wrapper" + }, + { + "name": "task:archived", + "file": "src-tauri/src/commands/task_commands/mutation.rs", + "line": 2244, + "kind": "wrapper" + }, + { + "name": "task:cancelled", + "file": "src-tauri/src/commands/task_commands/mutation.rs", + "line": 1526, + "kind": "wrapper" + }, + { + "name": "task:created", + "file": "src-tauri/src/application/task_transition_service/tests.rs", + "line": 80, + "kind": "method" + }, + { + "name": "task:created", + "file": "src-tauri/src/commands/task_commands/mutation.rs", + "line": 510, + "kind": "method" + }, + { + "name": "task:created", + "file": "src-tauri/src/commands/task_commands/mutation.rs", + "line": 512, + "kind": "method" + }, + { + "name": "task:custom", + "file": "src-tauri/src/application/task_transition_service/tests.rs", + "line": 61, + "kind": "wrapper_method" + }, + { + "name": "task:event", + "file": "src-tauri/src/application/startup_jobs.rs", + "line": 2040, + "kind": "method" + }, + { + "name": "task:event", + "file": "src-tauri/src/application/task_transition_service.rs", + "line": 1148, + "kind": "method" + }, + { + "name": "task:event", + "file": "src-tauri/src/application/task_transition_service.rs", + "line": 1150, + "kind": "method" + }, + { + "name": "task:list_changed", + "file": "src-tauri/src/commands/ideation_commands/ideation_commands_restart.rs", + "line": 903, + "kind": "method" + }, + { + "name": "task:list_changed", + "file": "src-tauri/src/commands/ideation_commands/ideation_commands_session.rs", + "line": 516, + "kind": "method" + }, + { + "name": "task:list_changed", + "file": "src-tauri/src/commands/task_commands/execution_plan_controls.rs", + "line": 39, + "kind": "method" + }, + { + "name": "task:list_changed", + "file": "src-tauri/src/commands/task_commands/mutation.rs", + "line": 976, + "kind": "method" + }, + { + "name": "task:list_changed", + "file": "src-tauri/src/commands/task_commands/mutation.rs", + "line": 1546, + "kind": "method" + }, + { + "name": "task:list_changed", + "file": "src-tauri/src/commands/task_commands/mutation.rs", + "line": 2029, + "kind": "method" + }, + { + "name": "task:list_changed", + "file": "src-tauri/src/commands/task_commands/mutation.rs", + "line": 2175, + "kind": "method" + }, + { + "name": "task:list_changed", + "file": "src-tauri/src/commands/task_commands/mutation.rs", + "line": 2262, + "kind": "method" + }, + { + "name": "task:merge_phases", + "file": "src-tauri/src/domain/state_machine/transition_handler/merge_validation/mod.rs", + "line": 417, + "kind": "method" + }, + { + "name": "task:merge_phases", + "file": "src-tauri/src/domain/state_machine/transition_handler/side_effects/merge_attempt/mod.rs", + "line": 80, + "kind": "method" + }, + { + "name": "task:merge_progress", + "file": "src-tauri/src/domain/state_machine/transition_handler/merge_validation/logging.rs", + "line": 94, + "kind": "wrapper" + }, + { + "name": "task:merged", + "file": "src-tauri/src/domain/state_machine/transition_handler/merge_completion.rs", + "line": 369, + "kind": "method" + }, + { + "name": "task:merged", + "file": "src-tauri/src/http_server/handlers/reviews/complete.rs", + "line": 586, + "kind": "wrapper" + }, + { + "name": "task:on_enter_error", + "file": "src-tauri/src/domain/state_machine/transition_handler/mod.rs", + "line": 331, + "kind": "wrapper_method" + }, + { + "name": "task:paused", + "file": "src-tauri/src/application/tasks_feature_toggle_service.rs", + "line": 392, + "kind": "method" + }, + { + "name": "task:paused", + "file": "src-tauri/src/commands/task_commands/mutation.rs", + "line": 1296, + "kind": "wrapper" + }, + { + "name": "task:paused", + "file": "src-tauri/src/commands/task_commands/mutation.rs", + "line": 1329, + "kind": "wrapper" + }, + { + "name": "task:paused", + "file": "src-tauri/src/commands/task_commands/mutation.rs", + "line": 2011, + "kind": "wrapper" + }, + { + "name": "task:provider_error_paused", + "file": "src-tauri/src/application/chat_service/chat_service_handlers.rs", + "line": 3396, + "kind": "method" + }, + { + "name": "task:provider_error_resuming", + "file": "src-tauri/src/application/reconciliation/handlers/execution.rs", + "line": 1928, + "kind": "method" + }, + { + "name": "task:reconciliation_action", + "file": "src-tauri/src/tests/hardening/error_visibility_tests.rs", + "line": 431, + "kind": "method" + }, + { + "name": "task:reconciliation_action", + "file": "src-tauri/src/tests/hardening/error_visibility_tests.rs", + "line": 434, + "kind": "wrapper_method" + }, + { + "name": "task:reconciliation_action", + "file": "src-tauri/src/tests/hardening/error_visibility_tests.rs", + "line": 467, + "kind": "method" + }, + { + "name": "task:recovery_failed", + "file": "src-tauri/src/application/chat_service/chat_service_handlers.rs", + "line": 3516, + "kind": "method" + }, + { + "name": "task:restarted", + "file": "src-tauri/src/commands/execution_commands.rs", + "line": 389, + "kind": "method" + }, + { + "name": "task:restored", + "file": "src-tauri/src/commands/task_commands/mutation.rs", + "line": 676, + "kind": "wrapper" + }, + { + "name": "task:resumed", + "file": "src-tauri/src/commands/task_commands/mutation.rs", + "line": 1689, + "kind": "wrapper" + }, + { + "name": "task:resumed", + "file": "src-tauri/src/commands/task_commands/mutation.rs", + "line": 1761, + "kind": "wrapper" + }, + { + "name": "task:resumed", + "file": "src-tauri/src/commands/task_commands/mutation.rs", + "line": 2165, + "kind": "wrapper" + }, + { + "name": "task:status_changed", + "file": "src-tauri/src/application/task_transition_service.rs", + "line": 436, + "kind": "method" + }, + { + "name": "task:status_changed", + "file": "src-tauri/src/application/task_transition_service.rs", + "line": 438, + "kind": "method" + }, + { + "name": "task:status_changed", + "file": "src-tauri/src/commands/review_commands.rs", + "line": 459, + "kind": "method" + }, + { + "name": "task:status_changed", + "file": "src-tauri/src/commands/review_commands.rs", + "line": 523, + "kind": "method" + }, + { + "name": "task:status_changed", + "file": "src-tauri/src/commands/review_commands.rs", + "line": 597, + "kind": "method" + }, + { + "name": "task:status_changed", + "file": "src-tauri/src/commands/review_commands.rs", + "line": 749, + "kind": "method" + }, + { + "name": "task:status_changed", + "file": "src-tauri/src/domain/state_machine/services.rs", + "line": 116, + "kind": "method" + }, + { + "name": "task:status_changed", + "file": "src-tauri/src/domain/state_machine/transition_handler/merge_completion.rs", + "line": 376, + "kind": "method" + }, + { + "name": "task:status_changed", + "file": "src-tauri/src/http_server/handlers/git.rs", + "line": 507, + "kind": "wrapper" + }, + { + "name": "task:status_changed", + "file": "src-tauri/src/http_server/handlers/git.rs", + "line": 677, + "kind": "wrapper" + }, + { + "name": "task:status_changed", + "file": "src-tauri/src/http_server/handlers/git.rs", + "line": 828, + "kind": "wrapper" + }, + { + "name": "task:status_changed", + "file": "src-tauri/src/http_server/handlers/reviews/complete.rs", + "line": 575, + "kind": "wrapper" + }, + { + "name": "task:status_changed", + "file": "src-tauri/src/http_server/handlers/reviews/human.rs", + "line": 101, + "kind": "wrapper" + }, + { + "name": "task:status_changed", + "file": "src-tauri/src/http_server/handlers/reviews/human.rs", + "line": 188, + "kind": "wrapper" + }, + { + "name": "task:stopped", + "file": "src-tauri/src/commands/task_commands/mutation.rs", + "line": 1413, + "kind": "method" + }, + { + "name": "task:stopped", + "file": "src-tauri/src/commands/task_commands/mutation.rs", + "line": 1439, + "kind": "method" + }, + { + "name": "task:unblocked", + "file": "src-tauri/src/application/startup_jobs.rs", + "line": 1829, + "kind": "method" + }, + { + "name": "task:unblocked", + "file": "src-tauri/src/application/task_transition_service.rs", + "line": 613, + "kind": "method" + }, + { + "name": "task_blocked", + "file": "src-tauri/src/domain/state_machine/mocks_tests.rs", + "line": 95, + "kind": "wrapper_method" + }, + { + "name": "task_completed", + "file": "src-tauri/src/domain/state_machine/transition_handler/on_enter_states/outcomes.rs", + "line": 6, + "kind": "method" + }, + { + "name": "task_failed", + "file": "src-tauri/src/domain/state_machine/transition_handler/on_enter_states/outcomes.rs", + "line": 170, + "kind": "method" + }, + { + "name": "task_started", + "file": "src-tauri/src/domain/state_machine/mocks_tests.rs", + "line": 84, + "kind": "method" + }, + { + "name": "task_started", + "file": "src-tauri/src/domain/state_machine/mocks_tests.rs", + "line": 117, + "kind": "method" + }, + { + "name": "task_validation:event", + "file": "src-tauri/src/application/validation_events.rs", + "line": 342, + "kind": "method" + }, + { + "name": "team:artifact_created", + "file": "src-tauri/src/http_server/handlers/artifacts/team_artifacts.rs", + "line": 311, + "kind": "wrapper" + }, + { + "name": "ticketing:cache_invalidated", + "file": "src-tauri/src/application/agent_conversation_start_service/finish_flow.rs", + "line": 449, + "kind": "method" + }, + { + "name": "ticketing:cache_invalidated", + "file": "src-tauri/src/application/agent_workspace_external_pr_reconciliation.rs", + "line": 482, + "kind": "method" + }, + { + "name": "ticketing:cache_invalidated", + "file": "src-tauri/src/application/ticketing_cache_invalidator.rs", + "line": 37, + "kind": "method" + }, + { + "name": "ticketing:operation_updated", + "file": "src-tauri/src/application/ticketing_service.rs", + "line": 151, + "kind": "method" + }, + { + "name": "verification:pending_confirmation", + "file": "src-tauri/src/application/verification_event_emitters.rs", + "line": 80, + "kind": "method" + }, + { + "name": "workspace_review_artifact:created", + "file": "src-tauri/src/http_server/handlers/agent_workspaces/mod.rs", + "line": 1931, + "kind": "wrapper" + }, + { + "name": "workspace_review_artifact:updated", + "file": "src-tauri/src/http_server/handlers/agent_workspaces/mod.rs", + "line": 1931, + "kind": "wrapper" + }, + { + "name": "workspace_review_artifact:updated", + "file": "src-tauri/src/http_server/handlers/agent_workspaces/mod.rs", + "line": 2147, + "kind": "wrapper" + } + ], + "consumed": [ + "agent:ask_user_question", + "agent:chunk", + "agent:conversation_created", + "agent:conversation_forked", + "agent:conversation_title_updated", + "agent:error", + "agent:heartbeat", + "agent:hook", + "agent:message", + "agent:message_created", + "agent:message_queued", + "agent:question_resolved", + "agent:queue_sent", + "agent:run_completed", + "agent:run_started", + "agent:session_recovered", + "agent:startup_progress", + "agent:stopped", + "agent:task_completed", + "agent:task_started", + "agent:tool_call", + "agent:turn_completed", + "agent:usage_updated", + "agent:workflow_progress", + "agent:workspace_changed", + "automation:deleted", + "automation:run:updated", + "automation:updated", + "dependency:added", + "dependency:removed", + "execution:error", + "execution:queue_changed", + "execution:status_changed", + "execution:stderr", + "file:change", + "ideation:child_session_created", + "ideation:finalize_pending_confirmation", + "ideation:session_accepted", + "ideation:session_created", + "ideation:session_title_updated", + "merge:validation_start", + "merge:validation_step", + "notification:created", + "notification:desktop_activated", + "notification:updated", + "permission:expired", + "permission:request", + "permission:resolved", + "persona:applied", + "persona:draft_updated", + "persona:injection_skipped", + "plan:merge_complete", + "plan_artifact:approved", + "plan_artifact:created", + "plan_artifact:updated", + "plan_verification:status_changed", + "pr_review_artifact:created", + "pr_review_artifact:updated", + "project:analysis_complete", + "project:analysis_failed", + "proposal:created", + "proposal:deleted", + "proposal:priority_assessed", + "proposal:updated", + "proposals:reordered", + "qa:prep", + "qa:test", + "recovery:prompt", + "review:update", + "session:priorities_assessed", + "step:created", + "step:deleted", + "step:status_changed", + "step:updated", + "steps:reordered", + "supervisor:alert", + "supervisor:event", + "task:archived", + "task:event", + "task:merge_phases", + "task:merge_progress", + "task:provider_error_paused", + "task:restored", + "task:status_changed", + "task:updated", + "task_validation:event", + "ticketing:cache_invalidated", + "workspace_review_artifact:created", + "workspace_review_artifact:updated" + ], + "classified": [ + "task:created", + "task:status_changed", + "task:merge_progress", + "task:merge_phases", + "task:archived", + "task:restored", + "notification:created", + "notification:updated", + "agent:run_started", + "agent:run_completed", + "agent:turn_completed", + "agent:message_created", + "agent:task_started", + "agent:task_completed", + "agent:error", + "agent:queue_sent", + "agent:message_queued", + "agent:session_recovered", + "automation:updated", + "automation:deleted", + "ticketing:cache_invalidated", + "task_validation:event", + "proposal:created", + "step:created", + "plan_artifact:created", + "plan_artifact:approved", + "execution:status_changed", + "agent:conversation_created", + "agent:conversation_forked", + "agent:conversation_title_updated", + "agent:question_resolved", + "agent:stopped", + "agent:workspace_changed", + "automation:run:updated", + "dependency:added", + "dependency:removed", + "execution:error", + "execution:queue_changed", + "file:change", + "ideation:child_session_created", + "ideation:finalize_pending_confirmation", + "ideation:session_accepted", + "ideation:session_created", + "ideation:session_title_updated", + "merge:validation_start", + "merge:validation_step", + "notification:desktop_activated", + "persona:applied", + "persona:draft_updated", + "persona:injection_skipped", + "plan:merge_complete", + "plan_artifact:updated", + "plan_verification:status_changed", + "pr_review_artifact:created", + "pr_review_artifact:updated", + "project:analysis_complete", + "project:analysis_failed", + "proposal:deleted", + "proposal:priority_assessed", + "proposal:updated", + "proposals:reordered", + "qa:prep", + "qa:test", + "recovery:prompt", + "review:update", + "session:priorities_assessed", + "step:deleted", + "step:status_changed", + "step:updated", + "steps:reordered", + "supervisor:alert", + "supervisor:event", + "task:event", + "task:provider_error_paused", + "workspace_review_artifact:created", + "workspace_review_artifact:updated", + "agent:chunk", + "agent:usage_updated", + "agent:tool_call", + "agent:message", + "agent:hook", + "agent:ask_user_question", + "agent:heartbeat", + "agent:startup_progress", + "agent:workflow_progress", + "execution:stderr", + "permission:expired", + "permission:request", + "permission:resolved", + "agent_terminal:event", + "task:updated", + "my:event", + "window:focus", + "dock:updated", + "updater:status" + ], + "false_positive_allowlist": [ + { + "kind": "function", + "target": "RecordingEventSink::emit_ticketing_operation_event", + "reason": "test double records a typed TicketingOperationEvent payload and does not emit to Tauri" + }, + { + "kind": "function", + "target": "TauriTicketingEventSink::emit_ticketing_operation_event", + "reason": "the event argument is a TicketingOperationEvent payload; the inner AppHandle emit uses TICKETING_OPERATION_EVENT" + }, + { + "kind": "receiver", + "target": "src-tauri/src/application/automation/provisioning.rs::self.event_emitter", + "reason": "typed AutomationEvent bus; the Tauri adapter emits a fixed classified name" + }, + { + "kind": "receiver", + "target": "src-tauri/src/application/automation/service.rs::self.event_emitter", + "reason": "typed AutomationEvent bus; the Tauri adapter emits a fixed classified name" + }, + { + "kind": "receiver", + "target": "src-tauri/src/application/automation/transition.rs::self.event_emitter", + "reason": "typed AutomationEvent bus; the Tauri adapter emits a fixed classified name" + } + ], + "static_event_functions": [ + { + "function": "menu_event_name_for_id", + "names": [ + "ralphx://check-for-updates", + "ralphx://show-release-notes" + ], + "reason": "native-menu ID mapping returns one of two local chrome event constants" + } + ], + "unmatched_classified_events": [ + { + "name": "execution:error", + "reason_code": "no-tauri-emitter", + "reason": "execution error state is surfaced through query invalidation, not a Tauri emit" + }, + { + "name": "file:change", + "reason_code": "no-tauri-emitter", + "reason": "file changes are consumed from watcher state without a Tauri emit" + }, + { + "name": "proposal:deleted", + "reason_code": "no-tauri-emitter", + "reason": "proposal deletion has no current Tauri event producer" + }, + { + "name": "qa:prep", + "reason_code": "no-tauri-emitter", + "reason": "QA preparation state has no current Tauri event producer" + }, + { + "name": "qa:test", + "reason_code": "no-tauri-emitter", + "reason": "QA test state has no current Tauri event producer" + }, + { + "name": "step:deleted", + "reason_code": "no-tauri-emitter", + "reason": "step deletion has no current Tauri event producer" + }, + { + "name": "step:status_changed", + "reason_code": "no-tauri-emitter", + "reason": "step status changes have no current Tauri event producer" + }, + { + "name": "steps:reordered", + "reason_code": "no-tauri-emitter", + "reason": "step reordering has no current Tauri event producer" + }, + { + "name": "supervisor:alert", + "reason_code": "no-tauri-emitter", + "reason": "supervisor alerts have no current Tauri event producer" + }, + { + "name": "supervisor:event", + "reason_code": "no-tauri-emitter", + "reason": "supervisor events have no current Tauri event producer" + }, + { + "name": "execution:stderr", + "reason_code": "no-tauri-emitter", + "reason": "execution stderr is consumed from process state without a Tauri emit" + } + ] +} From 6f55d4e6db5f3f2e420ba337a171ed86ae085d05 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 17:56:37 +0300 Subject: [PATCH 006/416] fix: harden event manifest census --- scripts/event-manifest-scanner/src/lib.rs | 258 ++++++++++++------ .../event-manifest-scanner/tests/scanner.rs | 69 ++++- scripts/event-manifest.json | 132 --------- 3 files changed, 246 insertions(+), 213 deletions(-) diff --git a/scripts/event-manifest-scanner/src/lib.rs b/scripts/event-manifest-scanner/src/lib.rs index cb7bd2d9c8..3834811b1b 100644 --- a/scripts/event-manifest-scanner/src/lib.rs +++ b/scripts/event-manifest-scanner/src/lib.rs @@ -197,8 +197,9 @@ pub fn scan_rust_source(file: impl Into, source: &str) -> Result, source: &str) -> Result Result { let rust_root = root.join("src-tauri/src"); - let constants = collect_rust_constants(&rust_root)?; - let mut emitted = Vec::new(); - for path in files_with_extension(&rust_root, "rs")? { - let source = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; - let syntax = syn::parse_file(&source).with_context(|| format!("parse {}", path.display()))?; - let functions = collect_functions(&syntax); - let file = relative(root, &path); - verify_wrapper_contract(&file, &syntax, &constants, &functions).map_err(anyhow::Error::new)?; - let mut visitor = EmitVisitor { - file, - constants: &constants, - current_function: None, - current_locals: BTreeMap::new(), - sites: Vec::new(), - error: None, - }; - visitor.visit_file(&syntax); - if let Some(error) = visitor.error { - return Err(error.into()); - } - emitted.extend(visitor.sites); - } + let emitted = scan_production_rust_tree(&rust_root)? + .into_iter() + .map(|mut site| { + site.file = relative(root, &rust_root.join(&site.file)); + site + }) + .collect::>(); + let mut emitted = emitted; emitted.sort(); emitted.dedup(); @@ -255,6 +242,41 @@ pub fn build_manifest(root: &Path) -> Result { }) } +pub fn scan_production_rust_tree(root: &Path) -> Result> { + let mut source_files = Vec::new(); + let mut constants = ConstantTable::default(); + let mut functions = Vec::new(); + let mut calls = Vec::new(); + for path in production_rust_files(root)? { + let file = relative(root, &path); + let source = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + let syntax = syn::parse_file(&source).with_context(|| format!("parse {}", path.display()))?; + constants.extend(collect_constants_from_file(&syntax)); + functions.extend(collect_functions(&syntax, &file)); + calls.extend(collect_call_sites(&syntax, &file)); + source_files.push((file, syntax)); + } + verify_wrapper_contract(&constants, &functions, &calls).map_err(anyhow::Error::new)?; + + let mut emitted = Vec::new(); + for (file, syntax) in source_files { + let mut visitor = EmitVisitor { + file, + constants: &constants, + current_function: None, + current_locals: BTreeMap::new(), + sites: Vec::new(), + error: None, + }; + visitor.visit_file(&syntax); + if let Some(error) = visitor.error { + return Err(error.into()); + } + emitted.extend(visitor.sites); + } + Ok(emitted) +} + fn verify_manifest( emitted: &[EmitSite], consumed: &[String], @@ -329,60 +351,99 @@ pub fn verify_unmatched_event_coverage(missing: &[&str]) -> Result<()> { Ok(()) } -fn collect_rust_constants(root: &Path) -> Result> { - let mut values = BTreeMap::new(); - for path in files_with_extension(root, "rs")? { - let source = fs::read_to_string(&path)?; - let syntax = syn::parse_file(&source)?; - merge_constants(&mut values, collect_constants_from_file(&syntax)); - } - Ok(values) +#[derive(Debug, Clone, Default)] +struct ConstantTable { + bindings: Vec, } -fn collect_constants_from_file(file: &File) -> BTreeMap { - struct Constants(BTreeMap); - impl<'ast> Visit<'ast> for Constants { - fn visit_item_const(&mut self, node: &'ast ItemConst) { - if let Some(value) = literal(&node.expr) { - self.0.insert(node.ident.to_string(), value); - } - visit::visit_item_const(self, node); +#[derive(Debug, Clone)] +struct ConstantBinding { + qualified_name: String, + value: String, +} + +impl ConstantTable { + fn extend(&mut self, other: Self) { + self.bindings.extend(other.bindings); + } + + fn resolve_path(&self, path: &syn::Path) -> Option { + let requested = path + .segments + .iter() + .map(|segment| segment.ident.to_string()) + .filter(|segment| !matches!(segment.as_str(), "crate" | "self" | "super")) + .collect::>() + .join("::"); + if requested.is_empty() { + return None; } + let suffix = format!("::{requested}"); + let matches = self + .bindings + .iter() + .filter(|binding| { + binding.qualified_name == requested + || binding.qualified_name.ends_with(&suffix) + || requested.ends_with(&format!("::{}", binding.qualified_name)) + }) + .collect::>(); + (matches.len() == 1).then(|| matches[0].value.clone()) } - let mut visitor = Constants(BTreeMap::new()); - visitor.visit_file(file); - visitor.0 } -fn merge_constants(into: &mut BTreeMap, next: BTreeMap) { - for (name, value) in next { - if let Some(previous) = into.get(&name) { - if previous != &value { - into.remove(&name); +fn collect_constants_from_file(file: &File) -> ConstantTable { + fn collect_items(items: &[Item], prefix: &str, table: &mut ConstantTable) { + for item in items { + match item { + Item::Const(ItemConst { ident, expr, .. }) => { + if let Some(value) = literal(expr) { + let qualified_name = if prefix.is_empty() { + ident.to_string() + } else { + format!("{prefix}::{ident}") + }; + table.bindings.push(ConstantBinding { qualified_name, value }); + } + } + Item::Mod(module) if !is_cfg_test(&module.attrs) => { + if let Some((_, contents)) = &module.content { + let nested = if prefix.is_empty() { + module.ident.to_string() + } else { + format!("{prefix}::{}", module.ident) + }; + collect_items(contents, &nested, table); + } + } + _ => {} } - } else { - into.insert(name, value); } } + + let mut table = ConstantTable::default(); + collect_items(&file.items, "", &mut table); + table } #[derive(Debug, Clone)] struct FunctionInfo { + file: String, name: String, simple_name: String, emitted_param_indexes: BTreeSet, } -fn collect_functions(file: &File) -> Vec { +fn collect_functions(file: &File, source_file: &str) -> Vec { let mut functions = Vec::new(); for item in &file.items { match item { - Item::Fn(function) => functions.push(function_info(function, None)), + Item::Fn(function) => functions.push(function_info(function, None, source_file)), Item::Impl(implementation) => { let type_name = impl_type_name(&implementation.self_ty); for member in &implementation.items { if let ImplItem::Fn(function) = member { - functions.push(impl_function_info(function, type_name.as_deref())); + functions.push(impl_function_info(function, type_name.as_deref(), source_file)); } } } @@ -392,23 +453,29 @@ fn collect_functions(file: &File) -> Vec { functions } -fn function_info(function: &ItemFn, owner: Option<&str>) -> FunctionInfo { +fn function_info(function: &ItemFn, owner: Option<&str>, source_file: &str) -> FunctionInfo { function_info_from_parts( owner, function.sig.ident.to_string(), &function.sig.inputs, &function.block, function.span().start().line, + source_file, ) } -fn impl_function_info(function: &syn::ImplItemFn, owner: Option<&str>) -> FunctionInfo { +fn impl_function_info( + function: &syn::ImplItemFn, + owner: Option<&str>, + source_file: &str, +) -> FunctionInfo { function_info_from_parts( owner, function.sig.ident.to_string(), &function.sig.inputs, &function.block, function.span().start().line, + source_file, ) } @@ -418,6 +485,7 @@ fn function_info_from_parts( inputs: &syn::punctuated::Punctuated, body: &syn::Block, line: usize, + source_file: &str, ) -> FunctionInfo { let param_names = inputs .iter() @@ -442,7 +510,7 @@ fn function_info_from_parts( .collect(); let name = owner.map_or_else(|| simple_name.clone(), |owner| format!("{owner}::{simple_name}")); let _ = line; - FunctionInfo { name, simple_name, emitted_param_indexes } + FunctionInfo { file: source_file.to_owned(), name, simple_name, emitted_param_indexes } } fn impl_type_name(ty: &syn::Type) -> Option { @@ -473,16 +541,10 @@ impl<'ast> Visit<'ast> for ParameterEmitFlow<'_> { } fn verify_wrapper_contract( - file: &str, - syntax: &File, - constants: &BTreeMap, + constants: &ConstantTable, functions: &[FunctionInfo], + calls: &[CallSite], ) -> Result<(), ScanError> { - let mut calls = Vec::new(); - { - let mut collector = CallCollector { calls: &mut calls }; - collector.visit_file(syntax); - } for function in functions.iter().filter(|function| !function.emitted_param_indexes.is_empty()) { for call in calls.iter().filter(|call| call.name == function.simple_name) { for index in &function.emitted_param_indexes { @@ -493,14 +555,14 @@ fn verify_wrapper_contract( } if resolve_name(argument, constants).is_none() { return Err(ScanError::UnresolvedWrapperCall { - file: file.to_owned(), + file: call.file.clone(), line, function: function.name.clone(), }); } if !is_false_positive(function) { return Err(ScanError::UnregisteredWrapper { - file: file.to_owned(), + file: function.file.clone(), line, function: function.name.clone(), }); @@ -512,20 +574,29 @@ fn verify_wrapper_contract( } struct CallSite { + file: String, name: String, args: Vec, line: usize, } struct CallCollector<'a> { + file: &'a str, calls: &'a mut Vec, } impl<'ast> Visit<'ast> for CallCollector<'ast> { + fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) { + if !is_cfg_test(&node.attrs) { + visit::visit_item_mod(self, node); + } + } + fn visit_expr_call(&mut self, node: &'ast ExprCall) { if let Expr::Path(path) = peel(&node.func) { if let Some(segment) = path.path.segments.last() { self.calls.push(CallSite { + file: self.file.to_owned(), name: segment.ident.to_string(), args: node.args.iter().cloned().collect(), line: node.span().start().line, @@ -536,6 +607,12 @@ impl<'ast> Visit<'ast> for CallCollector<'ast> { } } +fn collect_call_sites(syntax: &File, file: &str) -> Vec { + let mut calls = Vec::new(); + CallCollector { file, calls: &mut calls }.visit_file(syntax); + calls +} + fn wrapper_for_method(method: &str, current_function: Option<&str>) -> Option<&'static Wrapper> { WRAPPERS.iter().find(|wrapper| { wrapper @@ -560,7 +637,7 @@ fn is_false_positive(function: &FunctionInfo) -> bool { struct EmitVisitor<'a> { file: String, - constants: &'a BTreeMap, + constants: &'a ConstantTable, current_function: Option, current_locals: BTreeMap>, sites: Vec, @@ -612,7 +689,7 @@ impl EmitVisitor<'_> { }; RECEIVER_FALSE_POSITIVE_ALLOWLIST.iter().any(|entry| { debug_assert!(!entry.reason.is_empty()); - entry.file == self.file && entry.receiver == identity + entry.file.ends_with(&self.file) && entry.receiver == identity }) } @@ -631,6 +708,12 @@ impl EmitVisitor<'_> { } impl<'ast> Visit<'ast> for EmitVisitor<'_> { + fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) { + if !is_cfg_test(&node.attrs) { + visit::visit_item_mod(self, node); + } + } + fn visit_item_fn(&mut self, node: &'ast ItemFn) { self.enter_function( node.sig.ident.to_string(), @@ -706,14 +789,9 @@ impl<'ast> Visit<'ast> for EmitVisitor<'_> { } } -fn resolve_name(expr: &Expr, constants: &BTreeMap) -> Option { +fn resolve_name(expr: &Expr, constants: &ConstantTable) -> Option { literal(expr).or_else(|| match peel(expr) { - Expr::Path(path) => path - .path - .segments - .last() - .and_then(|segment| constants.get(&segment.ident.to_string())) - .cloned(), + Expr::Path(path) => (!path.path.segments.is_empty()).then(|| constants.resolve_path(&path.path)).flatten(), Expr::Reference(reference) => resolve_name(&reference.expr, constants), _ => None, }) @@ -721,7 +799,7 @@ fn resolve_name(expr: &Expr, constants: &BTreeMap) -> Option, + constants: &ConstantTable, locals: &BTreeMap>, ) -> BTreeSet { if let Expr::Path(path) = peel(expr) { @@ -736,7 +814,7 @@ fn resolve_names( fn static_local_names( block: &syn::Block, - constants: &BTreeMap, + constants: &ConstantTable, ) -> BTreeMap> { struct Locals { bindings: Vec<(Pat, Expr)>, @@ -777,7 +855,7 @@ fn static_local_names( fn static_expression_names( expression: &Expr, - constants: &BTreeMap, + constants: &ConstantTable, locals: &BTreeMap>, ) -> BTreeSet { match peel(expression) { @@ -827,7 +905,7 @@ fn static_expression_names( fn static_block_names( block: &syn::Block, - constants: &BTreeMap, + constants: &ConstantTable, locals: &BTreeMap>, ) -> BTreeSet { block.stmts.last().map_or_else(BTreeSet::new, |statement| match statement { @@ -839,7 +917,7 @@ fn static_block_names( fn static_pattern_bindings( pattern: &Pat, expression: &Expr, - constants: &BTreeMap, + constants: &ConstantTable, locals: &BTreeMap>, ) -> Vec<(String, BTreeSet)> { match pattern { @@ -934,6 +1012,26 @@ fn files_with_extension(root: &Path, extension: &str) -> Result> { Ok(paths) } +fn production_rust_files(root: &Path) -> Result> { + Ok(files_with_extension(root, "rs")? + .into_iter() + .filter(|path| { + let file_name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default(); + !file_name.ends_with("_tests.rs") + && !path.components().any(|component| component.as_os_str() == "tests") + }) + .collect()) +} + +fn is_cfg_test(attributes: &[syn::Attribute]) -> bool { + attributes.iter().any(|attribute| { + attribute.path().is_ident("cfg") + && attribute + .parse_args::() + .is_ok_and(|identifier| identifier == "test") + }) +} + fn relative(root: &Path, path: &Path) -> String { path.strip_prefix(root).unwrap_or(path).display().to_string() } diff --git a/scripts/event-manifest-scanner/tests/scanner.rs b/scripts/event-manifest-scanner/tests/scanner.rs index 5dfbfbad2f..5518b1604f 100644 --- a/scripts/event-manifest-scanner/tests/scanner.rs +++ b/scripts/event-manifest-scanner/tests/scanner.rs @@ -1,5 +1,5 @@ use event_manifest_scanner::{ - reviewed_unmatched_events, scan_consumed_source, scan_rust_source, + reviewed_unmatched_events, scan_consumed_source, scan_production_rust_tree, scan_rust_source, verify_unmatched_event_coverage, ScanError, }; @@ -50,6 +50,73 @@ fn rejects_unregistered_wrappers_called_with_event_names() { assert!(matches!(error, ScanError::UnregisteredWrapper { .. })); } +#[test] +fn resolves_qualified_constants_and_rejects_ambiguous_leaf_constants() { + let source = r#" + mod module_a { pub const EVENT: &str = "task:created"; } + mod module_b { pub const EVENT: &str = "task:deleted"; } + fn emits(app: &App) { + app.emit(module_a::EVENT, ()).unwrap(); + app.emit(module_b::EVENT, ()).unwrap(); + } + "#; + assert_eq!( + names(source), + vec!["task:created".to_owned(), "task:deleted".to_owned()] + ); + + let ambiguous = r#" + mod module_a { pub const EVENT: &str = "task:created"; } + mod module_b { pub const EVENT: &str = "task:deleted"; } + fn emits(app: &App) { app.emit(EVENT, ()).unwrap(); } + "#; + let error = scan_rust_source("ambiguous.rs", ambiguous) + .expect_err("an ambiguous bare constant must fail closed"); + assert!(matches!(error, ScanError::UnresolvedEmit { .. })); +} + +#[test] +fn production_census_excludes_test_only_emit_sites() { + let root = tempfile::tempdir().expect("temp root"); + std::fs::write( + root.path().join("production.rs"), + "fn no_events() {}", + ) + .expect("production source"); + std::fs::create_dir(root.path().join("tests")).expect("tests directory"); + std::fs::write( + root.path().join("tests/test_only.rs"), + "fn test_only(app: &App) { app.emit(\"agent:run_started\", ()).unwrap(); }", + ) + .expect("test source"); + + let sites = scan_production_rust_tree(root.path()).expect("scan production tree"); + assert!(sites.is_empty(), "test-only emit must not enter the production census"); + let error = verify_unmatched_event_coverage(&["agent:run_started"]) + .expect_err("test-only classified name remains unmatched"); + assert!(error.to_string().contains("no reviewed gap entry")); +} + +#[test] +fn rejects_wrapper_defined_in_one_file_and_called_in_another() { + let root = tempfile::tempdir().expect("temp root"); + std::fs::write( + root.path().join("wrapper.rs"), + "fn unregistered(app: &App, event: &str) { app.emit(event, ()).unwrap(); }", + ) + .expect("wrapper source"); + std::fs::write( + root.path().join("caller.rs"), + "fn call(app: &App) { unregistered(app, \"task:created\"); }", + ) + .expect("caller source"); + + let error = scan_production_rust_tree(root.path()) + .expect_err("cross-file forwarding wrapper must be registered"); + let message = error.to_string(); + assert!(message.contains("unregistered"), "{message}"); +} + #[test] fn resolves_direct_and_static_mapped_event_bus_subscriptions() { let names = scan_consumed_source( diff --git a/scripts/event-manifest.json b/scripts/event-manifest.json index 473c01cfec..d5dffb436c 100644 --- a/scripts/event-manifest.json +++ b/scripts/event-manifest.json @@ -19,12 +19,6 @@ "line": 3519, "kind": "method" }, - { - "name": "agent:chunk", - "file": "src-tauri/src/remote_server/capture_tests.rs", - "line": 59, - "kind": "method" - }, { "name": "agent:conversation_created", "file": "src-tauri/src/application/agent_conversation_start_service/finish_flow.rs", @@ -337,24 +331,6 @@ "line": 46, "kind": "method" }, - { - "name": "agent:run_completed", - "file": "src-tauri/src/application/throttled_emitter_tests.rs", - "line": 48, - "kind": "method" - }, - { - "name": "agent:run_completed", - "file": "src-tauri/src/commands/agent_workspace_auto_publish_tests.rs", - "line": 341, - "kind": "method" - }, - { - "name": "agent:run_completed", - "file": "src-tauri/src/commands/agent_workspace_auto_review_tests.rs", - "line": 274, - "kind": "method" - }, { "name": "agent:run_completed", "file": "src-tauri/src/http_server/handlers/ideation/verification/lifecycle.rs", @@ -493,18 +469,6 @@ "line": 2336, "kind": "method" }, - { - "name": "agent:turn_completed", - "file": "src-tauri/src/commands/agent_workspace_auto_publish_tests.rs", - "line": 349, - "kind": "method" - }, - { - "name": "agent:turn_completed", - "file": "src-tauri/src/commands/agent_workspace_auto_review_tests.rs", - "line": 276, - "kind": "method" - }, { "name": "agent:usage_updated", "file": "src-tauri/src/application/chat_service/chat_service_streaming.rs", @@ -643,42 +607,6 @@ "line": 37, "kind": "method" }, - { - "name": "event", - "file": "src-tauri/src/domain/state_machine/mocks_tests.rs", - "line": 126, - "kind": "method" - }, - { - "name": "event", - "file": "src-tauri/src/tests/event_sink_coverage_tests.rs", - "line": 17, - "kind": "method" - }, - { - "name": "event", - "file": "src-tauri/src/tests/event_sink_coverage_tests.rs", - "line": 42, - "kind": "method" - }, - { - "name": "event", - "file": "src-tauri/src/tests/event_sink_coverage_tests.rs", - "line": 53, - "kind": "method" - }, - { - "name": "event1", - "file": "src-tauri/src/domain/state_machine/mocks_tests.rs", - "line": 108, - "kind": "method" - }, - { - "name": "event2", - "file": "src-tauri/src/domain/state_machine/mocks_tests.rs", - "line": 109, - "kind": "method" - }, { "name": "execution:active_project_changed", "file": "src-tauri/src/commands/execution_commands/settings.rs", @@ -991,30 +919,6 @@ "line": 311, "kind": "method" }, - { - "name": "notification:created", - "file": "src-tauri/src/remote_server/capture_tests.rs", - "line": 58, - "kind": "method" - }, - { - "name": "notification:created", - "file": "src-tauri/src/remote_server/capture_tests.rs", - "line": 82, - "kind": "method" - }, - { - "name": "notification:created", - "file": "src-tauri/src/remote_server/capture_tests.rs", - "line": 84, - "kind": "method" - }, - { - "name": "notification:created", - "file": "src-tauri/src/remote_server/capture_tests.rs", - "line": 85, - "kind": "method" - }, { "name": "notification:desktop_activated", "file": "src-tauri/src/application/desktop_notification.rs", @@ -1573,24 +1477,6 @@ "line": 1928, "kind": "method" }, - { - "name": "task:reconciliation_action", - "file": "src-tauri/src/tests/hardening/error_visibility_tests.rs", - "line": 431, - "kind": "method" - }, - { - "name": "task:reconciliation_action", - "file": "src-tauri/src/tests/hardening/error_visibility_tests.rs", - "line": 434, - "kind": "wrapper_method" - }, - { - "name": "task:reconciliation_action", - "file": "src-tauri/src/tests/hardening/error_visibility_tests.rs", - "line": 467, - "kind": "method" - }, { "name": "task:recovery_failed", "file": "src-tauri/src/application/chat_service/chat_service_handlers.rs", @@ -1735,12 +1621,6 @@ "line": 613, "kind": "method" }, - { - "name": "task_blocked", - "file": "src-tauri/src/domain/state_machine/mocks_tests.rs", - "line": 95, - "kind": "wrapper_method" - }, { "name": "task_completed", "file": "src-tauri/src/domain/state_machine/transition_handler/on_enter_states/outcomes.rs", @@ -1753,18 +1633,6 @@ "line": 170, "kind": "method" }, - { - "name": "task_started", - "file": "src-tauri/src/domain/state_machine/mocks_tests.rs", - "line": 84, - "kind": "method" - }, - { - "name": "task_started", - "file": "src-tauri/src/domain/state_machine/mocks_tests.rs", - "line": 117, - "kind": "method" - }, { "name": "task_validation:event", "file": "src-tauri/src/application/validation_events.rs", From 3bbc70d02c73014408ce40f3d23eeb26c7f8236a Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:00:12 +0300 Subject: [PATCH 007/416] fix: resolve event manifest module constants --- scripts/event-manifest-scanner/src/lib.rs | 69 ++++++++++++++----- .../event-manifest-scanner/tests/scanner.rs | 58 +++++++++++++++- scripts/event-manifest.json | 26 +++---- 3 files changed, 119 insertions(+), 34 deletions(-) diff --git a/scripts/event-manifest-scanner/src/lib.rs b/scripts/event-manifest-scanner/src/lib.rs index 3834811b1b..7a1537a37a 100644 --- a/scripts/event-manifest-scanner/src/lib.rs +++ b/scripts/event-manifest-scanner/src/lib.rs @@ -75,17 +75,17 @@ const STATIC_EVENT_FUNCTIONS: &[StaticEventFunction] = &[StaticEventFunction { /// producer in the current application. This is deliberately a closed list: either adding a /// classification or landing its source emit requires a corresponding reviewed ledger update. const UNMATCHED_EVENT_GAPS: &[ReviewedUnmatchedEvent] = &[ - ReviewedUnmatchedEvent::new("execution:error", "no-tauri-emitter", "execution error state is surfaced through query invalidation, not a Tauri emit"), - ReviewedUnmatchedEvent::new("file:change", "no-tauri-emitter", "file changes are consumed from watcher state without a Tauri emit"), - ReviewedUnmatchedEvent::new("proposal:deleted", "no-tauri-emitter", "proposal deletion has no current Tauri event producer"), - ReviewedUnmatchedEvent::new("qa:prep", "no-tauri-emitter", "QA preparation state has no current Tauri event producer"), - ReviewedUnmatchedEvent::new("qa:test", "no-tauri-emitter", "QA test state has no current Tauri event producer"), - ReviewedUnmatchedEvent::new("step:deleted", "no-tauri-emitter", "step deletion has no current Tauri event producer"), - ReviewedUnmatchedEvent::new("step:status_changed", "no-tauri-emitter", "step status changes have no current Tauri event producer"), - ReviewedUnmatchedEvent::new("steps:reordered", "no-tauri-emitter", "step reordering has no current Tauri event producer"), - ReviewedUnmatchedEvent::new("supervisor:alert", "no-tauri-emitter", "supervisor alerts have no current Tauri event producer"), - ReviewedUnmatchedEvent::new("supervisor:event", "no-tauri-emitter", "supervisor events have no current Tauri event producer"), - ReviewedUnmatchedEvent::new("execution:stderr", "no-tauri-emitter", "execution stderr is consumed from process state without a Tauri emit"), + ReviewedUnmatchedEvent::new("execution:error", "no-tauri-emitter", "frontend consumer exists; no current Tauri event producer"), + ReviewedUnmatchedEvent::new("file:change", "no-tauri-emitter", "frontend consumer exists; no current Tauri event producer"), + ReviewedUnmatchedEvent::new("proposal:deleted", "no-tauri-emitter", "frontend consumer exists; no current Tauri event producer"), + ReviewedUnmatchedEvent::new("qa:prep", "no-tauri-emitter", "frontend consumer exists; no current Tauri event producer"), + ReviewedUnmatchedEvent::new("qa:test", "no-tauri-emitter", "frontend consumer exists; no current Tauri event producer"), + ReviewedUnmatchedEvent::new("step:deleted", "no-tauri-emitter", "frontend consumer exists; no current Tauri event producer"), + ReviewedUnmatchedEvent::new("step:status_changed", "no-tauri-emitter", "frontend consumer exists; no current Tauri event producer"), + ReviewedUnmatchedEvent::new("steps:reordered", "no-tauri-emitter", "frontend consumer exists; no current Tauri event producer"), + ReviewedUnmatchedEvent::new("supervisor:alert", "no-tauri-bridge", "frontend consumer exists; backend has internal Supervisor EventBus but no Tauri bridge"), + ReviewedUnmatchedEvent::new("supervisor:event", "no-tauri-bridge", "frontend consumer exists; backend has internal Supervisor EventBus but no Tauri bridge"), + ReviewedUnmatchedEvent::new("execution:stderr", "no-tauri-emitter", "frontend consumer exists; no current Tauri event producer"), ]; #[derive(Clone, Copy)] @@ -196,7 +196,7 @@ pub enum ScanError { pub fn scan_rust_source(file: impl Into, source: &str) -> Result, ScanError> { let file = file.into(); let syntax = syn::parse_file(source).map_err(|error| ScanError::Parse(format!("{file}: {error}")))?; - let constants = collect_constants_from_file(&syntax); + let constants = collect_constants_from_file(&syntax, ""); let functions = collect_functions(&syntax, &file); let calls = collect_call_sites(&syntax, &file); verify_wrapper_contract(&constants, &functions, &calls)?; @@ -251,7 +251,7 @@ pub fn scan_production_rust_tree(root: &Path) -> Result> { let file = relative(root, &path); let source = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; let syntax = syn::parse_file(&source).with_context(|| format!("parse {}", path.display()))?; - constants.extend(collect_constants_from_file(&syntax)); + constants.extend(collect_constants_from_file(&syntax, &module_path_for_file(root, &path))); functions.extend(collect_functions(&syntax, &file)); calls.extend(collect_call_sites(&syntax, &file)); source_files.push((file, syntax)); @@ -378,21 +378,33 @@ impl ConstantTable { if requested.is_empty() { return None; } + let exact = self + .bindings + .iter() + .filter(|binding| binding.qualified_name == requested) + .collect::>(); + if exact.len() == 1 { + return Some(exact[0].value.clone()); + } + if exact.len() > 1 { + return None; + } + let suffix = format!("::{requested}"); let matches = self .bindings .iter() .filter(|binding| { - binding.qualified_name == requested - || binding.qualified_name.ends_with(&suffix) - || requested.ends_with(&format!("::{}", binding.qualified_name)) + binding.qualified_name.ends_with(&suffix) + || (requested.contains("::") + && requested.ends_with(&format!("::{}", binding.qualified_name))) }) .collect::>(); (matches.len() == 1).then(|| matches[0].value.clone()) } } -fn collect_constants_from_file(file: &File) -> ConstantTable { +fn collect_constants_from_file(file: &File, module_path: &str) -> ConstantTable { fn collect_items(items: &[Item], prefix: &str, table: &mut ConstantTable) { for item in items { match item { @@ -422,7 +434,7 @@ fn collect_constants_from_file(file: &File) -> ConstantTable { } let mut table = ConstantTable::default(); - collect_items(&file.items, "", &mut table); + collect_items(&file.items, module_path, &mut table); table } @@ -1036,6 +1048,27 @@ fn relative(root: &Path, path: &Path) -> String { path.strip_prefix(root).unwrap_or(path).display().to_string() } +fn module_path_for_file(root: &Path, path: &Path) -> String { + let mut parts = path + .strip_prefix(root) + .unwrap_or(path) + .components() + .filter_map(|component| component.as_os_str().to_str()) + .map(ToOwned::to_owned) + .collect::>(); + let Some(file_name) = parts.pop() else { + return String::new(); + }; + let stem = Path::new(&file_name) + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or_default(); + if !matches!(stem, "lib" | "main" | "mod") { + parts.push(stem.to_owned()); + } + parts.join("::") +} + fn consumed_names(root: &Path) -> Result> { let mut names = BTreeSet::new(); let shared_constants = frontend_event_constants(root)?; diff --git a/scripts/event-manifest-scanner/tests/scanner.rs b/scripts/event-manifest-scanner/tests/scanner.rs index 5518b1604f..4dbde752aa 100644 --- a/scripts/event-manifest-scanner/tests/scanner.rs +++ b/scripts/event-manifest-scanner/tests/scanner.rs @@ -75,6 +75,47 @@ fn resolves_qualified_constants_and_rejects_ambiguous_leaf_constants() { assert!(matches!(error, ScanError::UnresolvedEmit { .. })); } +#[test] +fn resolves_qualified_constants_across_module_files_and_rejects_bare_leaf() { + let root = tempfile::tempdir().expect("temp root"); + std::fs::write( + root.path().join("module_a.rs"), + "pub const EVENT: &str = \"task:created\";", + ) + .expect("module a"); + std::fs::write( + root.path().join("module_b.rs"), + "pub const EVENT: &str = \"task:deleted\";", + ) + .expect("module b"); + std::fs::write( + root.path().join("caller.rs"), + r#" + fn emits(app: &App) { + app.emit(module_a::EVENT, ()).unwrap(); + app.emit(module_b::EVENT, ()).unwrap(); + } + "#, + ) + .expect("caller"); + + let names = scan_production_rust_tree(root.path()) + .expect("qualified multi-file constants resolve") + .into_iter() + .map(|site| site.name) + .collect::>(); + assert_eq!(names, vec!["task:created", "task:deleted"]); + + std::fs::write( + root.path().join("caller.rs"), + "fn emits(app: &App) { app.emit(EVENT, ()).unwrap(); }", + ) + .expect("ambiguous caller"); + let error = scan_production_rust_tree(root.path()) + .expect_err("bare multi-file constant must be ambiguous"); + assert!(error.to_string().contains("unresolved event name")); +} + #[test] fn production_census_excludes_test_only_emit_sites() { let root = tempfile::tempdir().expect("temp root"); @@ -149,12 +190,23 @@ fn renders_reason_coded_reviewed_unmatched_event_gaps() { let gaps = reviewed_unmatched_events(); assert_eq!(gaps.len(), 11); assert!(gaps.iter().any(|gap| gap.name() == "execution:stderr")); - assert!(serde_json::to_value(&gaps) - .expect("gaps serialize") + let rendered_value = serde_json::to_value(&gaps).expect("gaps serialize"); + let rendered = rendered_value .as_array() - .expect("gap list") + .expect("gap list"); + assert!(rendered .iter() .all(|gap| gap.get("reason_code").is_some() && gap.get("reason").is_some())); + assert!(rendered.iter().any(|gap| { + gap.get("name").and_then(serde_json::Value::as_str) == Some("execution:error") + && gap.get("reason").and_then(serde_json::Value::as_str) + == Some("frontend consumer exists; no current Tauri event producer") + })); + assert!(rendered.iter().any(|gap| { + gap.get("name").and_then(serde_json::Value::as_str) == Some("supervisor:event") + && gap.get("reason").and_then(serde_json::Value::as_str) + == Some("frontend consumer exists; backend has internal Supervisor EventBus but no Tauri bridge") + })); } #[test] diff --git a/scripts/event-manifest.json b/scripts/event-manifest.json index d5dffb436c..79ea780472 100644 --- a/scripts/event-manifest.json +++ b/scripts/event-manifest.json @@ -1923,57 +1923,57 @@ { "name": "execution:error", "reason_code": "no-tauri-emitter", - "reason": "execution error state is surfaced through query invalidation, not a Tauri emit" + "reason": "frontend consumer exists; no current Tauri event producer" }, { "name": "file:change", "reason_code": "no-tauri-emitter", - "reason": "file changes are consumed from watcher state without a Tauri emit" + "reason": "frontend consumer exists; no current Tauri event producer" }, { "name": "proposal:deleted", "reason_code": "no-tauri-emitter", - "reason": "proposal deletion has no current Tauri event producer" + "reason": "frontend consumer exists; no current Tauri event producer" }, { "name": "qa:prep", "reason_code": "no-tauri-emitter", - "reason": "QA preparation state has no current Tauri event producer" + "reason": "frontend consumer exists; no current Tauri event producer" }, { "name": "qa:test", "reason_code": "no-tauri-emitter", - "reason": "QA test state has no current Tauri event producer" + "reason": "frontend consumer exists; no current Tauri event producer" }, { "name": "step:deleted", "reason_code": "no-tauri-emitter", - "reason": "step deletion has no current Tauri event producer" + "reason": "frontend consumer exists; no current Tauri event producer" }, { "name": "step:status_changed", "reason_code": "no-tauri-emitter", - "reason": "step status changes have no current Tauri event producer" + "reason": "frontend consumer exists; no current Tauri event producer" }, { "name": "steps:reordered", "reason_code": "no-tauri-emitter", - "reason": "step reordering has no current Tauri event producer" + "reason": "frontend consumer exists; no current Tauri event producer" }, { "name": "supervisor:alert", - "reason_code": "no-tauri-emitter", - "reason": "supervisor alerts have no current Tauri event producer" + "reason_code": "no-tauri-bridge", + "reason": "frontend consumer exists; backend has internal Supervisor EventBus but no Tauri bridge" }, { "name": "supervisor:event", - "reason_code": "no-tauri-emitter", - "reason": "supervisor events have no current Tauri event producer" + "reason_code": "no-tauri-bridge", + "reason": "frontend consumer exists; backend has internal Supervisor EventBus but no Tauri bridge" }, { "name": "execution:stderr", "reason_code": "no-tauri-emitter", - "reason": "execution stderr is consumed from process state without a Tauri emit" + "reason": "frontend consumer exists; no current Tauri event producer" } ] } From 35e8242f7de8afc4d38597842100374b93462ef5 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:02:04 +0300 Subject: [PATCH 008/416] style: format event manifest scanner --- scripts/event-manifest-scanner/src/lib.rs | 359 ++++++++++++++---- scripts/event-manifest-scanner/src/main.rs | 9 +- .../event-manifest-scanner/tests/scanner.rs | 25 +- 3 files changed, 301 insertions(+), 92 deletions(-) diff --git a/scripts/event-manifest-scanner/src/lib.rs b/scripts/event-manifest-scanner/src/lib.rs index 7a1537a37a..0f8297dbb0 100644 --- a/scripts/event-manifest-scanner/src/lib.rs +++ b/scripts/event-manifest-scanner/src/lib.rs @@ -75,17 +75,61 @@ const STATIC_EVENT_FUNCTIONS: &[StaticEventFunction] = &[StaticEventFunction { /// producer in the current application. This is deliberately a closed list: either adding a /// classification or landing its source emit requires a corresponding reviewed ledger update. const UNMATCHED_EVENT_GAPS: &[ReviewedUnmatchedEvent] = &[ - ReviewedUnmatchedEvent::new("execution:error", "no-tauri-emitter", "frontend consumer exists; no current Tauri event producer"), - ReviewedUnmatchedEvent::new("file:change", "no-tauri-emitter", "frontend consumer exists; no current Tauri event producer"), - ReviewedUnmatchedEvent::new("proposal:deleted", "no-tauri-emitter", "frontend consumer exists; no current Tauri event producer"), - ReviewedUnmatchedEvent::new("qa:prep", "no-tauri-emitter", "frontend consumer exists; no current Tauri event producer"), - ReviewedUnmatchedEvent::new("qa:test", "no-tauri-emitter", "frontend consumer exists; no current Tauri event producer"), - ReviewedUnmatchedEvent::new("step:deleted", "no-tauri-emitter", "frontend consumer exists; no current Tauri event producer"), - ReviewedUnmatchedEvent::new("step:status_changed", "no-tauri-emitter", "frontend consumer exists; no current Tauri event producer"), - ReviewedUnmatchedEvent::new("steps:reordered", "no-tauri-emitter", "frontend consumer exists; no current Tauri event producer"), - ReviewedUnmatchedEvent::new("supervisor:alert", "no-tauri-bridge", "frontend consumer exists; backend has internal Supervisor EventBus but no Tauri bridge"), - ReviewedUnmatchedEvent::new("supervisor:event", "no-tauri-bridge", "frontend consumer exists; backend has internal Supervisor EventBus but no Tauri bridge"), - ReviewedUnmatchedEvent::new("execution:stderr", "no-tauri-emitter", "frontend consumer exists; no current Tauri event producer"), + ReviewedUnmatchedEvent::new( + "execution:error", + "no-tauri-emitter", + "frontend consumer exists; no current Tauri event producer", + ), + ReviewedUnmatchedEvent::new( + "file:change", + "no-tauri-emitter", + "frontend consumer exists; no current Tauri event producer", + ), + ReviewedUnmatchedEvent::new( + "proposal:deleted", + "no-tauri-emitter", + "frontend consumer exists; no current Tauri event producer", + ), + ReviewedUnmatchedEvent::new( + "qa:prep", + "no-tauri-emitter", + "frontend consumer exists; no current Tauri event producer", + ), + ReviewedUnmatchedEvent::new( + "qa:test", + "no-tauri-emitter", + "frontend consumer exists; no current Tauri event producer", + ), + ReviewedUnmatchedEvent::new( + "step:deleted", + "no-tauri-emitter", + "frontend consumer exists; no current Tauri event producer", + ), + ReviewedUnmatchedEvent::new( + "step:status_changed", + "no-tauri-emitter", + "frontend consumer exists; no current Tauri event producer", + ), + ReviewedUnmatchedEvent::new( + "steps:reordered", + "no-tauri-emitter", + "frontend consumer exists; no current Tauri event producer", + ), + ReviewedUnmatchedEvent::new( + "supervisor:alert", + "no-tauri-bridge", + "frontend consumer exists; backend has internal Supervisor EventBus but no Tauri bridge", + ), + ReviewedUnmatchedEvent::new( + "supervisor:event", + "no-tauri-bridge", + "frontend consumer exists; backend has internal Supervisor EventBus but no Tauri bridge", + ), + ReviewedUnmatchedEvent::new( + "execution:stderr", + "no-tauri-emitter", + "frontend consumer exists; no current Tauri event producer", + ), ]; #[derive(Clone, Copy)] @@ -140,7 +184,11 @@ pub struct ManifestFalsePositive { impl ReviewedUnmatchedEvent { const fn new(name: &'static str, reason_code: &'static str, reason: &'static str) -> Self { - Self { name, reason_code, reason } + Self { + name, + reason_code, + reason, + } } pub fn name(&self) -> &'static str { @@ -195,7 +243,8 @@ pub enum ScanError { pub fn scan_rust_source(file: impl Into, source: &str) -> Result, ScanError> { let file = file.into(); - let syntax = syn::parse_file(source).map_err(|error| ScanError::Parse(format!("{file}: {error}")))?; + let syntax = + syn::parse_file(source).map_err(|error| ScanError::Parse(format!("{file}: {error}")))?; let constants = collect_constants_from_file(&syntax, ""); let functions = collect_functions(&syntax, &file); let calls = collect_call_sites(&syntax, &file); @@ -249,9 +298,14 @@ pub fn scan_production_rust_tree(root: &Path) -> Result> { let mut calls = Vec::new(); for path in production_rust_files(root)? { let file = relative(root, &path); - let source = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; - let syntax = syn::parse_file(&source).with_context(|| format!("parse {}", path.display()))?; - constants.extend(collect_constants_from_file(&syntax, &module_path_for_file(root, &path))); + let source = + fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; + let syntax = + syn::parse_file(&source).with_context(|| format!("parse {}", path.display()))?; + constants.extend(collect_constants_from_file( + &syntax, + &module_path_for_file(root, &path), + )); functions.extend(collect_functions(&syntax, &file)); calls.extend(collect_call_sites(&syntax, &file)); source_files.push((file, syntax)); @@ -292,13 +346,24 @@ fn verify_manifest( .cloned() .collect::>(); if !missing_classifications.is_empty() { - bail!("UI-consumed event names are unclassified: {}", missing_classifications.join(", ")); + bail!( + "UI-consumed event names are unclassified: {}", + missing_classifications.join(", ") + ); } - let emitted_names = emitted.iter().map(|site| site.name.as_str()).collect::>(); + let emitted_names = emitted + .iter() + .map(|site| site.name.as_str()) + .collect::>(); let missing_emits = classifications .iter() - .filter(|entry| !matches!(entry.delivery, ralphx_remote_protocol::EventDelivery::LocalOnly)) + .filter(|entry| { + !matches!( + entry.delivery, + ralphx_remote_protocol::EventDelivery::LocalOnly + ) + }) .filter(|entry| !emitted_names.contains(entry.name)) .map(|entry| entry.name) .collect::>(); @@ -318,11 +383,15 @@ fn manifest_false_positives() -> Vec { target: entry.function.to_owned(), reason: entry.reason.to_owned(), }) - .chain(RECEIVER_FALSE_POSITIVE_ALLOWLIST.iter().map(|entry| ManifestFalsePositive { - kind: "receiver".to_owned(), - target: format!("{}::{}", entry.file, entry.receiver), - reason: entry.reason.to_owned(), - })) + .chain( + RECEIVER_FALSE_POSITIVE_ALLOWLIST + .iter() + .map(|entry| ManifestFalsePositive { + kind: "receiver".to_owned(), + target: format!("{}::{}", entry.file, entry.receiver), + reason: entry.reason.to_owned(), + }), + ) .collect::>(); entries.sort_by(|left, right| left.target.cmp(&right.target)); entries @@ -415,7 +484,10 @@ fn collect_constants_from_file(file: &File, module_path: &str) -> ConstantTable } else { format!("{prefix}::{ident}") }; - table.bindings.push(ConstantBinding { qualified_name, value }); + table.bindings.push(ConstantBinding { + qualified_name, + value, + }); } } Item::Mod(module) if !is_cfg_test(&module.attrs) => { @@ -455,7 +527,11 @@ fn collect_functions(file: &File, source_file: &str) -> Vec { let type_name = impl_type_name(&implementation.self_ty); for member in &implementation.items { if let ImplItem::Fn(function) = member { - functions.push(impl_function_info(function, type_name.as_deref(), source_file)); + functions.push(impl_function_info( + function, + type_name.as_deref(), + source_file, + )); } } } @@ -509,7 +585,11 @@ fn function_info_from_parts( }, }) .collect::>(); - let param_set = param_names.iter().flatten().cloned().collect::>(); + let param_set = param_names + .iter() + .flatten() + .cloned() + .collect::>(); let mut flow = ParameterEmitFlow { params: ¶m_set, names: BTreeSet::new(), @@ -518,16 +598,32 @@ fn function_info_from_parts( let emitted_param_indexes = param_names .iter() .enumerate() - .filter_map(|(index, name)| name.as_ref().filter(|name| flow.names.contains(*name)).map(|_| index)) + .filter_map(|(index, name)| { + name.as_ref() + .filter(|name| flow.names.contains(*name)) + .map(|_| index) + }) .collect(); - let name = owner.map_or_else(|| simple_name.clone(), |owner| format!("{owner}::{simple_name}")); + let name = owner.map_or_else( + || simple_name.clone(), + |owner| format!("{owner}::{simple_name}"), + ); let _ = line; - FunctionInfo { file: source_file.to_owned(), name, simple_name, emitted_param_indexes } + FunctionInfo { + file: source_file.to_owned(), + name, + simple_name, + emitted_param_indexes, + } } fn impl_type_name(ty: &syn::Type) -> Option { match ty { - syn::Type::Path(path) => path.path.segments.last().map(|segment| segment.ident.to_string()), + syn::Type::Path(path) => path + .path + .segments + .last() + .map(|segment| segment.ident.to_string()), _ => None, } } @@ -557,10 +653,18 @@ fn verify_wrapper_contract( functions: &[FunctionInfo], calls: &[CallSite], ) -> Result<(), ScanError> { - for function in functions.iter().filter(|function| !function.emitted_param_indexes.is_empty()) { - for call in calls.iter().filter(|call| call.name == function.simple_name) { + for function in functions + .iter() + .filter(|function| !function.emitted_param_indexes.is_empty()) + { + for call in calls + .iter() + .filter(|call| call.name == function.simple_name) + { for index in &function.emitted_param_indexes { - let Some(argument) = call.args.get(*index) else { continue }; + let Some(argument) = call.args.get(*index) else { + continue; + }; let line = call.line; if wrapper_for_function(function).is_some() { continue; @@ -621,7 +725,11 @@ impl<'ast> Visit<'ast> for CallCollector<'ast> { fn collect_call_sites(syntax: &File, file: &str) -> Vec { let mut calls = Vec::new(); - CallCollector { file, calls: &mut calls }.visit_file(syntax); + CallCollector { + file, + calls: &mut calls, + } + .visit_file(syntax); calls } @@ -632,12 +740,15 @@ fn wrapper_for_method(method: &str, current_function: Option<&str>) -> Option<&' .rsplit_once("::") .is_some_and(|(_, name)| name == method) && (method != "emit_event" - || current_function.is_some_and(|function| function.starts_with("AppChatService::"))) + || current_function + .is_some_and(|function| function.starts_with("AppChatService::"))) }) } fn wrapper_for_function(function: &FunctionInfo) -> Option<&'static Wrapper> { - WRAPPERS.iter().find(|wrapper| wrapper.name == function.name || wrapper.name == function.simple_name) + WRAPPERS + .iter() + .find(|wrapper| wrapper.name == function.name || wrapper.name == function.simple_name) } fn is_false_positive(function: &FunctionInfo) -> bool { @@ -683,7 +794,10 @@ impl EmitVisitor<'_> { self.error = Some(ScanError::UnresolvedEmit { file: self.file.clone(), line: span.start().line, - function: self.current_function.clone().unwrap_or_else(|| "".into()), + function: self + .current_function + .clone() + .unwrap_or_else(|| "".into()), }); } } @@ -691,7 +805,9 @@ impl EmitVisitor<'_> { fn current_function_is_known_wrapper_or_false_positive(&self) -> bool { self.current_function.as_deref().is_some_and(|name| { WRAPPERS.iter().any(|wrapper| wrapper.name == name) - || FALSE_POSITIVE_ALLOWLIST.iter().any(|entry| entry.function == name && !entry.reason.is_empty()) + || FALSE_POSITIVE_ALLOWLIST + .iter() + .any(|entry| entry.function == name && !entry.reason.is_empty()) }) } @@ -782,7 +898,12 @@ impl<'ast> Visit<'ast> for EmitVisitor<'_> { fn visit_expr_call(&mut self, node: &'ast ExprCall) { if let Expr::Path(path) = peel(&node.func) { - if let Some(name) = path.path.segments.last().map(|segment| segment.ident.to_string()) { + if let Some(name) = path + .path + .segments + .last() + .map(|segment| segment.ident.to_string()) + { if let Some(wrapper) = WRAPPERS.iter().find(|wrapper| wrapper.name == name) { if let Some(index) = wrapper.event_arg { if let Some(event) = node.args.iter().nth(index) { @@ -803,7 +924,9 @@ impl<'ast> Visit<'ast> for EmitVisitor<'_> { fn resolve_name(expr: &Expr, constants: &ConstantTable) -> Option { literal(expr).or_else(|| match peel(expr) { - Expr::Path(path) => (!path.path.segments.is_empty()).then(|| constants.resolve_path(&path.path)).flatten(), + Expr::Path(path) => (!path.path.segments.is_empty()) + .then(|| constants.resolve_path(&path.path)) + .flatten(), Expr::Reference(reference) => resolve_name(&reference.expr, constants), _ => None, }) @@ -834,23 +957,29 @@ fn static_local_names( impl<'ast> Visit<'ast> for Locals { fn visit_local(&mut self, node: &'ast syn::Local) { if let Some(initializer) = &node.init { - self.bindings.push((node.pat.clone(), (*initializer.expr).clone())); + self.bindings + .push((node.pat.clone(), (*initializer.expr).clone())); } visit::visit_local(self, node); } fn visit_expr_let(&mut self, node: &'ast syn::ExprLet) { - self.bindings.push(((*node.pat).clone(), (*node.expr).clone())); + self.bindings + .push(((*node.pat).clone(), (*node.expr).clone())); visit::visit_expr_let(self, node); } } - let mut collector = Locals { bindings: Vec::new() }; + let mut collector = Locals { + bindings: Vec::new(), + }; collector.visit_block(block); let mut values = BTreeMap::new(); for _ in 0..collector.bindings.len() { let mut changed = false; for (pattern, expression) in &collector.bindings { - for (identifier, names) in static_pattern_bindings(pattern, expression, constants, &values) { + for (identifier, names) in + static_pattern_bindings(pattern, expression, constants, &values) + { if names.is_empty() { continue; } @@ -871,7 +1000,9 @@ fn static_expression_names( locals: &BTreeMap>, ) -> BTreeSet { match peel(expression) { - Expr::Lit(_) | Expr::Reference(_) | Expr::Path(_) => resolve_names(expression, constants, locals), + Expr::Lit(_) | Expr::Reference(_) | Expr::Path(_) => { + resolve_names(expression, constants, locals) + } Expr::If(expression) => { let mut names = static_block_names(&expression.then_branch, constants, locals); if let Some((_, otherwise)) = &expression.else_branch { @@ -892,11 +1023,19 @@ fn static_expression_names( .unwrap_or_default(), Expr::Call(expression) if expression.args.len() == 1 => { let function = match peel(&expression.func) { - Expr::Path(path) => path.path.segments.last().map(|segment| segment.ident.to_string()), + Expr::Path(path) => path + .path + .segments + .last() + .map(|segment| segment.ident.to_string()), _ => None, }; if function.as_deref() == Some("Some") { - static_expression_names(expression.args.first().expect("one checked argument"), constants, locals) + static_expression_names( + expression.args.first().expect("one checked argument"), + constants, + locals, + ) } else if let Some(output) = STATIC_EVENT_FUNCTIONS .iter() .find(|output| Some(output.function) == function.as_deref()) @@ -907,8 +1046,14 @@ fn static_expression_names( BTreeSet::new() } } - Expr::MethodCall(expression) if expression.method == "map" && expression.args.len() == 1 => { - static_expression_names(expression.args.first().expect("one checked argument"), constants, locals) + Expr::MethodCall(expression) + if expression.method == "map" && expression.args.len() == 1 => + { + static_expression_names( + expression.args.first().expect("one checked argument"), + constants, + locals, + ) } Expr::Closure(expression) => static_expression_names(&expression.body, constants, locals), _ => BTreeSet::new(), @@ -920,10 +1065,15 @@ fn static_block_names( constants: &ConstantTable, locals: &BTreeMap>, ) -> BTreeSet { - block.stmts.last().map_or_else(BTreeSet::new, |statement| match statement { - syn::Stmt::Expr(expression, _) => static_expression_names(expression, constants, locals), - _ => BTreeSet::new(), - }) + block + .stmts + .last() + .map_or_else(BTreeSet::new, |statement| match statement { + syn::Stmt::Expr(expression, _) => { + static_expression_names(expression, constants, locals) + } + _ => BTreeSet::new(), + }) } fn static_pattern_bindings( @@ -939,7 +1089,9 @@ fn static_pattern_bindings( )], Pat::Type(pattern) => static_pattern_bindings(&pattern.pat, expression, constants, locals), Pat::Paren(pattern) => static_pattern_bindings(&pattern.pat, expression, constants, locals), - Pat::Reference(pattern) => static_pattern_bindings(&pattern.pat, expression, constants, locals), + Pat::Reference(pattern) => { + static_pattern_bindings(&pattern.pat, expression, constants, locals) + } Pat::Or(pattern) => pattern .cases .iter() @@ -950,7 +1102,9 @@ fn static_pattern_bindings( .elems .iter() .zip(values.elems.iter()) - .flat_map(|(pattern, value)| static_pattern_bindings(pattern, value, constants, locals)) + .flat_map(|(pattern, value)| { + static_pattern_bindings(pattern, value, constants, locals) + }) .collect(), _ => tuple .elems @@ -963,7 +1117,9 @@ fn static_pattern_bindings( .elems .iter() .zip(values.elems.iter()) - .flat_map(|(pattern, value)| static_pattern_bindings(pattern, value, constants, locals)) + .flat_map(|(pattern, value)| { + static_pattern_bindings(pattern, value, constants, locals) + }) .collect(), _ => tuple .elems @@ -1017,7 +1173,12 @@ fn files_with_extension(root: &Path, extension: &str) -> Result> { .into_iter() .filter_map(|entry| entry.ok()) .filter(|entry| entry.file_type().is_file()) - .filter(|entry| entry.path().extension().is_some_and(|value| value == extension)) + .filter(|entry| { + entry + .path() + .extension() + .is_some_and(|value| value == extension) + }) .map(|entry| entry.into_path()) .collect::>(); paths.sort(); @@ -1028,9 +1189,14 @@ fn production_rust_files(root: &Path) -> Result> { Ok(files_with_extension(root, "rs")? .into_iter() .filter(|path| { - let file_name = path.file_name().and_then(|name| name.to_str()).unwrap_or_default(); + let file_name = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or_default(); !file_name.ends_with("_tests.rs") - && !path.components().any(|component| component.as_os_str() == "tests") + && !path + .components() + .any(|component| component.as_os_str() == "tests") }) .collect()) } @@ -1045,7 +1211,10 @@ fn is_cfg_test(attributes: &[syn::Attribute]) -> bool { } fn relative(root: &Path, path: &Path) -> String { - path.strip_prefix(root).unwrap_or(path).display().to_string() + path.strip_prefix(root) + .unwrap_or(path) + .display() + .to_string() } fn module_path_for_file(root: &Path, path: &Path) -> String { @@ -1074,7 +1243,10 @@ fn consumed_names(root: &Path) -> Result> { let shared_constants = frontend_event_constants(root)?; for extension in ["ts", "tsx"] { for path in files_with_extension(root, extension)? { - if path.file_name().is_some_and(|name| name.to_string_lossy().contains(".test.")) { + if path + .file_name() + .is_some_and(|name| name.to_string_lossy().contains(".test.")) + { continue; } let source = fs::read_to_string(&path)?; @@ -1082,8 +1254,12 @@ fn consumed_names(root: &Path) -> Result> { continue; } names.extend( - scan_consumed_source_with_constants(&path.display().to_string(), &source, &shared_constants) - .with_context(|| format!("scan {}", path.display()))?, + scan_consumed_source_with_constants( + &path.display().to_string(), + &source, + &shared_constants, + ) + .with_context(|| format!("scan {}", path.display()))?, ); } } @@ -1154,7 +1330,10 @@ fn collect_ts_constants_inner( values: &mut BTreeMap>, ) { if node.kind() == "variable_declarator" { - if let (Some(name), Some(value)) = (node.child_by_field_name("name"), node.child_by_field_name("value")) { + if let (Some(name), Some(value)) = ( + node.child_by_field_name("name"), + node.child_by_field_name("value"), + ) { if name.kind() == "identifier" { if let Some(events) = static_event_values(value, source) { values.insert(node_text(name, source).to_owned(), events); @@ -1186,10 +1365,16 @@ fn collect_subscriptions( return Ok(()); } let events = constants.get(list_name).ok_or_else(|| { - anyhow::anyhow!("{file}:{} mapped event list `{list_name}` is not a static literal array", node.start_position().row + 1) + anyhow::anyhow!( + "{file}:{} mapped event list `{list_name}` is not a static literal array", + node.start_position().row + 1 + ) })?; let parameter = callback_parameter(callback, source).ok_or_else(|| { - anyhow::anyhow!("{file}:{} mapped event callback must declare one identifier parameter", callback.start_position().row + 1) + anyhow::anyhow!( + "{file}:{} mapped event callback must declare one identifier parameter", + callback.start_position().row + 1 + ) })?; let mut mapped = mapped_values.clone(); mapped.insert(parameter, events.clone()); @@ -1201,15 +1386,19 @@ fn collect_subscriptions( } if is_event_bus_subscribe(node, source) { let argument = first_argument(node).ok_or_else(|| { - anyhow::anyhow!("{file}:{} EventBus.subscribe requires an event-name argument", node.start_position().row + 1) - })?; - let events = event_values(argument, source, constants, mapped_values).ok_or_else(|| { anyhow::anyhow!( - "{file}:{} unresolved EventBus subscription argument `{}`", - node.start_position().row + 1, - node_text(argument, source) + "{file}:{} EventBus.subscribe requires an event-name argument", + node.start_position().row + 1 ) })?; + let events = + event_values(argument, source, constants, mapped_values).ok_or_else(|| { + anyhow::anyhow!( + "{file}:{} unresolved EventBus subscription argument `{}`", + node.start_position().row + 1, + node_text(argument, source) + ) + })?; output.extend(events); } } @@ -1242,7 +1431,9 @@ fn static_map_call<'a>(node: Node<'a>, source: &'a str) -> Option<(&'a str, Node return None; } let arguments = node.child_by_field_name("arguments")?; - let callback = named_children(arguments).into_iter().find(|child| child.kind() == "arrow_function")?; + let callback = named_children(arguments) + .into_iter() + .find(|child| child.kind() == "arrow_function")?; Some((node_text(object, source), callback)) } @@ -1258,16 +1449,23 @@ fn callback_parameter(callback: Node<'_>, source: &str) -> Option { } fn is_event_bus_subscribe(node: Node<'_>, source: &str) -> bool { - let Some(function) = node.child_by_field_name("function") else { return false }; - if function.kind() != "member_expression" || member_property(function, source) != Some("subscribe") { + let Some(function) = node.child_by_field_name("function") else { + return false; + }; + if function.kind() != "member_expression" + || member_property(function, source) != Some("subscribe") + { return false; } - let Some(object) = function.child_by_field_name("object") else { return false }; + let Some(object) = function.child_by_field_name("object") else { + return false; + }; matches!(node_text(object, source), "bus" | "eventBus") } fn member_property<'a>(node: Node<'a>, source: &'a str) -> Option<&'a str> { - node.child_by_field_name("property").map(|property| node_text(property, source)) + node.child_by_field_name("property") + .map(|property| node_text(property, source)) } fn first_argument(node: Node<'_>) -> Option> { @@ -1284,7 +1482,10 @@ fn event_values( static_event_values(argument, source).or_else(|| { (argument.kind() == "identifier").then(|| { let name = node_text(argument, source); - mapped_values.get(name).or_else(|| constants.get(name)).cloned() + mapped_values + .get(name) + .or_else(|| constants.get(name)) + .cloned() })? }) } diff --git a/scripts/event-manifest-scanner/src/main.rs b/scripts/event-manifest-scanner/src/main.rs index 76b38b3b82..485a72b735 100644 --- a/scripts/event-manifest-scanner/src/main.rs +++ b/scripts/event-manifest-scanner/src/main.rs @@ -7,7 +7,10 @@ use std::path::PathBuf; fn main() -> Result<()> { let mut args = env::args().skip(1); let mode = args.next().unwrap_or_else(|| "--check".into()); - let root = args.next().map(PathBuf::from).unwrap_or(env::current_dir()?); + let root = args + .next() + .map(PathBuf::from) + .unwrap_or(env::current_dir()?); if args.next().is_some() { bail!("usage: event-manifest-scanner [--check|--write] [repository-root]"); } @@ -15,7 +18,9 @@ fn main() -> Result<()> { let output = root.join("scripts/event-manifest.json"); let rendered = serde_json::to_string_pretty(&build_manifest(&root)?)? + "\n"; match mode.as_str() { - "--write" => fs::write(&output, rendered).with_context(|| format!("write {}", output.display())), + "--write" => { + fs::write(&output, rendered).with_context(|| format!("write {}", output.display())) + } "--check" => { let checked = fs::read_to_string(&output) .with_context(|| format!("read {}; run with --write", output.display()))?; diff --git a/scripts/event-manifest-scanner/tests/scanner.rs b/scripts/event-manifest-scanner/tests/scanner.rs index 4dbde752aa..55c033260b 100644 --- a/scripts/event-manifest-scanner/tests/scanner.rs +++ b/scripts/event-manifest-scanner/tests/scanner.rs @@ -29,7 +29,10 @@ fn resolves_all_required_receiver_and_wrapper_shapes() { "agent:message_queued", "review:update", ] { - assert!(names.iter().any(|name| name == expected), "missing {expected}"); + assert!( + names.iter().any(|name| name == expected), + "missing {expected}" + ); } } @@ -119,11 +122,8 @@ fn resolves_qualified_constants_across_module_files_and_rejects_bare_leaf() { #[test] fn production_census_excludes_test_only_emit_sites() { let root = tempfile::tempdir().expect("temp root"); - std::fs::write( - root.path().join("production.rs"), - "fn no_events() {}", - ) - .expect("production source"); + std::fs::write(root.path().join("production.rs"), "fn no_events() {}") + .expect("production source"); std::fs::create_dir(root.path().join("tests")).expect("tests directory"); std::fs::write( root.path().join("tests/test_only.rs"), @@ -132,7 +132,10 @@ fn production_census_excludes_test_only_emit_sites() { .expect("test source"); let sites = scan_production_rust_tree(root.path()).expect("scan production tree"); - assert!(sites.is_empty(), "test-only emit must not enter the production census"); + assert!( + sites.is_empty(), + "test-only emit must not enter the production census" + ); let error = verify_unmatched_event_coverage(&["agent:run_started"]) .expect_err("test-only classified name remains unmatched"); assert!(error.to_string().contains("no reviewed gap entry")); @@ -182,7 +185,9 @@ fn rejects_dynamic_event_bus_subscriptions() { include_str!("fixtures/dynamic_subscription.ts"), ) .expect_err("dynamic subscription must fail closed"); - assert!(error.to_string().contains("unresolved EventBus subscription")); + assert!(error + .to_string() + .contains("unresolved EventBus subscription")); } #[test] @@ -191,9 +196,7 @@ fn renders_reason_coded_reviewed_unmatched_event_gaps() { assert_eq!(gaps.len(), 11); assert!(gaps.iter().any(|gap| gap.name() == "execution:stderr")); let rendered_value = serde_json::to_value(&gaps).expect("gaps serialize"); - let rendered = rendered_value - .as_array() - .expect("gap list"); + let rendered = rendered_value.as_array().expect("gap list"); assert!(rendered .iter() .all(|gap| gap.get("reason_code").is_some() && gap.get("reason").is_some())); From 0dce9e527b3f86627067914d158744c7196d1147 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:04:49 +0300 Subject: [PATCH 009/416] docs: add remote transport spike findings skeleton --- .../remote-mobile/transport-spike-findings.md | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 docs/handoffs/remote-mobile/transport-spike-findings.md diff --git a/docs/handoffs/remote-mobile/transport-spike-findings.md b/docs/handoffs/remote-mobile/transport-spike-findings.md new file mode 100644 index 0000000000..94b359076e --- /dev/null +++ b/docs/handoffs/remote-mobile/transport-spike-findings.md @@ -0,0 +1,146 @@ +# Remote Mobile Transport Spike — Findings + +> Status: **PENDING — skeleton only.** No experiment result, verdict, or owner decision is recorded by this document yet. +> +> Scope: PR 0.3 of the Remote Multi-Environment plan. This tracked appendix is the evidence record for R-1 and informs PR 1.1's C-15 CORS layer and the mobile transport specification; it is not a transport implementation or a substitute for the source specification. + +## Source contract + +- Desktop: the Rust-proxied flow keeps the WKWebView on local `tauri://` IPC; the bearer and remote HTTP/WS connection stay in the client Rust backend (source spec §§2.1, 6.2). +- Direct mobile/browser: the future remote router must use restrictive CORS and handle `OPTIONS` before bearer authentication so preflight cannot receive a 401 (source spec §3.1; C-15). +- Exposure posture to evaluate: Tailscale Serve terminates TLS at the tailnet edge; direct tailnet uses the tailnet address (source spec §4.4 and R-1). + +## Preconditions and probe record + +| Field | Required record | Status / value | +|---|---|---| +| Host revision | Commit SHA and dirty-tree state used for the probe | Pending | +| Debug harness | Exact debug-only command/listener shape and cfg-gate evidence | Pending — owner decision required before implementation | +| Tailnet access | Logged-in tailnet identity and evidence that Serve is enabled for the host | Pending | +| Serve endpoint | HTTPS/WSS URL used, without pairing codes, bearers, or other secrets | Pending | +| Direct-tailnet endpoint | HTTP/WS URL used, without credentials | Pending | +| Apple probe vehicle | Named macOS `URLSession`/WKWebView harness and, if available, iOS Simulator harness; OS/runtime version | Pending — required before ATS conclusion | +| Browser probe vehicle | Browser/version and origin used for direct-path CORS tests | Pending | +| Evidence storage | Stable tracked artifact links or redacted command output paths | Pending | + +## Evidence index + +| ID | Question | Capture required | Location | Status | +|---|---|---|---|---| +| E-1 | (a) Desktop Rust-proxy traffic boundary | WKWebView network/devtools capture plus Rust-proxy request log | Pending | Pending | +| E-2 | (b) Auth-before-`OPTIONS` failure | Request/response capture proving the preflight status and CORS headers | Pending | Pending | +| E-3 | (b) Pre-auth-`OPTIONS` success | Request/response capture proving restrictive origin behavior and successful preflight | Pending | Pending | +| E-4 | (c) Serve ATS result | Named Apple probe output for HTTPS/WSS through Serve | Pending | Pending | +| E-5 | (c) Direct-tailnet ATS result | Named Apple probe output for plain tailnet HTTP/WS | Pending | Pending | + +## (a) Does the Rust-proxied desktop transport produce zero WKWebView cross-origin traffic? + +### Question + +When the desktop remote-shaped flow is exercised, does the WKWebView issue only local `tauri://` IPC while the Rust proxy owns remote HTTP and WebSocket traffic? + +### Required setup and evidence + +- Record the remote-shaped operation and the local Tauri command it invokes. +- Capture the WKWebView network/devtools view and the Rust-side proxy request/connection log for the same attempt. +- Redact hostnames, device identifiers, pairing codes, and bearer material from retained evidence. + +| Field | Record | +|---|---| +| Probe vehicle and version | Pending | +| WKWebView capture | Pending | +| Rust-proxy capture | Pending | +| Cross-origin `fetch` observed from WKWebView | Pending | +| Cross-origin WebSocket observed from WKWebView | Pending | +| WKWebView preflight observed | Pending | +| Evidence IDs | Pending | +| Finding / verdict | Pending | + +### Implication slots + +- PR 1.1 / C-15 desktop boundary: Pending evidence review. +- Mobile transport specification: Pending; desktop evidence does not answer the direct-client path. + +## (b) Direct browser path: what are the CORS and pre-auth `OPTIONS` ordering results? + +### Question + +Against the debug-only direct-path listener, does auth-before-`OPTIONS` reproduce the required 401-preflight failure, and does pre-auth `OPTIONS` produce the intended restrictive-CORS behavior? + +### Required setup and evidence + +- Record the exact request origin, method, requested headers, and listener configuration for both orderings. +- Record complete status and relevant `Access-Control-*` headers for the failing and working cases. +- Confirm the origin allowlist is restrictive; do not use :3847's `allow_origin(Any)` behavior as the experiment baseline. + +| Field | Auth-before-`OPTIONS` configuration | Pre-auth-`OPTIONS` configuration | +|---|---|---| +| Probe vehicle and version | Pending | Pending | +| Request origin / method / headers | Pending | Pending | +| Listener ordering evidence | Pending | Pending | +| HTTP status | Pending | Pending | +| CORS response headers | Pending | Pending | +| Browser-visible result | Pending | Pending | +| Evidence IDs | Pending | Pending | +| Finding / verdict | Pending | Pending | + +### Implication slots + +- PR 1.1 / C-15 router middleware ordering and restrictive-origin policy: Pending evidence review. +- Mobile transport specification direct-browser behavior: Pending evidence review. + +## (c) ATS: does Serve TLS satisfy Apple-client requirements, and does plain tailnet HTTP need exceptions? + +### Question + +For each named Apple probe vehicle, does HTTPS/WSS through Tailscale Serve work without an ATS exception, and what happens for plain direct-tailnet HTTP/WS? + +### Required setup and evidence + +- Use the named Apple probe vehicle from the preconditions table; do not generalize an observation from an unnamed client. +- Record TLS/certificate observations for Serve and the exact ATS diagnostics for every failed request. +- Keep the result matrix separate by probe vehicle and transport rather than inferring an iOS result from macOS. + +| Probe vehicle | Serve HTTPS/WSS result | Direct-tailnet HTTP/WS result | ATS exception required | Evidence IDs | Finding | +|---|---|---|---|---|---| +| macOS `URLSession` / WKWebView | Pending | Pending | Pending | Pending | Pending | +| iOS Simulator, if available | Pending | Pending | Pending | Pending | Pending | + +### Implication slots + +- PR 1.1 endpoint and CORS implementation: Pending evidence review. +- Mobile transport specification ATS policy and any exception requirement: Pending evidence review. + +## (d) Verdict: does Serve-only suffice? + +### Decision record + +| Field | Record | +|---|---| +| Verdict (`yes` / `no` / `insufficient evidence`) | Pending | +| Rationale linked to E-1 through E-5 | Pending | +| Direct-tailnet posture if Serve-only is not selected | Pending | +| Owner decision required | Pending | +| Decision date / owner | Pending | + +### Downstream implications + +- PR 1.1: Pending — amend or confirm C-15 only after the evidence and owner verdict are recorded. +- Mobile transport specification: Pending — document the selected direct-client transport posture and ATS requirements only after the verdict. + +## Open decisions and follow-up + +| Decision / follow-up | Owner | Needed before | Status | +|---|---|---|---| +| Choose the debug harness shape: command only or command-controlled throwaway listener | Pending | PR 0.3 harness implementation | Open | +| Name the Apple ATS probe vehicle(s) and record availability | Pending | ATS experiment | Open | +| Provide a Serve-capable logged-in tailnet environment | Pending | Serve experiment | Open | +| Record the Serve-only verdict from captured evidence | Pending | PR 0.3 completion; informational input to PR 1.1 | Open | + +## Completion checklist + +- [ ] E-1 through E-5 contain redacted, stable evidence links or output. +- [ ] Questions (a) through (d) each have a recorded finding. +- [ ] The Serve-only verdict is explicit and evidence-linked. +- [ ] PR 1.1 C-15 and the mobile transport specification implication slots are filled without contradicting the source contract. +- [ ] The debug harness is confirmed absent from release registration and no :3847/:3848 routing or binding changed. From 28d8ffcc40cb00283a619f4218583dabffddf85b Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:19:30 +0300 Subject: [PATCH 010/416] feat: add debug transport spike harness --- .../remote-mobile/transport-spike-findings.md | 9 +- src-tauri/src/commands/mod.rs | 4 + src-tauri/src/commands/registry.rs | 4 + .../remote_transport_spike_commands.rs | 28 ++++ .../remote_transport_spike_commands_tests.rs | 11 ++ src-tauri/src/remote_server/mod.rs | 4 + .../src/remote_server/transport_spike.rs | 141 ++++++++++++++++++ .../remote_server/transport_spike_tests.rs | 77 ++++++++++ 8 files changed, 277 insertions(+), 1 deletion(-) create mode 100644 src-tauri/src/commands/remote_transport_spike_commands.rs create mode 100644 src-tauri/src/commands/remote_transport_spike_commands_tests.rs create mode 100644 src-tauri/src/remote_server/transport_spike.rs create mode 100644 src-tauri/src/remote_server/transport_spike_tests.rs diff --git a/docs/handoffs/remote-mobile/transport-spike-findings.md b/docs/handoffs/remote-mobile/transport-spike-findings.md index 94b359076e..7d8aa399fb 100644 --- a/docs/handoffs/remote-mobile/transport-spike-findings.md +++ b/docs/handoffs/remote-mobile/transport-spike-findings.md @@ -15,7 +15,7 @@ | Field | Required record | Status / value | |---|---|---| | Host revision | Commit SHA and dirty-tree state used for the probe | Pending | -| Debug harness | Exact debug-only command/listener shape and cfg-gate evidence | Pending — owner decision required before implementation | +| Debug harness | Exact debug-only command/listener shape and cfg-gate evidence | Implemented: `debug_start_remote_transport_cors_probe` / `debug_stop_remote_transport_cors_probe` control a fixed `127.0.0.1:0` fixture; both command registration and the module are `#[cfg(debug_assertions)]`-gated. This records harness shape only, not an experiment result. | | Tailnet access | Logged-in tailnet identity and evidence that Serve is enabled for the host | Pending | | Serve endpoint | HTTPS/WSS URL used, without pairing codes, bearers, or other secrets | Pending | | Direct-tailnet endpoint | HTTP/WS URL used, without credentials | Pending | @@ -33,6 +33,13 @@ | E-4 | (c) Serve ATS result | Named Apple probe output for HTTPS/WSS through Serve | Pending | Pending | | E-5 | (c) Direct-tailnet ATS result | Named Apple probe output for plain tailnet HTTP/WS | Pending | Pending | +## Implemented harness boundary + +- The debug-only fixture is isolated in `remote_server::transport_spike`; it binds an ephemeral loopback address only and returns that address to the caller. +- It models only the two direct-browser preflight orderings: fixed 401-before-preflight and pre-auth `OPTIONS` with the fixed development origin `http://127.0.0.1:1420`. It accepts no bearer, pairing code, or remote-listener configuration. +- It is absent from release module compilation and Tauri command registration via `#[cfg(debug_assertions)]`. This is cfg-gate evidence, not release-build execution evidence; the release-build verification remains the final PR 0.3 task. +- No desktop, browser, Serve, direct-tailnet, or ATS experiment has been run or concluded by this harness implementation. + ## (a) Does the Rust-proxied desktop transport produce zero WKWebView cross-origin traffic? ### Question diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index aa56f58537..5dbb1b64d8 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -81,6 +81,10 @@ pub mod release_notes_commands; pub mod repository_settings_commands; #[cfg(test)] mod repository_settings_commands_tests; +#[cfg(debug_assertions)] +pub mod remote_transport_spike_commands; +#[cfg(all(test, debug_assertions))] +mod remote_transport_spike_commands_tests; pub mod research_commands; pub mod review_commands; pub mod review_commands_types; diff --git a/src-tauri/src/commands/registry.rs b/src-tauri/src/commands/registry.rs index 0b60dbbf6d..0851349c97 100644 --- a/src-tauri/src/commands/registry.rs +++ b/src-tauri/src/commands/registry.rs @@ -21,6 +21,10 @@ macro_rules! register_tauri_commands { commands::notification_commands::get_unread_notification_count, #[cfg(debug_assertions)] commands::notification_commands::debug_send_test_notification, + #[cfg(debug_assertions)] + commands::remote_transport_spike_commands::debug_start_remote_transport_cors_probe, + #[cfg(debug_assertions)] + commands::remote_transport_spike_commands::debug_stop_remote_transport_cors_probe, commands::release_notes_commands::get_current_release_notes, commands::release_notes_commands::get_last_seen_release_notes_version, commands::release_notes_commands::mark_release_notes_seen, diff --git a/src-tauri/src/commands/remote_transport_spike_commands.rs b/src-tauri/src/commands/remote_transport_spike_commands.rs new file mode 100644 index 0000000000..a12c8b9e38 --- /dev/null +++ b/src-tauri/src/commands/remote_transport_spike_commands.rs @@ -0,0 +1,28 @@ +//! Debug-only commands for the PR 0.3 remote transport CORS probe. + +use serde::Deserialize; + +use crate::remote_server::transport_spike::{ + start_cors_probe_listener, stop_cors_probe_listener, DebugCorsProbeEndpoint, + DebugCorsProbeOrdering, +}; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DebugStartRemoteTransportCorsProbeInput { + pub ordering: DebugCorsProbeOrdering, +} + +/// Starts a loopback-only fixture for the documented direct-browser CORS ordering probe. +#[tauri::command] +pub async fn debug_start_remote_transport_cors_probe( + input: DebugStartRemoteTransportCorsProbeInput, +) -> Result { + start_cors_probe_listener(input.ordering).await +} + +/// Stops the loopback-only fixture started by `debug_start_remote_transport_cors_probe`. +#[tauri::command] +pub fn debug_stop_remote_transport_cors_probe() -> Result { + stop_cors_probe_listener() +} diff --git a/src-tauri/src/commands/remote_transport_spike_commands_tests.rs b/src-tauri/src/commands/remote_transport_spike_commands_tests.rs new file mode 100644 index 0000000000..e1200c4793 --- /dev/null +++ b/src-tauri/src/commands/remote_transport_spike_commands_tests.rs @@ -0,0 +1,11 @@ +use super::remote_transport_spike_commands::DebugStartRemoteTransportCorsProbeInput; +use crate::remote_server::transport_spike::DebugCorsProbeOrdering; + +#[test] +fn start_probe_input_deserializes_the_camel_case_ordering() { + let input: DebugStartRemoteTransportCorsProbeInput = + serde_json::from_str(r#"{"ordering":"authBeforeOptions"}"#) + .expect("Tauri command input should deserialize"); + + assert_eq!(input.ordering, DebugCorsProbeOrdering::AuthBeforeOptions); +} diff --git a/src-tauri/src/remote_server/mod.rs b/src-tauri/src/remote_server/mod.rs index 8833300b0b..d66a0eb982 100644 --- a/src-tauri/src/remote_server/mod.rs +++ b/src-tauri/src/remote_server/mod.rs @@ -1 +1,5 @@ pub mod capture; +#[cfg(debug_assertions)] +pub mod transport_spike; +#[cfg(all(test, debug_assertions))] +mod transport_spike_tests; diff --git a/src-tauri/src/remote_server/transport_spike.rs b/src-tauri/src/remote_server/transport_spike.rs new file mode 100644 index 0000000000..f2869956bd --- /dev/null +++ b/src-tauri/src/remote_server/transport_spike.rs @@ -0,0 +1,141 @@ +//! Debug-only loopback fixture for the PR 0.3 direct-browser CORS probe. +//! +//! This fixture intentionally models ordering only. It has no remote-listener routes, credentials, +//! pairing state, or production auth behavior. + +use axum::{ + http::{header, HeaderValue, StatusCode}, + response::{IntoResponse, Response}, + routing::options, + Router, +}; +use serde::{Deserialize, Serialize}; +use std::sync::{Mutex, OnceLock}; +use tokio::net::TcpListener; +use tokio_util::sync::CancellationToken; + +/// Fixed local development origin used by the browser CORS ordering probe. +pub const DEBUG_CORS_PROBE_ORIGIN: &str = "http://127.0.0.1:1420"; +const LOOPBACK_EPHEMERAL_BIND_ADDR: &str = "127.0.0.1:0"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum DebugCorsProbeOrdering { + AuthBeforeOptions, + OptionsBeforeAuth, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DebugCorsProbeEndpoint { + pub base_url: String, + pub ordering: DebugCorsProbeOrdering, +} + +struct DebugCorsProbeListener { + shutdown: CancellationToken, +} + +static ACTIVE_CORS_PROBE: OnceLock>> = OnceLock::new(); + +fn active_cors_probe() -> &'static Mutex> { + ACTIVE_CORS_PROBE.get_or_init(|| Mutex::new(None)) +} + +/// Starts a loopback-only, ephemeral CORS ordering fixture for manual browser probes. +/// +/// The caller may use the returned endpoint only with `DEBUG_CORS_PROBE_ORIGIN`; it models a +/// preflight ordering failure/success without accepting bearer or pairing credentials. +/// +/// # Errors +/// +/// Returns an error when the OS cannot bind a loopback socket or the debug listener state is +/// poisoned. +pub async fn start_cors_probe_listener( + ordering: DebugCorsProbeOrdering, +) -> Result { + let listener = TcpListener::bind(LOOPBACK_EPHEMERAL_BIND_ADDR) + .await + .map_err(|error| format!("Failed to bind debug CORS probe listener: {error}"))?; + let address = listener + .local_addr() + .map_err(|error| format!("Failed to read debug CORS probe listener address: {error}"))?; + let shutdown = CancellationToken::new(); + let server_shutdown = shutdown.clone(); + + tauri::async_runtime::spawn(async move { + if let Err(error) = axum::serve(listener, cors_probe_router(ordering)) + .with_graceful_shutdown(server_shutdown.cancelled_owned()) + .await + { + tracing::warn!(?error, "debug CORS probe listener stopped unexpectedly"); + } + }); + + let previous = { + let mut active = active_cors_probe() + .lock() + .map_err(|_| "Debug CORS probe listener state is unavailable".to_string())?; + active.replace(DebugCorsProbeListener { shutdown }) + }; + if let Some(previous) = previous { + previous.shutdown.cancel(); + } + + Ok(DebugCorsProbeEndpoint { + base_url: format!("http://{address}"), + ordering, + }) +} + +/// Stops the current debug CORS probe listener, if one is running. +pub fn stop_cors_probe_listener() -> Result { + let listener = active_cors_probe() + .lock() + .map_err(|_| "Debug CORS probe listener state is unavailable".to_string())? + .take(); + if let Some(listener) = listener { + listener.shutdown.cancel(); + return Ok(true); + } + Ok(false) +} + +pub(crate) fn cors_probe_router(ordering: DebugCorsProbeOrdering) -> Router { + match ordering { + DebugCorsProbeOrdering::AuthBeforeOptions => { + Router::new().fallback(unauthorized_probe_response) + } + DebugCorsProbeOrdering::OptionsBeforeAuth => Router::new().route( + "/*path", + options(preflight_probe_response).fallback(unauthorized_probe_response), + ), + } +} + +async fn unauthorized_probe_response() -> StatusCode { + // Deliberately fixed: this is a CORS-ordering fixture, not Phase 1 authentication. + StatusCode::UNAUTHORIZED +} + +async fn preflight_probe_response() -> Response { + ( + StatusCode::NO_CONTENT, + [ + ( + header::ACCESS_CONTROL_ALLOW_ORIGIN, + HeaderValue::from_static(DEBUG_CORS_PROBE_ORIGIN), + ), + ( + header::ACCESS_CONTROL_ALLOW_METHODS, + HeaderValue::from_static("POST"), + ), + ( + header::ACCESS_CONTROL_ALLOW_HEADERS, + HeaderValue::from_static("authorization,content-type"), + ), + (header::VARY, HeaderValue::from_static("Origin")), + ], + ) + .into_response() +} diff --git a/src-tauri/src/remote_server/transport_spike_tests.rs b/src-tauri/src/remote_server/transport_spike_tests.rs new file mode 100644 index 0000000000..5f1216be21 --- /dev/null +++ b/src-tauri/src/remote_server/transport_spike_tests.rs @@ -0,0 +1,77 @@ +use axum::{ + body::Body, + http::{header, Method, Request, StatusCode}, +}; +use tower::ServiceExt; + +use super::transport_spike::{cors_probe_router, DebugCorsProbeOrdering, DEBUG_CORS_PROBE_ORIGIN}; + +fn preflight_request(origin: &'static str) -> Request { + Request::builder() + .method(Method::OPTIONS) + .uri("/remote/v1/invoke") + .header(header::ORIGIN, origin) + .header(header::ACCESS_CONTROL_REQUEST_METHOD, Method::POST.as_str()) + .header( + header::ACCESS_CONTROL_REQUEST_HEADERS, + "authorization,content-type", + ) + .body(Body::empty()) + .expect("preflight request should be valid") +} + +#[tokio::test] +async fn auth_before_options_rejects_preflight_without_cors_headers() { + let response = cors_probe_router(DebugCorsProbeOrdering::AuthBeforeOptions) + .oneshot(preflight_request(DEBUG_CORS_PROBE_ORIGIN)) + .await + .expect("probe router should respond"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert!(response + .headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .is_none()); +} + +#[tokio::test] +async fn options_before_auth_returns_a_restrictive_preflight_response() { + let response = cors_probe_router(DebugCorsProbeOrdering::OptionsBeforeAuth) + .oneshot(preflight_request(DEBUG_CORS_PROBE_ORIGIN)) + .await + .expect("probe router should respond"); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert_eq!( + response + .headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .expect("preflight should name the fixed development origin"), + DEBUG_CORS_PROBE_ORIGIN + ); + assert_eq!( + response + .headers() + .get(header::ACCESS_CONTROL_ALLOW_METHODS) + .expect("preflight should permit the probe method"), + "POST" + ); +} + +#[tokio::test] +async fn options_before_auth_never_reflects_an_unlisted_origin() { + let unlisted_origin = "https://unlisted.example"; + let response = cors_probe_router(DebugCorsProbeOrdering::OptionsBeforeAuth) + .oneshot(preflight_request(unlisted_origin)) + .await + .expect("probe router should respond"); + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert_ne!( + response + .headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .expect("preflight should retain the fixed allowlist"), + unlisted_origin + ); +} From 2ae9bab77f4a5796dcdae590c307b2d16f009cff Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:27:18 +0300 Subject: [PATCH 011/416] fix: restrict debug transport probe origins --- src-tauri/src/commands/mod.rs | 2 - .../remote_transport_spike_commands_tests.rs | 11 ----- .../src/remote_server/transport_spike.rs | 11 ++++- .../remote_server/transport_spike_tests.rs | 41 ++++++++++++++----- 4 files changed, 40 insertions(+), 25 deletions(-) delete mode 100644 src-tauri/src/commands/remote_transport_spike_commands_tests.rs diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 5dbb1b64d8..9aeb9b226b 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -83,8 +83,6 @@ pub mod repository_settings_commands; mod repository_settings_commands_tests; #[cfg(debug_assertions)] pub mod remote_transport_spike_commands; -#[cfg(all(test, debug_assertions))] -mod remote_transport_spike_commands_tests; pub mod research_commands; pub mod review_commands; pub mod review_commands_types; diff --git a/src-tauri/src/commands/remote_transport_spike_commands_tests.rs b/src-tauri/src/commands/remote_transport_spike_commands_tests.rs deleted file mode 100644 index e1200c4793..0000000000 --- a/src-tauri/src/commands/remote_transport_spike_commands_tests.rs +++ /dev/null @@ -1,11 +0,0 @@ -use super::remote_transport_spike_commands::DebugStartRemoteTransportCorsProbeInput; -use crate::remote_server::transport_spike::DebugCorsProbeOrdering; - -#[test] -fn start_probe_input_deserializes_the_camel_case_ordering() { - let input: DebugStartRemoteTransportCorsProbeInput = - serde_json::from_str(r#"{"ordering":"authBeforeOptions"}"#) - .expect("Tauri command input should deserialize"); - - assert_eq!(input.ordering, DebugCorsProbeOrdering::AuthBeforeOptions); -} diff --git a/src-tauri/src/remote_server/transport_spike.rs b/src-tauri/src/remote_server/transport_spike.rs index f2869956bd..5a97170ce9 100644 --- a/src-tauri/src/remote_server/transport_spike.rs +++ b/src-tauri/src/remote_server/transport_spike.rs @@ -4,7 +4,7 @@ //! pairing state, or production auth behavior. use axum::{ - http::{header, HeaderValue, StatusCode}, + http::{header, HeaderMap, HeaderValue, StatusCode}, response::{IntoResponse, Response}, routing::options, Router, @@ -118,7 +118,14 @@ async fn unauthorized_probe_response() -> StatusCode { StatusCode::UNAUTHORIZED } -async fn preflight_probe_response() -> Response { +async fn preflight_probe_response(headers: HeaderMap) -> Response { + let Some(origin) = headers.get(header::ORIGIN) else { + return StatusCode::FORBIDDEN.into_response(); + }; + if origin.as_bytes() != DEBUG_CORS_PROBE_ORIGIN.as_bytes() { + return StatusCode::FORBIDDEN.into_response(); + } + ( StatusCode::NO_CONTENT, [ diff --git a/src-tauri/src/remote_server/transport_spike_tests.rs b/src-tauri/src/remote_server/transport_spike_tests.rs index 5f1216be21..de8c1bc265 100644 --- a/src-tauri/src/remote_server/transport_spike_tests.rs +++ b/src-tauri/src/remote_server/transport_spike_tests.rs @@ -2,9 +2,13 @@ use axum::{ body::Body, http::{header, Method, Request, StatusCode}, }; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use tower::ServiceExt; -use super::transport_spike::{cors_probe_router, DebugCorsProbeOrdering, DEBUG_CORS_PROBE_ORIGIN}; +use super::transport_spike::{ + cors_probe_router, start_cors_probe_listener, stop_cors_probe_listener, DebugCorsProbeOrdering, + DEBUG_CORS_PROBE_ORIGIN, +}; fn preflight_request(origin: &'static str) -> Request { Request::builder() @@ -59,19 +63,36 @@ async fn options_before_auth_returns_a_restrictive_preflight_response() { } #[tokio::test] -async fn options_before_auth_never_reflects_an_unlisted_origin() { +async fn options_before_auth_rejects_an_unlisted_origin_without_cors_headers() { let unlisted_origin = "https://unlisted.example"; let response = cors_probe_router(DebugCorsProbeOrdering::OptionsBeforeAuth) .oneshot(preflight_request(unlisted_origin)) .await .expect("probe router should respond"); - assert_eq!(response.status(), StatusCode::NO_CONTENT); - assert_ne!( - response - .headers() - .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) - .expect("preflight should retain the fixed allowlist"), - unlisted_origin - ); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert!(response + .headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .is_none()); +} + +#[tokio::test] +async fn loopback_probe_binds_an_ephemeral_ipv4_port_and_stops_once() { + let endpoint = start_cors_probe_listener(DebugCorsProbeOrdering::OptionsBeforeAuth) + .await + .expect("loopback probe should bind"); + let address: SocketAddr = endpoint + .base_url + .strip_prefix("http://") + .expect("endpoint should use HTTP") + .parse() + .expect("endpoint should contain a socket address"); + let stopped = stop_cors_probe_listener().expect("probe listener should stop"); + let stopped_again = stop_cors_probe_listener().expect("stop should be idempotent"); + + assert_eq!(address.ip(), IpAddr::V4(Ipv4Addr::LOCALHOST)); + assert_ne!(address.port(), 0); + assert!(stopped); + assert!(!stopped_again); } From d1cf778e1d3ebdb37b7d2e5d5db1d64290430458 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:44:32 +0300 Subject: [PATCH 012/416] feat: add desktop transport proxy spike --- .../remote-mobile/transport-spike-findings.md | 7 ++ src-tauri/src/commands/registry.rs | 2 + .../remote_transport_spike_commands.rs | 18 +++- .../src/remote_server/transport_spike.rs | 84 ++++++++++++++++++- .../remote_server/transport_spike_tests.rs | 35 +++++++- 5 files changed, 139 insertions(+), 7 deletions(-) diff --git a/docs/handoffs/remote-mobile/transport-spike-findings.md b/docs/handoffs/remote-mobile/transport-spike-findings.md index 7d8aa399fb..f47fffebc6 100644 --- a/docs/handoffs/remote-mobile/transport-spike-findings.md +++ b/docs/handoffs/remote-mobile/transport-spike-findings.md @@ -40,6 +40,13 @@ - It is absent from release module compilation and Tauri command registration via `#[cfg(debug_assertions)]`. This is cfg-gate evidence, not release-build execution evidence; the release-build verification remains the final PR 0.3 task. - No desktop, browser, Serve, direct-tailnet, or ATS experiment has been run or concluded by this harness implementation. +## Desktop proxy-stub code evidence (not a WKWebView capture) + +- `debug_run_desktop_proxy_stub` is a debug-only Tauri command: a webview invokes local IPC, while its Rust implementation starts the fixture and makes the fixed `POST /remote/v1/invoke` request itself. +- The stub obtains the request target from its own `127.0.0.1:0` listener bind; command input selects only the two fixture orderings. It accepts no URL, host, bearer, pairing code, or caller-provided path. +- The sibling behavioral test `desktop_proxy_command_uses_the_loopback_fixture_and_reports_its_result` asserts the loopback-only result schema and the Rust-observed fixture response. This is code/test evidence of the intended boundary, not evidence of actual WKWebView network behavior. +- E-1 and question (a)'s verdict remain pending until a native WKWebView/devtools capture is collected; no such capture was performed in this task. + ## (a) Does the Rust-proxied desktop transport produce zero WKWebView cross-origin traffic? ### Question diff --git a/src-tauri/src/commands/registry.rs b/src-tauri/src/commands/registry.rs index 0851349c97..0b98ffbdaf 100644 --- a/src-tauri/src/commands/registry.rs +++ b/src-tauri/src/commands/registry.rs @@ -25,6 +25,8 @@ macro_rules! register_tauri_commands { commands::remote_transport_spike_commands::debug_start_remote_transport_cors_probe, #[cfg(debug_assertions)] commands::remote_transport_spike_commands::debug_stop_remote_transport_cors_probe, + #[cfg(debug_assertions)] + commands::remote_transport_spike_commands::debug_run_desktop_proxy_stub, commands::release_notes_commands::get_current_release_notes, commands::release_notes_commands::get_last_seen_release_notes_version, commands::release_notes_commands::mark_release_notes_seen, diff --git a/src-tauri/src/commands/remote_transport_spike_commands.rs b/src-tauri/src/commands/remote_transport_spike_commands.rs index a12c8b9e38..4e64b061ad 100644 --- a/src-tauri/src/commands/remote_transport_spike_commands.rs +++ b/src-tauri/src/commands/remote_transport_spike_commands.rs @@ -3,8 +3,8 @@ use serde::Deserialize; use crate::remote_server::transport_spike::{ - start_cors_probe_listener, stop_cors_probe_listener, DebugCorsProbeEndpoint, - DebugCorsProbeOrdering, + run_desktop_proxy_stub, start_cors_probe_listener, stop_cors_probe_listener, + DebugCorsProbeEndpoint, DebugCorsProbeOrdering, DebugDesktopProxyStubResult, }; #[derive(Debug, Deserialize)] @@ -13,6 +13,12 @@ pub struct DebugStartRemoteTransportCorsProbeInput { pub ordering: DebugCorsProbeOrdering, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DebugDesktopProxyStubInput { + pub ordering: DebugCorsProbeOrdering, +} + /// Starts a loopback-only fixture for the documented direct-browser CORS ordering probe. #[tauri::command] pub async fn debug_start_remote_transport_cors_probe( @@ -26,3 +32,11 @@ pub async fn debug_start_remote_transport_cors_probe( pub fn debug_stop_remote_transport_cors_probe() -> Result { stop_cors_probe_listener() } + +/// Runs a fixed local-IPC-to-Rust-to-loopback desktop transport probe. +#[tauri::command] +pub async fn debug_run_desktop_proxy_stub( + input: DebugDesktopProxyStubInput, +) -> Result { + run_desktop_proxy_stub(input.ordering).await +} diff --git a/src-tauri/src/remote_server/transport_spike.rs b/src-tauri/src/remote_server/transport_spike.rs index 5a97170ce9..d150793c8c 100644 --- a/src-tauri/src/remote_server/transport_spike.rs +++ b/src-tauri/src/remote_server/transport_spike.rs @@ -11,12 +11,14 @@ use axum::{ }; use serde::{Deserialize, Serialize}; use std::sync::{Mutex, OnceLock}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpListener; use tokio_util::sync::CancellationToken; /// Fixed local development origin used by the browser CORS ordering probe. pub const DEBUG_CORS_PROBE_ORIGIN: &str = "http://127.0.0.1:1420"; const LOOPBACK_EPHEMERAL_BIND_ADDR: &str = "127.0.0.1:0"; +const DEBUG_PROXY_REQUEST_PATH: &str = "/remote/v1/invoke"; #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] @@ -32,6 +34,15 @@ pub struct DebugCorsProbeEndpoint { pub ordering: DebugCorsProbeOrdering, } +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DebugDesktopProxyStubResult { + pub fixture_base_url: String, + pub request_path: &'static str, + pub status_code: u16, + pub transport: &'static str, +} + struct DebugCorsProbeListener { shutdown: CancellationToken, } @@ -54,6 +65,12 @@ fn active_cors_probe() -> &'static Mutex> { pub async fn start_cors_probe_listener( ordering: DebugCorsProbeOrdering, ) -> Result { + Ok(start_cors_probe_listener_with_address(ordering).await?.0) +} + +async fn start_cors_probe_listener_with_address( + ordering: DebugCorsProbeOrdering, +) -> Result<(DebugCorsProbeEndpoint, std::net::SocketAddr), String> { let listener = TcpListener::bind(LOOPBACK_EPHEMERAL_BIND_ADDR) .await .map_err(|error| format!("Failed to bind debug CORS probe listener: {error}"))?; @@ -82,10 +99,13 @@ pub async fn start_cors_probe_listener( previous.shutdown.cancel(); } - Ok(DebugCorsProbeEndpoint { - base_url: format!("http://{address}"), - ordering, - }) + Ok(( + DebugCorsProbeEndpoint { + base_url: format!("http://{address}"), + ordering, + }, + address, + )) } /// Stops the current debug CORS probe listener, if one is running. @@ -101,6 +121,62 @@ pub fn stop_cors_probe_listener() -> Result { Ok(false) } +/// Performs a fixed remote-shaped HTTP request from the Rust side of the debug fixture. +/// +/// This models the desktop transport boundary only: the target is the fixture's own loopback +/// address, and no caller can provide a host, bearer, pairing code, or request path. +/// +/// # Errors +/// +/// Returns an error if the fixture cannot bind, the Rust-side request cannot complete, or the +/// fixture cannot be stopped afterward. +pub async fn run_desktop_proxy_stub( + ordering: DebugCorsProbeOrdering, +) -> Result { + let (endpoint, address) = start_cors_probe_listener_with_address(ordering).await?; + let request_result = request_loopback_probe(address).await; + let stop_result = stop_cors_probe_listener(); + + let status_code = request_result?; + if !stop_result? { + return Err("Debug CORS probe listener was not active after the proxy request".to_string()); + } + + Ok(DebugDesktopProxyStubResult { + fixture_base_url: endpoint.base_url, + request_path: DEBUG_PROXY_REQUEST_PATH, + status_code, + transport: "rustLoopbackHttp", + }) +} + +async fn request_loopback_probe(address: std::net::SocketAddr) -> Result { + let mut stream = tokio::net::TcpStream::connect(address) + .await + .map_err(|error| { + format!("Debug desktop proxy could not reach loopback fixture: {error}") + })?; + let request = format!( + "POST {DEBUG_PROXY_REQUEST_PATH} HTTP/1.0\r\nHost: {address}\r\nContent-Length: 0\r\n\r\n" + ); + stream + .write_all(request.as_bytes()) + .await + .map_err(|error| { + format!("Debug desktop proxy could not write to loopback fixture: {error}") + })?; + + let mut response = [0_u8; 1024]; + let read = stream.read(&mut response).await.map_err(|error| { + format!("Debug desktop proxy could not read loopback response: {error}") + })?; + std::str::from_utf8(&response[..read]) + .ok() + .and_then(|response| response.split_whitespace().nth(1)) + .and_then(|status| status.parse::().ok()) + .ok_or_else(|| "Debug desktop proxy received an invalid loopback HTTP response".to_string()) +} + pub(crate) fn cors_probe_router(ordering: DebugCorsProbeOrdering) -> Router { match ordering { DebugCorsProbeOrdering::AuthBeforeOptions => { diff --git a/src-tauri/src/remote_server/transport_spike_tests.rs b/src-tauri/src/remote_server/transport_spike_tests.rs index de8c1bc265..442ed18537 100644 --- a/src-tauri/src/remote_server/transport_spike_tests.rs +++ b/src-tauri/src/remote_server/transport_spike_tests.rs @@ -2,13 +2,26 @@ use axum::{ body::Body, http::{header, Method, Request, StatusCode}, }; -use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::{ + net::{IpAddr, Ipv4Addr, SocketAddr}, + sync::OnceLock, +}; +use tokio::sync::Mutex; use tower::ServiceExt; use super::transport_spike::{ cors_probe_router, start_cors_probe_listener, stop_cors_probe_listener, DebugCorsProbeOrdering, DEBUG_CORS_PROBE_ORIGIN, }; +use crate::commands::remote_transport_spike_commands::{ + debug_run_desktop_proxy_stub, DebugDesktopProxyStubInput, +}; + +static LISTENER_TEST_LOCK: OnceLock> = OnceLock::new(); + +fn listener_test_lock() -> &'static Mutex<()> { + LISTENER_TEST_LOCK.get_or_init(|| Mutex::new(())) +} fn preflight_request(origin: &'static str) -> Request { Request::builder() @@ -79,6 +92,7 @@ async fn options_before_auth_rejects_an_unlisted_origin_without_cors_headers() { #[tokio::test] async fn loopback_probe_binds_an_ephemeral_ipv4_port_and_stops_once() { + let _guard = listener_test_lock().lock().await; let endpoint = start_cors_probe_listener(DebugCorsProbeOrdering::OptionsBeforeAuth) .await .expect("loopback probe should bind"); @@ -96,3 +110,22 @@ async fn loopback_probe_binds_an_ephemeral_ipv4_port_and_stops_once() { assert!(stopped); assert!(!stopped_again); } + +#[tokio::test] +async fn desktop_proxy_command_uses_the_loopback_fixture_and_reports_its_result() { + let _guard = listener_test_lock().lock().await; + let result = debug_run_desktop_proxy_stub(DebugDesktopProxyStubInput { + ordering: DebugCorsProbeOrdering::OptionsBeforeAuth, + }) + .await + .expect("desktop proxy command should reach the fixture"); + + assert!(result.fixture_base_url.starts_with("http://127.0.0.1:")); + assert_eq!(result.request_path, "/remote/v1/invoke"); + assert_eq!(result.status_code, StatusCode::UNAUTHORIZED.as_u16()); + assert_eq!(result.transport, "rustLoopbackHttp"); + let serialized = serde_json::to_value(&result).expect("result should serialize for Tauri IPC"); + assert_eq!(serialized["requestPath"], "/remote/v1/invoke"); + assert_eq!(serialized["statusCode"], StatusCode::UNAUTHORIZED.as_u16()); + assert_eq!(serialized["transport"], "rustLoopbackHttp"); +} From 4bc32247ef593d25606f2be3b0fca9671e194d48 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:58:07 +0300 Subject: [PATCH 013/416] test: exercise listener CORS preflight --- .../remote-mobile/transport-spike-findings.md | 31 +++-- .../remote_server/transport_spike_tests.rs | 122 ++++++++++++++++++ 2 files changed, 142 insertions(+), 11 deletions(-) diff --git a/docs/handoffs/remote-mobile/transport-spike-findings.md b/docs/handoffs/remote-mobile/transport-spike-findings.md index f47fffebc6..722add5bdf 100644 --- a/docs/handoffs/remote-mobile/transport-spike-findings.md +++ b/docs/handoffs/remote-mobile/transport-spike-findings.md @@ -28,8 +28,8 @@ | ID | Question | Capture required | Location | Status | |---|---|---|---|---| | E-1 | (a) Desktop Rust-proxy traffic boundary | WKWebView network/devtools capture plus Rust-proxy request log | Pending | Pending | -| E-2 | (b) Auth-before-`OPTIONS` failure | Request/response capture proving the preflight status and CORS headers | Pending | Pending | -| E-3 | (b) Pre-auth-`OPTIONS` success | Request/response capture proving restrictive origin behavior and successful preflight | Pending | Pending | +| E-2 | (b) Auth-before-`OPTIONS` failure | Actual loopback socket request/response | `actual_listener_auth_before_options_returns_401_without_cors_headers` | Captured — Rust socket evidence, not a browser capture | +| E-3 | (b) Pre-auth-`OPTIONS` success and restrictive rejection | Actual loopback socket request/response | `actual_listener_options_before_auth_returns_restrictive_cors_for_allowed_origin`; `actual_listener_options_before_auth_denies_an_unlisted_origin_without_cors_headers` | Captured — Rust socket evidence, not a browser capture | | E-4 | (c) Serve ATS result | Named Apple probe output for HTTPS/WSS through Serve | Pending | Pending | | E-5 | (c) Direct-tailnet ATS result | Named Apple probe output for plain tailnet HTTP/WS | Pending | Pending | @@ -47,6 +47,15 @@ - The sibling behavioral test `desktop_proxy_command_uses_the_loopback_fixture_and_reports_its_result` asserts the loopback-only result schema and the Rust-observed fixture response. This is code/test evidence of the intended boundary, not evidence of actual WKWebView network behavior. - E-1 and question (a)'s verdict remain pending until a native WKWebView/devtools capture is collected; no such capture was performed in this task. +## E-2 / E-3 actual listener socket evidence (not a browser capture) + +- Probe vehicle: the focused Rust sibling tests named in the evidence index use `tokio::net::TcpStream` against the command's actual ephemeral `127.0.0.1:0` listener. They do not call `Router::oneshot` for this evidence. +- Request shape: `OPTIONS /remote/v1/invoke` with `Origin`, `Access-Control-Request-Method: POST`, and `Access-Control-Request-Headers: authorization,content-type`. No bearer, pairing code, or remote host is supplied. +- E-2: with `AuthBeforeOptions`, the actual listener returns `401 Unauthorized` and emits no `Access-Control-Allow-Origin`, `Access-Control-Allow-Methods`, or `Access-Control-Allow-Headers` header. +- E-3 allowed origin: with `OptionsBeforeAuth` and `Origin: http://127.0.0.1:1420`, the actual listener returns `204 No Content` with `Access-Control-Allow-Origin` set only to that origin, methods `POST`, headers `authorization,content-type`, and `Vary: Origin`. +- E-3 unlisted origin: with `OptionsBeforeAuth` and `Origin: https://unlisted.example`, the actual listener returns `403 Forbidden` and emits no CORS allow headers. +- These are listener/socket assertions recorded by the focused Rust test suite. No browser page, browser network inspector, or WKWebView capture was run, so browser-visible results remain pending. + ## (a) Does the Rust-proxied desktop transport produce zero WKWebView cross-origin traffic? ### Question @@ -89,19 +98,19 @@ Against the debug-only direct-path listener, does auth-before-`OPTIONS` reproduc | Field | Auth-before-`OPTIONS` configuration | Pre-auth-`OPTIONS` configuration | |---|---|---| -| Probe vehicle and version | Pending | Pending | -| Request origin / method / headers | Pending | Pending | -| Listener ordering evidence | Pending | Pending | -| HTTP status | Pending | Pending | -| CORS response headers | Pending | Pending | +| Probe vehicle and version | Focused Rust `tokio::net::TcpStream` sibling test (not a browser) | Focused Rust `tokio::net::TcpStream` sibling tests (not a browser) | +| Request origin / method / headers | `OPTIONS /remote/v1/invoke`; `Origin: http://127.0.0.1:1420`; requested `POST`, `authorization,content-type` | Allowed case: same fixed origin/request; rejected case: `Origin: https://unlisted.example`; same requested method/headers | +| Listener ordering evidence | `DebugCorsProbeOrdering::AuthBeforeOptions` on actual ephemeral loopback listener | `DebugCorsProbeOrdering::OptionsBeforeAuth` on actual ephemeral loopback listener | +| HTTP status | `401 Unauthorized` | Allowed: `204 No Content`; unlisted: `403 Forbidden` | +| CORS response headers | No allow-origin/methods/headers | Allowed: fixed allow-origin, `POST`, `authorization,content-type`, `Vary: Origin`; unlisted: no allow headers | | Browser-visible result | Pending | Pending | -| Evidence IDs | Pending | Pending | -| Finding / verdict | Pending | Pending | +| Evidence IDs | E-2 — actual socket test named above | E-3 — actual socket tests named above | +| Finding / verdict | The modeled auth-before-OPTIONS ordering reproduces the required preflight-401 failure. Browser result remains pending. | The modeled pre-auth OPTIONS ordering has restrictive success for only the fixed origin and rejects an unlisted origin. Browser result remains pending. | ### Implication slots -- PR 1.1 / C-15 router middleware ordering and restrictive-origin policy: Pending evidence review. -- Mobile transport specification direct-browser behavior: Pending evidence review. +- PR 1.1 / C-15 router middleware ordering and restrictive-origin policy: the actual-listener socket evidence supports pre-auth `OPTIONS` and a fixed allowlist; browser evidence is still pending. +- Mobile transport specification direct-browser behavior: Pending browser evidence review; the socket fixture is not a mobile/browser observation. ## (c) ATS: does Serve TLS satisfy Apple-client requirements, and does plain tailnet HTTP need exceptions? diff --git a/src-tauri/src/remote_server/transport_spike_tests.rs b/src-tauri/src/remote_server/transport_spike_tests.rs index 442ed18537..0d726795eb 100644 --- a/src-tauri/src/remote_server/transport_spike_tests.rs +++ b/src-tauri/src/remote_server/transport_spike_tests.rs @@ -6,6 +6,7 @@ use std::{ net::{IpAddr, Ipv4Addr, SocketAddr}, sync::OnceLock, }; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::sync::Mutex; use tower::ServiceExt; @@ -37,6 +38,72 @@ fn preflight_request(origin: &'static str) -> Request { .expect("preflight request should be valid") } +struct SocketPreflightResponse { + status: StatusCode, + allow_origin: Option, + allow_methods: Option, + allow_headers: Option, + vary: Option, +} + +async fn send_preflight_to_listener( + endpoint: &super::transport_spike::DebugCorsProbeEndpoint, + origin: &str, +) -> SocketPreflightResponse { + let address: SocketAddr = endpoint + .base_url + .strip_prefix("http://") + .expect("endpoint should use HTTP") + .parse() + .expect("endpoint should contain a socket address"); + let mut stream = tokio::net::TcpStream::connect(address) + .await + .expect("ephemeral probe listener should accept loopback connections"); + let request = format!( + "OPTIONS /remote/v1/invoke HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\nOrigin: {origin}\r\nAccess-Control-Request-Method: POST\r\nAccess-Control-Request-Headers: authorization,content-type\r\n\r\n" + ); + stream + .write_all(request.as_bytes()) + .await + .expect("socket preflight should reach the listener"); + + let mut response = Vec::new(); + stream + .read_to_end(&mut response) + .await + .expect("socket preflight should receive a complete HTTP response"); + let response = std::str::from_utf8(&response).expect("response should be valid HTTP text"); + let mut lines = response.split("\r\n"); + let status = lines + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .and_then(|status| status.parse::().ok()) + .and_then(|status| StatusCode::from_u16(status).ok()) + .expect("response should carry a valid HTTP status"); + let mut parsed = SocketPreflightResponse { + status, + allow_origin: None, + allow_methods: None, + allow_headers: None, + vary: None, + }; + for line in lines.take_while(|line| !line.is_empty()) { + let Some((name, value)) = line.split_once(':') else { + continue; + }; + let value = value.trim().to_string(); + match name.to_ascii_lowercase().as_str() { + "access-control-allow-origin" => parsed.allow_origin = Some(value), + "access-control-allow-methods" => parsed.allow_methods = Some(value), + "access-control-allow-headers" => parsed.allow_headers = Some(value), + "vary" => parsed.vary = Some(value), + _ => {} + } + } + + parsed +} + #[tokio::test] async fn auth_before_options_rejects_preflight_without_cors_headers() { let response = cors_probe_router(DebugCorsProbeOrdering::AuthBeforeOptions) @@ -129,3 +196,58 @@ async fn desktop_proxy_command_uses_the_loopback_fixture_and_reports_its_result( assert_eq!(serialized["statusCode"], StatusCode::UNAUTHORIZED.as_u16()); assert_eq!(serialized["transport"], "rustLoopbackHttp"); } + +#[tokio::test] +async fn actual_listener_auth_before_options_returns_401_without_cors_headers() { + let _guard = listener_test_lock().lock().await; + let endpoint = start_cors_probe_listener(DebugCorsProbeOrdering::AuthBeforeOptions) + .await + .expect("auth-before-options listener should bind"); + let response = send_preflight_to_listener(&endpoint, DEBUG_CORS_PROBE_ORIGIN).await; + let stopped = stop_cors_probe_listener().expect("probe listener should stop"); + + assert_eq!(response.status, StatusCode::UNAUTHORIZED); + assert!(response.allow_origin.is_none()); + assert!(response.allow_methods.is_none()); + assert!(response.allow_headers.is_none()); + assert!(stopped); +} + +#[tokio::test] +async fn actual_listener_options_before_auth_returns_restrictive_cors_for_allowed_origin() { + let _guard = listener_test_lock().lock().await; + let endpoint = start_cors_probe_listener(DebugCorsProbeOrdering::OptionsBeforeAuth) + .await + .expect("options-before-auth listener should bind"); + let response = send_preflight_to_listener(&endpoint, DEBUG_CORS_PROBE_ORIGIN).await; + let stopped = stop_cors_probe_listener().expect("probe listener should stop"); + + assert_eq!(response.status, StatusCode::NO_CONTENT); + assert_eq!( + response.allow_origin.as_deref(), + Some(DEBUG_CORS_PROBE_ORIGIN) + ); + assert_eq!(response.allow_methods.as_deref(), Some("POST")); + assert_eq!( + response.allow_headers.as_deref(), + Some("authorization,content-type") + ); + assert_eq!(response.vary.as_deref(), Some("Origin")); + assert!(stopped); +} + +#[tokio::test] +async fn actual_listener_options_before_auth_denies_an_unlisted_origin_without_cors_headers() { + let _guard = listener_test_lock().lock().await; + let endpoint = start_cors_probe_listener(DebugCorsProbeOrdering::OptionsBeforeAuth) + .await + .expect("options-before-auth listener should bind"); + let response = send_preflight_to_listener(&endpoint, "https://unlisted.example").await; + let stopped = stop_cors_probe_listener().expect("probe listener should stop"); + + assert_eq!(response.status, StatusCode::FORBIDDEN); + assert!(response.allow_origin.is_none()); + assert!(response.allow_methods.is_none()); + assert!(response.allow_headers.is_none()); + assert!(stopped); +} From eb67a67451dd0cb5792095a960743839fbfd10cd Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:59:35 +0300 Subject: [PATCH 014/416] docs: record blocked ATS probe prerequisites --- .../remote-mobile/transport-spike-findings.md | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/docs/handoffs/remote-mobile/transport-spike-findings.md b/docs/handoffs/remote-mobile/transport-spike-findings.md index 722add5bdf..3a7d93d838 100644 --- a/docs/handoffs/remote-mobile/transport-spike-findings.md +++ b/docs/handoffs/remote-mobile/transport-spike-findings.md @@ -16,10 +16,10 @@ |---|---|---| | Host revision | Commit SHA and dirty-tree state used for the probe | Pending | | Debug harness | Exact debug-only command/listener shape and cfg-gate evidence | Implemented: `debug_start_remote_transport_cors_probe` / `debug_stop_remote_transport_cors_probe` control a fixed `127.0.0.1:0` fixture; both command registration and the module are `#[cfg(debug_assertions)]`-gated. This records harness shape only, not an experiment result. | -| Tailnet access | Logged-in tailnet identity and evidence that Serve is enabled for the host | Pending | -| Serve endpoint | HTTPS/WSS URL used, without pairing codes, bearers, or other secrets | Pending | -| Direct-tailnet endpoint | HTTP/WS URL used, without credentials | Pending | -| Apple probe vehicle | Named macOS `URLSession`/WKWebView harness and, if available, iOS Simulator harness; OS/runtime version | Pending — required before ATS conclusion | +| Tailnet access | Logged-in tailnet identity and evidence that Serve is enabled for the host | Blocked — 2026-07-27 read-only audit found no `tailscale` executable in `PATH`; no logged-in Serve-capable tailnet evidence is available. | +| Serve endpoint | HTTPS/WSS URL used, without pairing codes, bearers, or other secrets | Blocked — no Serve-capable tailnet or endpoint is available; no request was sent. | +| Direct-tailnet endpoint | HTTP/WS URL used, without credentials | Blocked — no tailnet endpoint is available; no request was sent. | +| Apple probe vehicle | Named macOS `URLSession`/WKWebView harness and, if available, iOS Simulator harness; OS/runtime version | macOS 15.7.4 (24G517), Xcode 26.3 (17C529), and an available iOS 26.3 `iPhone 17 Pro` simulator were observed on 2026-07-27. The simulator remained shut down and no URLSession, WKWebView, or simulator probe was run. | | Browser probe vehicle | Browser/version and origin used for direct-path CORS tests | Pending | | Evidence storage | Stable tracked artifact links or redacted command output paths | Pending | @@ -30,8 +30,8 @@ | E-1 | (a) Desktop Rust-proxy traffic boundary | WKWebView network/devtools capture plus Rust-proxy request log | Pending | Pending | | E-2 | (b) Auth-before-`OPTIONS` failure | Actual loopback socket request/response | `actual_listener_auth_before_options_returns_401_without_cors_headers` | Captured — Rust socket evidence, not a browser capture | | E-3 | (b) Pre-auth-`OPTIONS` success and restrictive rejection | Actual loopback socket request/response | `actual_listener_options_before_auth_returns_restrictive_cors_for_allowed_origin`; `actual_listener_options_before_auth_denies_an_unlisted_origin_without_cors_headers` | Captured — Rust socket evidence, not a browser capture | -| E-4 | (c) Serve ATS result | Named Apple probe output for HTTPS/WSS through Serve | Pending | Pending | -| E-5 | (c) Direct-tailnet ATS result | Named Apple probe output for plain tailnet HTTP/WS | Pending | Pending | +| E-4 | (c) Serve ATS result | Named Apple probe output for HTTPS/WSS through Serve | Blocked — no logged-in Serve-capable tailnet or endpoint | Not executed; insufficient evidence | +| E-5 | (c) Direct-tailnet ATS result | Named Apple probe output for plain tailnet HTTP/WS | Blocked — no direct-tailnet endpoint | Not executed; insufficient evidence | ## Implemented harness boundary @@ -56,6 +56,14 @@ - E-3 unlisted origin: with `OptionsBeforeAuth` and `Origin: https://unlisted.example`, the actual listener returns `403 Forbidden` and emits no CORS allow headers. - These are listener/socket assertions recorded by the focused Rust test suite. No browser page, browser network inspector, or WKWebView capture was run, so browser-visible results remain pending. +## E-4 / E-5 ATS prerequisite audit — blocked (no transport result) + +- Audit date: 2026-07-27. This was read-only environment inspection; it did not install software, start the simulator, configure Serve, or send a network request. +- The `tailscale` command was unavailable in `PATH`. Therefore this worktree has no verified logged-in tailnet identity, Serve capability, Serve HTTPS/WSS endpoint, or direct-tailnet HTTP/WS endpoint for the required probes. +- The host was macOS 15.7.4 (build 24G517) with Xcode 26.3 (build 17C529). An iOS 26.3 `iPhone 17 Pro` simulator is available for a future named vehicle, but it was shutdown and was not started. +- A future actual run must use a named macOS `URLSession`/WKWebView probe and, if selected, that iOS Simulator vehicle, after a logged-in Serve-capable tailnet supplies redacted Serve and direct-tailnet endpoints. +- E-4 and E-5 are **not executed** and the ATS/direct-tailnet outcomes are **not inferred**. This task is blocked with insufficient evidence rather than a Serve-only verdict. + ## (a) Does the Rust-proxied desktop transport produce zero WKWebView cross-origin traffic? ### Question @@ -126,8 +134,8 @@ For each named Apple probe vehicle, does HTTPS/WSS through Tailscale Serve work | Probe vehicle | Serve HTTPS/WSS result | Direct-tailnet HTTP/WS result | ATS exception required | Evidence IDs | Finding | |---|---|---|---|---|---| -| macOS `URLSession` / WKWebView | Pending | Pending | Pending | Pending | Pending | -| iOS Simulator, if available | Pending | Pending | Pending | Pending | Pending | +| macOS `URLSession` / WKWebView | Not executed — no Serve endpoint | Not executed — no direct-tailnet endpoint | Insufficient evidence | E-4/E-5 blocked prerequisite audit | Preserve as a future named probe vehicle | +| iOS 26.3 `iPhone 17 Pro` Simulator (available, shutdown) | Not executed — no Serve endpoint | Not executed — no direct-tailnet endpoint | Insufficient evidence | E-4/E-5 blocked prerequisite audit | Preserve as a future named probe vehicle; simulator was not started | ### Implication slots @@ -156,9 +164,9 @@ For each named Apple probe vehicle, does HTTPS/WSS through Tailscale Serve work | Decision / follow-up | Owner | Needed before | Status | |---|---|---|---| | Choose the debug harness shape: command only or command-controlled throwaway listener | Pending | PR 0.3 harness implementation | Open | -| Name the Apple ATS probe vehicle(s) and record availability | Pending | ATS experiment | Open | -| Provide a Serve-capable logged-in tailnet environment | Pending | Serve experiment | Open | -| Record the Serve-only verdict from captured evidence | Pending | PR 0.3 completion; informational input to PR 1.1 | Open | +| Name the Apple ATS probe vehicle(s) and record availability | Future macOS `URLSession`/WKWebView vehicle; iOS 26.3 `iPhone 17 Pro` Simulator observed available and shutdown on 2026-07-27 | ATS experiment | Open — named vehicles were not run | +| Provide a Serve-capable logged-in tailnet environment | Pending | Serve experiment | Blocked — `tailscale` unavailable in `PATH`; no endpoint to probe | +| Record the Serve-only verdict from captured evidence | Pending | PR 0.3 completion; informational input to PR 1.1 | Blocked — E-4/E-5 not executed, insufficient evidence | ## Completion checklist From 011dccef0e49916b80698ea157111c2e5da3be46 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:00:53 +0300 Subject: [PATCH 015/416] docs: synthesize transport spike evidence --- .../remote-mobile/transport-spike-findings.md | 54 ++++++++++--------- 1 file changed, 30 insertions(+), 24 deletions(-) diff --git a/docs/handoffs/remote-mobile/transport-spike-findings.md b/docs/handoffs/remote-mobile/transport-spike-findings.md index 3a7d93d838..12f21ceff7 100644 --- a/docs/handoffs/remote-mobile/transport-spike-findings.md +++ b/docs/handoffs/remote-mobile/transport-spike-findings.md @@ -1,6 +1,6 @@ # Remote Mobile Transport Spike — Findings -> Status: **PENDING — skeleton only.** No experiment result, verdict, or owner decision is recorded by this document yet. +> Status: **PARTIAL — E-2/E-3 socket evidence captured; E-1/E-4/E-5 remain missing or blocked.** The only overall verdict recorded below is insufficient evidence; no transport or owner decision is implied. > > Scope: PR 0.3 of the Remote Multi-Environment plan. This tracked appendix is the evidence record for R-1 and informs PR 1.1's C-15 CORS layer and the mobile transport specification; it is not a transport implementation or a substitute for the source specification. @@ -14,8 +14,8 @@ | Field | Required record | Status / value | |---|---|---| -| Host revision | Commit SHA and dirty-tree state used for the probe | Pending | -| Debug harness | Exact debug-only command/listener shape and cfg-gate evidence | Implemented: `debug_start_remote_transport_cors_probe` / `debug_stop_remote_transport_cors_probe` control a fixed `127.0.0.1:0` fixture; both command registration and the module are `#[cfg(debug_assertions)]`-gated. This records harness shape only, not an experiment result. | +| Host revision | Commit SHA and dirty-tree state used for the probe | Pre-task-6 HEAD `4188b18cc305f3f22cf674ec7e97c324cc3c15cc`; worktree clean. | +| Debug harness | Exact debug-only command/listener shape and cfg-gate evidence | Resolved: `debug_start_remote_transport_cors_probe` / `debug_stop_remote_transport_cors_probe` control the implemented command-controlled ephemeral `127.0.0.1:0` loopback listener; both command registration and the module are `#[cfg(debug_assertions)]`-gated. This resolves harness shape only, not a transport result. | | Tailnet access | Logged-in tailnet identity and evidence that Serve is enabled for the host | Blocked — 2026-07-27 read-only audit found no `tailscale` executable in `PATH`; no logged-in Serve-capable tailnet evidence is available. | | Serve endpoint | HTTPS/WSS URL used, without pairing codes, bearers, or other secrets | Blocked — no Serve-capable tailnet or endpoint is available; no request was sent. | | Direct-tailnet endpoint | HTTP/WS URL used, without credentials | Blocked — no tailnet endpoint is available; no request was sent. | @@ -38,7 +38,7 @@ - The debug-only fixture is isolated in `remote_server::transport_spike`; it binds an ephemeral loopback address only and returns that address to the caller. - It models only the two direct-browser preflight orderings: fixed 401-before-preflight and pre-auth `OPTIONS` with the fixed development origin `http://127.0.0.1:1420`. It accepts no bearer, pairing code, or remote-listener configuration. - It is absent from release module compilation and Tauri command registration via `#[cfg(debug_assertions)]`. This is cfg-gate evidence, not release-build execution evidence; the release-build verification remains the final PR 0.3 task. -- No desktop, browser, Serve, direct-tailnet, or ATS experiment has been run or concluded by this harness implementation. +- Apart from the actual-listener Rust socket tests recorded as E-2/E-3 below, no desktop, browser, Serve, direct-tailnet, or ATS experiment has been run or concluded by this harness implementation. ## Desktop proxy-stub code evidence (not a WKWebView capture) @@ -78,19 +78,21 @@ When the desktop remote-shaped flow is exercised, does the WKWebView issue only | Field | Record | |---|---| -| Probe vehicle and version | Pending | -| WKWebView capture | Pending | -| Rust-proxy capture | Pending | -| Cross-origin `fetch` observed from WKWebView | Pending | -| Cross-origin WebSocket observed from WKWebView | Pending | -| WKWebView preflight observed | Pending | -| Evidence IDs | Pending | -| Finding / verdict | Pending | +| Probe vehicle and version | No native WKWebView probe vehicle was run. | +| WKWebView capture | Missing — no devtools/network capture was collected. | +| Rust-proxy capture | The debug command test records the Rust-observed fixed loopback response only; it is not a proxy connection log for a native attempt. | +| Cross-origin `fetch` observed from WKWebView | Unobserved — no native capture. | +| Cross-origin WebSocket observed from WKWebView | Unobserved — no native capture. | +| WKWebView preflight observed | Unobserved — no native capture. | +| Evidence IDs | Code/test evidence only: `desktop_proxy_command_uses_the_loopback_fixture_and_reports_its_result`; E-1 capture missing. | +| Finding / verdict | The code/test boundary exists, but the required native proof is missing; desktop claim remains **insufficient evidence**. | ### Implication slots -- PR 1.1 / C-15 desktop boundary: Pending evidence review. -- Mobile transport specification: Pending; desktop evidence does not answer the direct-client path. +- PR 1.1 / C-15 desktop boundary: the code/test seam is consistent with a Rust-side proxy boundary, but the required native WKWebView capture is missing; do not treat the desktop claim as confirmed. +- Mobile transport specification / R-1: desktop code evidence does not answer the direct-client path; the residual remains open. + +**Recorded finding:** (a) is not confirmed. The debug Rust-side loopback test is useful boundary evidence, but it cannot establish which requests left a WKWebView. ## (b) Direct browser path: what are the CORS and pre-auth `OPTIONS` ordering results? @@ -120,6 +122,8 @@ Against the debug-only direct-path listener, does auth-before-`OPTIONS` reproduc - PR 1.1 / C-15 router middleware ordering and restrictive-origin policy: the actual-listener socket evidence supports pre-auth `OPTIONS` and a fixed allowlist; browser evidence is still pending. - Mobile transport specification direct-browser behavior: Pending browser evidence review; the socket fixture is not a mobile/browser observation. +**Recorded finding:** (b) confirms the debug listener's order-dependent socket behavior and fixed-origin policy, not browser-visible CORS behavior. + ## (c) ATS: does Serve TLS satisfy Apple-client requirements, and does plain tailnet HTTP need exceptions? ### Question @@ -139,8 +143,10 @@ For each named Apple probe vehicle, does HTTPS/WSS through Tailscale Serve work ### Implication slots -- PR 1.1 endpoint and CORS implementation: Pending evidence review. -- Mobile transport specification ATS policy and any exception requirement: Pending evidence review. +- PR 1.1 / C-15: no Serve/ATS result confirms or amends the endpoint posture. The only confirmed router implication remains pre-auth `OPTIONS` plus a fixed restrictive allowlist from E-2/E-3. +- Mobile transport specification / R-1: ATS policy, direct-tailnet posture, and any exception requirement remain unselected because E-4/E-5 were not executed. + +**Recorded finding:** (c) is blocked before a transport request; neither Serve TLS nor plain direct-tailnet ATS behavior was observed. ## (d) Verdict: does Serve-only suffice? @@ -148,22 +154,22 @@ For each named Apple probe vehicle, does HTTPS/WSS through Tailscale Serve work | Field | Record | |---|---| -| Verdict (`yes` / `no` / `insufficient evidence`) | Pending | -| Rationale linked to E-1 through E-5 | Pending | -| Direct-tailnet posture if Serve-only is not selected | Pending | -| Owner decision required | Pending | -| Decision date / owner | Pending | +| Verdict (`yes` / `no` / `insufficient evidence`) | **Insufficient evidence** | +| Rationale linked to E-1 through E-5 | E-1 lacks native WKWebView/devtools capture; E-2/E-3 establish only actual loopback socket ordering behavior; E-4/E-5 were blocked before any Apple/Serve/direct-tailnet request. | +| Direct-tailnet posture if Serve-only is not selected | Unselected — no plain-tailnet behavior or ATS result was observed. | +| Owner decision required | Yes — choose Serve-only or a direct-tailnet posture only after the missing evidence exists. | +| Decision date / owner | Pending owner decision; no date recorded. | ### Downstream implications -- PR 1.1: Pending — amend or confirm C-15 only after the evidence and owner verdict are recorded. -- Mobile transport specification: Pending — document the selected direct-client transport posture and ATS requirements only after the verdict. +- PR 1.1 / C-15: carry forward the confirmed E-2/E-3 rule: handle `OPTIONS` before bearer authentication and permit only the fixed allowlisted origin. Do not claim the desktop WKWebView boundary or Serve posture is confirmed. +- Mobile transport specification / R-1: retain the direct-client CORS/ATS and Serve-vs-direct-tailnet questions as residual gaps; do not select a transport or ATS exception policy from this appendix. ## Open decisions and follow-up | Decision / follow-up | Owner | Needed before | Status | |---|---|---|---| -| Choose the debug harness shape: command only or command-controlled throwaway listener | Pending | PR 0.3 harness implementation | Open | +| Choose the debug harness shape: command only or command-controlled throwaway listener | Resolved in the implemented debug-only command-controlled ephemeral loopback listener | PR 0.3 harness implementation | Resolved | | Name the Apple ATS probe vehicle(s) and record availability | Future macOS `URLSession`/WKWebView vehicle; iOS 26.3 `iPhone 17 Pro` Simulator observed available and shutdown on 2026-07-27 | ATS experiment | Open — named vehicles were not run | | Provide a Serve-capable logged-in tailnet environment | Pending | Serve experiment | Blocked — `tailscale` unavailable in `PATH`; no endpoint to probe | | Record the Serve-only verdict from captured evidence | Pending | PR 0.3 completion; informational input to PR 1.1 | Blocked — E-4/E-5 not executed, insufficient evidence | From 84bad4789de447de521ad3f1e1c1554da6c986da Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:08:25 +0300 Subject: [PATCH 016/416] docs: record release transport spike gate --- .../remote-mobile/transport-spike-findings.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/handoffs/remote-mobile/transport-spike-findings.md b/docs/handoffs/remote-mobile/transport-spike-findings.md index 12f21ceff7..476118ea97 100644 --- a/docs/handoffs/remote-mobile/transport-spike-findings.md +++ b/docs/handoffs/remote-mobile/transport-spike-findings.md @@ -37,7 +37,7 @@ - The debug-only fixture is isolated in `remote_server::transport_spike`; it binds an ephemeral loopback address only and returns that address to the caller. - It models only the two direct-browser preflight orderings: fixed 401-before-preflight and pre-auth `OPTIONS` with the fixed development origin `http://127.0.0.1:1420`. It accepts no bearer, pairing code, or remote-listener configuration. -- It is absent from release module compilation and Tauri command registration via `#[cfg(debug_assertions)]`. This is cfg-gate evidence, not release-build execution evidence; the release-build verification remains the final PR 0.3 task. +- It is absent from release module compilation and Tauri command registration via `#[cfg(debug_assertions)]`. On 2026-07-27, `cargo check --manifest-path src-tauri/Cargo.toml --release --lib` completed successfully from pre-task-7 HEAD `cd410d86e`; this compiles the library/command registry with `debug_assertions` off. No Rust tests ran in that command. - Apart from the actual-listener Rust socket tests recorded as E-2/E-3 below, no desktop, browser, Serve, direct-tailnet, or ATS experiment has been run or concluded by this harness implementation. ## Desktop proxy-stub code evidence (not a WKWebView capture) @@ -64,6 +64,13 @@ - A future actual run must use a named macOS `URLSession`/WKWebView probe and, if selected, that iOS Simulator vehicle, after a logged-in Serve-capable tailnet supplies redacted Serve and direct-tailnet endpoints. - E-4 and E-5 are **not executed** and the ATS/direct-tailnet outcomes are **not inferred**. This task is blocked with insufficient evidence rather than a Serve-only verdict. +## Release configuration and routing-scope evidence + +- Release cfg proof: `cargo check --manifest-path src-tauri/Cargo.toml --release --lib` completed successfully on 2026-07-27 from `cd410d86e`. It compiles the command registry/library with `debug_assertions` off; because the transport-spike module and each registration are `#[cfg(debug_assertions)]`, the release configuration contains no transport-spike code path. A separate source-text guard was not added because this release compilation is the stronger deterministic check. +- Routing/binding diff proof: `git diff --name-only ab9b47961..cd410d86e -- src-tauri/src/http_server src-tauri/src/utils/backend_endpoint.rs` returned no paths. `ab9b47961` is the pre-0.3 base and `cd410d86e` the pre-task-7 Phase-0.3 HEAD. +- Therefore this PR 0.3 diff does not change `src-tauri/src/http_server/**`, `backend_endpoint.rs`, or the production :3847/:3848 routing/binding configuration. The debug fixture remains separate on ephemeral loopback only. +- The release check started no Rust tests. `cd src-tauri && cargo clean` ran afterward for disk hygiene and removed the generated check artifacts. + ## (a) Does the Rust-proxied desktop transport produce zero WKWebView cross-origin traffic? ### Question @@ -180,4 +187,4 @@ For each named Apple probe vehicle, does HTTPS/WSS through Tailscale Serve work - [ ] Questions (a) through (d) each have a recorded finding. - [ ] The Serve-only verdict is explicit and evidence-linked. - [ ] PR 1.1 C-15 and the mobile transport specification implication slots are filled without contradicting the source contract. -- [ ] The debug harness is confirmed absent from release registration and no :3847/:3848 routing or binding changed. +- [x] The debug harness is confirmed absent from release registration and no :3847/:3848 routing or binding changed. *(Release library check and scoped pre-0.3 diff recorded above; this does not close E-1/E-4/E-5.)* From b69666ab6a1e6375069289a8319512fdbd4d7c36 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:09:15 +0300 Subject: [PATCH 017/416] docs: align transport evidence after rebase --- docs/handoffs/remote-mobile/transport-spike-findings.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/handoffs/remote-mobile/transport-spike-findings.md b/docs/handoffs/remote-mobile/transport-spike-findings.md index 476118ea97..47dce137df 100644 --- a/docs/handoffs/remote-mobile/transport-spike-findings.md +++ b/docs/handoffs/remote-mobile/transport-spike-findings.md @@ -14,7 +14,7 @@ | Field | Required record | Status / value | |---|---|---| -| Host revision | Commit SHA and dirty-tree state used for the probe | Pre-task-6 HEAD `4188b18cc305f3f22cf674ec7e97c324cc3c15cc`; worktree clean. | +| Host revision | Commit SHA and dirty-tree state used for the probe | Pre-task-6 HEAD `eb67a67451dd0cb5792095a960743839fbfd10cd`; worktree clean. | | Debug harness | Exact debug-only command/listener shape and cfg-gate evidence | Resolved: `debug_start_remote_transport_cors_probe` / `debug_stop_remote_transport_cors_probe` control the implemented command-controlled ephemeral `127.0.0.1:0` loopback listener; both command registration and the module are `#[cfg(debug_assertions)]`-gated. This resolves harness shape only, not a transport result. | | Tailnet access | Logged-in tailnet identity and evidence that Serve is enabled for the host | Blocked — 2026-07-27 read-only audit found no `tailscale` executable in `PATH`; no logged-in Serve-capable tailnet evidence is available. | | Serve endpoint | HTTPS/WSS URL used, without pairing codes, bearers, or other secrets | Blocked — no Serve-capable tailnet or endpoint is available; no request was sent. | @@ -37,7 +37,7 @@ - The debug-only fixture is isolated in `remote_server::transport_spike`; it binds an ephemeral loopback address only and returns that address to the caller. - It models only the two direct-browser preflight orderings: fixed 401-before-preflight and pre-auth `OPTIONS` with the fixed development origin `http://127.0.0.1:1420`. It accepts no bearer, pairing code, or remote-listener configuration. -- It is absent from release module compilation and Tauri command registration via `#[cfg(debug_assertions)]`. On 2026-07-27, `cargo check --manifest-path src-tauri/Cargo.toml --release --lib` completed successfully from pre-task-7 HEAD `cd410d86e`; this compiles the library/command registry with `debug_assertions` off. No Rust tests ran in that command. +- It is absent from release module compilation and Tauri command registration via `#[cfg(debug_assertions)]`. On 2026-07-27, `cargo check --manifest-path src-tauri/Cargo.toml --release --lib` completed successfully from the content now at rebased pre-task-7 HEAD `011dccef0`; this compiles the library/command registry with `debug_assertions` off. No Rust tests ran in that command. - Apart from the actual-listener Rust socket tests recorded as E-2/E-3 below, no desktop, browser, Serve, direct-tailnet, or ATS experiment has been run or concluded by this harness implementation. ## Desktop proxy-stub code evidence (not a WKWebView capture) @@ -66,8 +66,8 @@ ## Release configuration and routing-scope evidence -- Release cfg proof: `cargo check --manifest-path src-tauri/Cargo.toml --release --lib` completed successfully on 2026-07-27 from `cd410d86e`. It compiles the command registry/library with `debug_assertions` off; because the transport-spike module and each registration are `#[cfg(debug_assertions)]`, the release configuration contains no transport-spike code path. A separate source-text guard was not added because this release compilation is the stronger deterministic check. -- Routing/binding diff proof: `git diff --name-only ab9b47961..cd410d86e -- src-tauri/src/http_server src-tauri/src/utils/backend_endpoint.rs` returned no paths. `ab9b47961` is the pre-0.3 base and `cd410d86e` the pre-task-7 Phase-0.3 HEAD. +- Release cfg proof: `cargo check --manifest-path src-tauri/Cargo.toml --release --lib` completed successfully on 2026-07-27 from the content now at rebased pre-task-7 HEAD `011dccef0`. It compiles the command registry/library with `debug_assertions` off; because the transport-spike module and each registration are `#[cfg(debug_assertions)]`, the release configuration contains no transport-spike code path. A separate source-text guard was not added because this release compilation is the stronger deterministic check. +- Routing/binding diff proof: `git diff --name-only 35e8242f7..011dccef0 -- src-tauri/src/http_server src-tauri/src/utils/backend_endpoint.rs` returned no paths. `35e8242f7` is the rebased pre-0.3 base and `011dccef0` the rebased pre-task-7 Phase-0.3 HEAD. - Therefore this PR 0.3 diff does not change `src-tauri/src/http_server/**`, `backend_endpoint.rs`, or the production :3847/:3848 routing/binding configuration. The debug fixture remains separate on ephemeral loopback only. - The release check started no Rust tests. `cd src-tauri && cargo clean` ran afterward for disk hygiene and removed the generated check artifacts. From 6afcd1bd83e7e345124cae64cb2dc3835a84a913 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:21:41 +0300 Subject: [PATCH 018/416] feat: add remote host settings migration --- .../infrastructure/sqlite/migrations/mod.rs | 10 +- .../v20260727161131_remote_host_settings.rs | 22 +++ ...260727161131_remote_host_settings_tests.rs | 75 ++++++++++ src-tauri/src/remote_server/mod.rs | 3 + src-tauri/src/remote_server/settings.rs | 128 ++++++++++++++++++ src-tauri/src/remote_server/settings_tests.rs | 66 +++++++++ 6 files changed, 303 insertions(+), 1 deletion(-) create mode 100644 src-tauri/src/infrastructure/sqlite/migrations/v20260727161131_remote_host_settings.rs create mode 100644 src-tauri/src/infrastructure/sqlite/migrations/v20260727161131_remote_host_settings_tests.rs create mode 100644 src-tauri/src/remote_server/settings.rs create mode 100644 src-tauri/src/remote_server/settings_tests.rs diff --git a/src-tauri/src/infrastructure/sqlite/migrations/mod.rs b/src-tauri/src/infrastructure/sqlite/migrations/mod.rs index ed062d46a0..095b0204c1 100644 --- a/src-tauri/src/infrastructure/sqlite/migrations/mod.rs +++ b/src-tauri/src/infrastructure/sqlite/migrations/mod.rs @@ -551,6 +551,9 @@ mod v20260724141500_workspace_review_requested_changes_tests; mod v20260724222347_agent_task_assignment_planned_run_identity; #[cfg(test)] mod v20260724222347_agent_task_assignment_planned_run_identity_tests; +mod v20260727161131_remote_host_settings; +#[cfg(test)] +mod v20260727161131_remote_host_settings_tests; #[cfg(test)] pub(super) fn migrate_scripted_agent_workflows_for_test(conn: &Connection) -> AppResult<()> { v20260715194617_scripted_agent_workflows::migrate(conn) @@ -645,7 +648,7 @@ mod v8_task_git_fields_tests; mod v9_project_git_fields_tests; /// Current schema version - bump this when adding a new migration -pub const SCHEMA_VERSION: i64 = 20260724222347; +pub const SCHEMA_VERSION: i64 = 20260727161131; /// Migration function signature type MigrationFn = fn(&Connection) -> AppResult<()>; @@ -1776,6 +1779,11 @@ const MIGRATIONS: &[Migration] = &[ name: "agent_task_assignment_planned_run_identity", migrate: v20260724222347_agent_task_assignment_planned_run_identity::migrate, }, + Migration { + version: 20260727161131, + name: "remote_host_settings", + migrate: v20260727161131_remote_host_settings::migrate, + }, ]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src-tauri/src/infrastructure/sqlite/migrations/v20260727161131_remote_host_settings.rs b/src-tauri/src/infrastructure/sqlite/migrations/v20260727161131_remote_host_settings.rs new file mode 100644 index 0000000000..b61bb787f8 --- /dev/null +++ b/src-tauri/src/infrastructure/sqlite/migrations/v20260727161131_remote_host_settings.rs @@ -0,0 +1,22 @@ +// Migration v20260727161131: remote host settings + +use rusqlite::Connection; + +use crate::error::{AppError, AppResult}; + +pub fn migrate(conn: &Connection) -> AppResult<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS remote_host_settings ( + id INTEGER PRIMARY KEY CHECK (id = 1), + enabled INTEGER NOT NULL DEFAULT 0 CHECK (enabled IN (0, 1)), + exposure_mode TEXT NOT NULL DEFAULT 'serve' + CHECK (exposure_mode IN ('serve', 'tailnet_direct')), + port INTEGER NOT NULL DEFAULT 3849 CHECK (port BETWEEN 1 AND 65535), + environment_id TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now')) + );", + ) + .map_err(|error| AppError::Database(error.to_string()))?; + Ok(()) +} diff --git a/src-tauri/src/infrastructure/sqlite/migrations/v20260727161131_remote_host_settings_tests.rs b/src-tauri/src/infrastructure/sqlite/migrations/v20260727161131_remote_host_settings_tests.rs new file mode 100644 index 0000000000..d1392208b4 --- /dev/null +++ b/src-tauri/src/infrastructure/sqlite/migrations/v20260727161131_remote_host_settings_tests.rs @@ -0,0 +1,75 @@ +//! Tests for migration v20260727161131: remote host settings + +use rusqlite::Connection; + +use super::{helpers, v20260727161131_remote_host_settings}; + +fn setup_test_db() -> Connection { + Connection::open_in_memory().expect("in-memory database should open") +} + +#[test] +fn migration_creates_the_remote_host_settings_singleton_schema() { + let conn = setup_test_db(); + + v20260727161131_remote_host_settings::migrate(&conn) + .expect("migration should create remote host settings"); + + assert!(helpers::table_exists(&conn, "remote_host_settings")); + for column in ["enabled", "exposure_mode", "port", "environment_id"] { + assert!( + helpers::column_exists(&conn, "remote_host_settings", column), + "remote host settings should contain {column}" + ); + } + let row_count: i64 = conn + .query_row("SELECT COUNT(*) FROM remote_host_settings", [], |row| { + row.get(0) + }) + .expect("singleton table should be queryable"); + assert_eq!(row_count, 0, "migration must not enable or seed host mode"); +} + +#[test] +fn migration_enforces_one_valid_remote_host_settings_row() { + let conn = setup_test_db(); + v20260727161131_remote_host_settings::migrate(&conn) + .expect("migration should create remote host settings"); + + conn.execute( + "INSERT INTO remote_host_settings (id, enabled, exposure_mode, port, environment_id) + VALUES (1, 0, 'serve', 3849, '8d3d6a07-8e85-4e91-97ce-915fc038fdb2')", + [], + ) + .expect("singleton row should accept the default valid values"); + + assert!(conn + .execute( + "INSERT INTO remote_host_settings (id, enabled, exposure_mode, port, environment_id) + VALUES (2, 0, 'serve', 3849, 'b3a3d3c4-802a-4a02-b4c7-6b2c7aa0fd4c')", + [], + ) + .is_err()); + assert!(conn + .execute( + "UPDATE remote_host_settings SET exposure_mode = 'lan' WHERE id = 1", + [], + ) + .is_err()); +} + +#[test] +fn migration_is_idempotent_without_seeding_a_row() { + let conn = setup_test_db(); + + v20260727161131_remote_host_settings::migrate(&conn).expect("first migration should succeed"); + v20260727161131_remote_host_settings::migrate(&conn) + .expect("second migration should remain safe"); + + let row_count: i64 = conn + .query_row("SELECT COUNT(*) FROM remote_host_settings", [], |row| { + row.get(0) + }) + .expect("singleton table should be queryable"); + assert_eq!(row_count, 0); +} diff --git a/src-tauri/src/remote_server/mod.rs b/src-tauri/src/remote_server/mod.rs index d66a0eb982..508c82ec1b 100644 --- a/src-tauri/src/remote_server/mod.rs +++ b/src-tauri/src/remote_server/mod.rs @@ -1,5 +1,8 @@ pub mod capture; +pub mod settings; #[cfg(debug_assertions)] pub mod transport_spike; #[cfg(all(test, debug_assertions))] mod transport_spike_tests; +#[cfg(test)] +mod settings_tests; diff --git a/src-tauri/src/remote_server/settings.rs b/src-tauri/src/remote_server/settings.rs new file mode 100644 index 0000000000..28492f2b62 --- /dev/null +++ b/src-tauri/src/remote_server/settings.rs @@ -0,0 +1,128 @@ +use std::sync::Arc; + +use rusqlite::Connection; +use tokio::sync::Mutex; +use uuid::Uuid; + +use crate::error::{AppError, AppResult}; +use crate::infrastructure::sqlite::DbConnection; + +pub(crate) const DEFAULT_REMOTE_PORT: u16 = 3849; +const SETTINGS_ROW_ID: i64 = 1; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RemoteExposureMode { + Serve, + TailnetDirect, +} + +impl RemoteExposureMode { + fn as_db_value(self) -> &'static str { + match self { + Self::Serve => "serve", + Self::TailnetDirect => "tailnet_direct", + } + } + + fn from_db_value(value: &str) -> AppResult { + match value { + "serve" => Ok(Self::Serve), + "tailnet_direct" => Ok(Self::TailnetDirect), + _ => Err(AppError::Database(format!( + "invalid remote host exposure mode: {value}" + ))), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct RemoteHostSettings { + pub enabled: bool, + pub exposure_mode: RemoteExposureMode, + pub port: u16, + pub environment_id: String, +} + +/// SQLite-backed singleton settings and stable host identity for remote access. +pub(crate) struct RemoteHostSettingsStore { + db: DbConnection, +} + +impl RemoteHostSettingsStore { + pub(crate) fn new(conn: Connection) -> Self { + Self { + db: DbConnection::new(conn), + } + } + + pub(crate) fn from_shared(conn: Arc>) -> Self { + Self { + db: DbConnection::from_shared(conn), + } + } + + /// Returns the singleton settings, creating the disabled default on first access. + pub(crate) async fn get_or_create(&self) -> AppResult { + self.db + .run_transaction(move |conn| { + if let Some(settings) = read_settings(conn)? { + return Ok(settings); + } + + let environment_id = Uuid::new_v4().to_string(); + conn.execute( + "INSERT INTO remote_host_settings ( + id, enabled, exposure_mode, port, environment_id + ) VALUES (?1, 0, ?2, ?3, ?4)", + rusqlite::params![ + SETTINGS_ROW_ID, + RemoteExposureMode::Serve.as_db_value(), + i64::from(DEFAULT_REMOTE_PORT), + environment_id, + ], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + + read_settings(conn)?.ok_or_else(|| { + AppError::Database("remote host settings row missing after insert".to_string()) + }) + }) + .await + } +} + +fn read_settings(conn: &Connection) -> AppResult> { + let result = conn.query_row( + "SELECT enabled, exposure_mode, port, environment_id + FROM remote_host_settings + WHERE id = ?1", + [SETTINGS_ROW_ID], + |row| { + let enabled: i64 = row.get(0)?; + let exposure_mode = row.get::<_, String>(1)?; + let port = row.get::<_, i64>(2)?; + let environment_id = row.get::<_, String>(3)?; + Ok((enabled, exposure_mode, port, environment_id)) + }, + ); + + match result { + Ok((enabled, exposure_mode, port, environment_id)) => { + let exposure_mode = RemoteExposureMode::from_db_value(&exposure_mode)?; + let port = u16::try_from(port).map_err(|_| { + AppError::Database(format!("invalid remote host settings port: {port}")) + })?; + Uuid::parse_str(&environment_id).map_err(|error| { + AppError::Database(format!("invalid remote host environment id: {error}")) + })?; + Ok(Some(RemoteHostSettings { + enabled: enabled != 0, + exposure_mode, + port, + environment_id, + })) + } + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(error) => Err(AppError::Database(error.to_string())), + } +} diff --git a/src-tauri/src/remote_server/settings_tests.rs b/src-tauri/src/remote_server/settings_tests.rs new file mode 100644 index 0000000000..ca40b7b31e --- /dev/null +++ b/src-tauri/src/remote_server/settings_tests.rs @@ -0,0 +1,66 @@ +use super::settings::{RemoteExposureMode, RemoteHostSettingsStore, DEFAULT_REMOTE_PORT}; +use crate::testing::SqliteTestDb; +use uuid::Uuid; + +#[tokio::test] +async fn first_access_mints_a_disabled_singleton_with_valid_defaults() { + let db = SqliteTestDb::new("remote-host-settings-first-access"); + db.with_connection(|conn| { + let row_count: i64 = conn + .query_row("SELECT COUNT(*) FROM remote_host_settings", [], |row| { + row.get(0) + }) + .expect("migration should leave the singleton row absent"); + assert_eq!(row_count, 0); + }); + let store = RemoteHostSettingsStore::from_shared(db.shared_conn()); + + let settings = store + .get_or_create() + .await + .expect("first access should create settings"); + + assert!(!settings.enabled); + assert_eq!(settings.exposure_mode, RemoteExposureMode::Serve); + assert_eq!(settings.port, DEFAULT_REMOTE_PORT); + assert!(Uuid::parse_str(&settings.environment_id).is_ok()); + db.with_connection(|conn| { + let row_count: i64 = conn + .query_row("SELECT COUNT(*) FROM remote_host_settings", [], |row| { + row.get(0) + }) + .expect("singleton row should be queryable"); + assert_eq!(row_count, 1); + }); +} + +#[tokio::test] +async fn repeated_access_and_a_reopened_connection_keep_the_environment_id() { + let db = SqliteTestDb::new("remote-host-settings-stable-environment-id"); + let first_store = RemoteHostSettingsStore::from_shared(db.shared_conn()); + let first = first_store + .get_or_create() + .await + .expect("first access should create settings"); + let second = first_store + .get_or_create() + .await + .expect("second access should read settings"); + let reopened_store = RemoteHostSettingsStore::new(db.new_connection()); + let reopened = reopened_store + .get_or_create() + .await + .expect("reopened connection should read settings"); + + assert_eq!(first.environment_id, second.environment_id); + assert_eq!(first.environment_id, reopened.environment_id); + assert!(Uuid::parse_str(&reopened.environment_id).is_ok()); + db.with_connection(|conn| { + let row_count: i64 = conn + .query_row("SELECT COUNT(*) FROM remote_host_settings", [], |row| { + row.get(0) + }) + .expect("singleton row should be queryable"); + assert_eq!(row_count, 1); + }); +} From 966da42a194329c6a10335530abffcbb32d94865 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:05:09 +0300 Subject: [PATCH 019/416] feat: migrate AgentTerminalDrawer to the shared event bus Replace the raw Tauri listen() call with useEventBus().subscribe() so terminal-event subscription goes through the same seam as the rest of the app, keeping mount/unmount lifecycle and payload handling intact. --- .../agents/AgentTerminalDrawer.test.tsx | 95 +++++-------------- .../components/agents/AgentTerminalDrawer.tsx | 32 ++----- 2 files changed, 30 insertions(+), 97 deletions(-) diff --git a/frontend/src/components/agents/AgentTerminalDrawer.test.tsx b/frontend/src/components/agents/AgentTerminalDrawer.test.tsx index 8f151d8587..8126c3e7f7 100644 --- a/frontend/src/components/agents/AgentTerminalDrawer.test.tsx +++ b/frontend/src/components/agents/AgentTerminalDrawer.test.tsx @@ -8,7 +8,7 @@ import { AgentTerminalDrawer } from "./AgentTerminalDrawer"; import { useAgentTerminalStore } from "./agentTerminalStore"; const { - listenMock, + subscribeMock, openAgentTerminalMock, closeAgentTerminalMock, clearAgentTerminalMock, @@ -17,8 +17,9 @@ const { writeAgentTerminalMock, terminalOpenMock, terminalEventSafeParseMock, + eventBusMock, } = vi.hoisted(() => ({ - listenMock: vi.fn(), + subscribeMock: vi.fn(), openAgentTerminalMock: vi.fn(), closeAgentTerminalMock: vi.fn(), clearAgentTerminalMock: vi.fn(), @@ -27,10 +28,13 @@ const { writeAgentTerminalMock: vi.fn(), terminalOpenMock: vi.fn(), terminalEventSafeParseMock: vi.fn(), + eventBusMock: { subscribe: vi.fn(), emit: vi.fn() }, })); -vi.mock("@tauri-apps/api/event", () => ({ - listen: (...args: unknown[]) => listenMock(...args), +eventBusMock.subscribe = subscribeMock; + +vi.mock("@/providers/EventProvider", () => ({ + useEventBus: () => eventBusMock, })); vi.mock("@/api/terminal", () => ({ @@ -113,7 +117,7 @@ describe("AgentTerminalDrawer", () => { }, ); - listenMock.mockReset(); + subscribeMock.mockReset(); openAgentTerminalMock.mockReset(); closeAgentTerminalMock.mockReset(); clearAgentTerminalMock.mockReset(); @@ -132,7 +136,7 @@ describe("AgentTerminalDrawer", () => { dragOverDock: null, }); - listenMock.mockResolvedValue(vi.fn()); + subscribeMock.mockReturnValue(vi.fn()); terminalEventSafeParseMock.mockReturnValue({ success: false }); openAgentTerminalMock.mockResolvedValue({ status: "running", @@ -270,9 +274,11 @@ describe("AgentTerminalDrawer", () => { const dockElement = document.createElement("div"); document.body.appendChild(dockElement); let terminalEventListener: ((event: { payload: unknown }) => void) | null = null; - listenMock.mockImplementation((_eventName, listener) => { - terminalEventListener = listener as (event: { payload: unknown }) => void; - return Promise.resolve(vi.fn()); + subscribeMock.mockImplementation((_eventName, listener) => { + terminalEventListener = (event) => { + (listener as (payload: unknown) => void)(event.payload); + }; + return vi.fn(); }); terminalEventSafeParseMock.mockImplementation((payload) => ({ success: true, @@ -873,17 +879,11 @@ describe("AgentTerminalDrawer", () => { }); }); - it("swallows stale Tauri terminal listener cleanup failures", async () => { + it("unsubscribes from terminal events on unmount", async () => { const dockElement = document.createElement("div"); document.body.appendChild(dockElement); - const cleanupError = new TypeError( - "undefined is not an object (evaluating 'listeners[eventId].handlerId')", - ); - const staleUnlisten = vi.fn(() => { - throw cleanupError; - }); - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined); - listenMock.mockResolvedValue(staleUnlisten); + const unsubscribe = vi.fn(); + subscribeMock.mockReturnValue(unsubscribe); const { unmount } = render( @@ -914,63 +914,12 @@ describe("AgentTerminalDrawer", () => { await Promise.resolve(); }); - expect(() => unmount()).not.toThrow(); - expect(staleUnlisten).toHaveBeenCalledTimes(1); - expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining("Failed to unlisten"), - cleanupError, - ); - }); - - it("releases the terminal event listener when it resolves after unmount", async () => { - const dockElement = document.createElement("div"); - document.body.appendChild(dockElement); - const lateUnlisten = vi.fn(); - let resolveListen: (dispose: () => void) => void = () => undefined; - listenMock.mockReturnValue( - new Promise((resolve) => { - resolveListen = resolve; - }), - ); - - const { unmount } = render( - - - , + expect(subscribeMock).toHaveBeenCalledWith( + "agent-terminal://event", + expect.any(Function), ); - - await act(async () => { - await Promise.resolve(); - rafCallbacks[0]?.(0); - await vi.runOnlyPendingTimersAsync(); - await vi.dynamicImportSettled(); - await Promise.resolve(); - await Promise.resolve(); - }); - - expect(listenMock).toHaveBeenCalledTimes(1); - unmount(); - - await act(async () => { - resolveListen(lateUnlisten); - await Promise.resolve(); - }); - - expect(lateUnlisten).toHaveBeenCalledTimes(1); + expect(unsubscribe).toHaveBeenCalledTimes(1); }); it("requests visual collapse before waiting for the backend terminal close", async () => { diff --git a/frontend/src/components/agents/AgentTerminalDrawer.tsx b/frontend/src/components/agents/AgentTerminalDrawer.tsx index aba5dc317b..c2ecb868d0 100644 --- a/frontend/src/components/agents/AgentTerminalDrawer.tsx +++ b/frontend/src/components/agents/AgentTerminalDrawer.tsx @@ -12,7 +12,6 @@ import { type ReactNode, } from "react"; import { createPortal } from "react-dom"; -import { listen } from "@tauri-apps/api/event"; import type { FitAddon } from "@xterm/addon-fit"; import type { Terminal as XTermTerminal, @@ -58,14 +57,11 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { formatBranchDisplay } from "@/lib/branch-utils"; +import { useEventBus } from "@/providers/EventProvider"; import { RALPHX_TERMINAL_DOCK_DRAG_TYPE, setRalphxTerminalDockDragActive, } from "@/lib/internalDragTypes"; -import { - safelyUnlistenTauri, - type TauriUnlistenFn, -} from "@/lib/tauri-listener-cleanup"; import { cn } from "@/lib/utils"; import { compactTerminalPath } from "./agentTerminalPaths"; import { loadAgentTerminalRuntime } from "./agentTerminalRuntime"; @@ -159,6 +155,7 @@ export function AgentTerminalDrawer({ onPlacementDragEnd, dockElement, }: AgentTerminalDrawerProps) { + const eventBus = useEventBus(); const terminalId = DEFAULT_AGENT_TERMINAL_ID; const [portalRoot] = useState(() => { const element = document.createElement("div"); @@ -418,14 +415,7 @@ export function AgentTerminalDrawer({ let initFrame: number | null = null; let initTimer: number | null = null; let resizeObserver: ResizeObserver | null = null; - let unlisten: TauriUnlistenFn | null = null; - let listenerPromise: Promise | null = null; - - const releaseListener = () => { - const dispose = unlisten; - unlisten = null; - safelyUnlistenTauri(dispose, AGENT_TERMINAL_EVENT); - }; + let unsubscribe: (() => void) | null = null; const scheduleFit = () => { if (resizeFrame !== null) { @@ -462,19 +452,12 @@ export function AgentTerminalDrawer({ fitAddonRef.current = fitAddon; setIsHydrating(false); - listenerPromise = listen(AGENT_TERMINAL_EVENT, (event) => { - const parsed = AgentTerminalEventSchema.safeParse(event.payload); + unsubscribe = eventBus.subscribe(AGENT_TERMINAL_EVENT, (payload) => { + const parsed = AgentTerminalEventSchema.safeParse(payload); if (parsed.success) { applyEvent(parsed.data); } - }).then((dispose) => { - if (disposed) { - safelyUnlistenTauri(dispose, AGENT_TERMINAL_EVENT); - return; - } - unlisten = dispose; }); - await listenerPromise; if (disposed) { return; @@ -561,8 +544,8 @@ export function AgentTerminalDrawer({ } resizeObserver?.disconnect(); dataDisposable?.dispose(); - releaseListener(); - void listenerPromise?.then(releaseListener).catch(() => undefined); + unsubscribe?.(); + unsubscribe = null; terminal?.dispose(); terminalRef.current = null; fitAddonRef.current = null; @@ -571,6 +554,7 @@ export function AgentTerminalDrawer({ applyEvent, applySnapshot, conversationId, + eventBus, fitTerminal, fitAndReportSize, shouldHydrateTerminal, From f59685978518e6419955e35282243b5779de24b5 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:05:13 +0300 Subject: [PATCH 020/416] feat: migrate GitAuthRepairPanel to the shared event bus Replace the raw Tauri listen() call for gh-auth login prompts with useEventBus().subscribe(), preserving the operation-scoped subscribe/unsubscribe lifecycle. Adds coverage proving the subscription is released once the sign-in operation settles. --- .../git/GitAuthRepairPanel.test.tsx | 63 ++++++++++++++++--- .../src/components/git/GitAuthRepairPanel.tsx | 7 ++- 2 files changed, 58 insertions(+), 12 deletions(-) diff --git a/frontend/src/components/git/GitAuthRepairPanel.test.tsx b/frontend/src/components/git/GitAuthRepairPanel.test.tsx index 8da9cc3fab..7298aa318e 100644 --- a/frontend/src/components/git/GitAuthRepairPanel.test.tsx +++ b/frontend/src/components/git/GitAuthRepairPanel.test.tsx @@ -29,10 +29,13 @@ const { mocks } = vi.hoisted(() => ({ mutateAsync: vi.fn().mockResolvedValue(false), isPending: false, }, - listen: vi.fn(), + subscribe: vi.fn(), + eventBus: { subscribe: vi.fn(), emit: vi.fn() }, }, })); +mocks.eventBus.subscribe = mocks.subscribe; + vi.mock("@/hooks/useGithubSettings", () => ({ useGitAuthDiagnostics: () => mocks.diagnostics, useLoginGhWithBrowser: () => mocks.loginGh, @@ -45,8 +48,8 @@ vi.mock("@/hooks/useGitHubConnectionStatus", () => ({ useGitHubConnectionStatus: () => mocks.ghAuth, })); -vi.mock("@tauri-apps/api/event", () => ({ - listen: (...args: unknown[]) => mocks.listen(...args), +vi.mock("@/providers/EventProvider", () => ({ + useEventBus: () => mocks.eventBus, })); vi.mock("sonner", () => ({ @@ -85,7 +88,7 @@ vi.mock("@/hooks/useConfirmation", () => ({ import { GitAuthRepairPanel } from "./GitAuthRepairPanel"; beforeEach(() => { - mocks.listen.mockReset(); + mocks.subscribe.mockReset(); mocks.loginGh.mutateAsync.mockReset(); mocks.loginGh.mutateAsync.mockResolvedValue(undefined); mocks.resumeDeferred.mutateAsync.mockReset(); @@ -119,18 +122,60 @@ beforeEach(() => { }); describe("GitAuthRepairPanel — Sign in", () => { + it("subscribes to login prompts for the sign-in operation and unsubscribes after it settles", async () => { + const user = userEvent.setup(); + const unsubscribe = vi.fn(); + let loginPromptHandler: ((event: { payload: unknown }) => void) | undefined; + let resolveLogin: (() => void) | undefined; + mocks.subscribe.mockImplementation((_event, handler) => { + loginPromptHandler = (event) => { + (handler as (payload: unknown) => void)(event.payload); + }; + return unsubscribe; + }); + mocks.loginGh.mutateAsync.mockReturnValue( + new Promise((resolve) => { + resolveLogin = resolve; + }), + ); + + render(); + const clickPromise = user.click(screen.getByTestId("git-auth-login-gh")); + + await waitFor(() => + expect(mocks.subscribe).toHaveBeenCalledWith( + "gh-auth:login_prompt", + expect.any(Function), + ), + ); + loginPromptHandler?.({ + payload: { code: "ABCD-1234", url: "https://github.com/login/device" }, + }); + expect(await screen.findByTestId("gh-auth-login-prompt")).toHaveTextContent( + "ABCD-1234", + ); + expect(unsubscribe).not.toHaveBeenCalled(); + + resolveLogin?.(); + await clickPromise; + + await waitFor(() => expect(unsubscribe).toHaveBeenCalledTimes(1)); + }); + it("clicking Sign in invokes the login mutation and opens the listen pipe", async () => { const user = userEvent.setup(); let listenCallback: ((event: { payload: unknown }) => void) | null = null; - mocks.listen.mockImplementation((_event, cb) => { - listenCallback = cb as typeof listenCallback; - return Promise.resolve(() => undefined); + mocks.subscribe.mockImplementation((_event, cb) => { + listenCallback = (event) => { + (cb as (payload: unknown) => void)(event.payload); + }; + return () => undefined; }); render(); await user.click(screen.getByTestId("git-auth-login-gh")); - expect(mocks.listen).toHaveBeenCalledWith("gh-auth:login_prompt", expect.any(Function)); + expect(mocks.subscribe).toHaveBeenCalledWith("gh-auth:login_prompt", expect.any(Function)); expect(mocks.loginGh.mutateAsync).toHaveBeenCalled(); // Simulate the device-code event payload arriving — it should render GhAuthLoginPrompt. @@ -376,7 +421,7 @@ describe("GitAuthRepairPanel — Sign in", () => { it("Sign in mutation failure surfaces an error toast", async () => { const user = userEvent.setup(); const sonner = await import("sonner"); - mocks.listen.mockResolvedValue(() => undefined); + mocks.subscribe.mockReturnValue(() => undefined); mocks.loginGh.mutateAsync.mockReset(); mocks.loginGh.mutateAsync.mockRejectedValue(new Error("nope")); diff --git a/frontend/src/components/git/GitAuthRepairPanel.tsx b/frontend/src/components/git/GitAuthRepairPanel.tsx index 849bfefac9..5cd165c957 100644 --- a/frontend/src/components/git/GitAuthRepairPanel.tsx +++ b/frontend/src/components/git/GitAuthRepairPanel.tsx @@ -1,4 +1,3 @@ -import { listen } from "@tauri-apps/api/event"; import { AlertTriangle, CheckCircle2, @@ -19,6 +18,7 @@ import { import { Button } from "@/components/ui/button"; import { useConfirmation } from "@/hooks/useConfirmation"; import { useGitHubConnectionStatus } from "@/hooks/useGitHubConnectionStatus"; +import { useEventBus } from "@/providers/EventProvider"; import { useGitAuthDiagnostics, useLoginGhWithBrowser, @@ -94,6 +94,7 @@ export function GitAuthRepairPanel({ showWhenHealthy?: boolean; requiresGhAuth?: boolean; }) { + const eventBus = useEventBus(); const diagnosticsQuery = useGitAuthDiagnostics(projectId); const ghStatusQuery = useGitHubConnectionStatus(); const switchToSshMutation = useSwitchGitOriginToSsh(); @@ -244,9 +245,9 @@ export function GitAuthRepairPanel({ let unlisten: (() => void) | undefined; try { - unlisten = await listen( + unlisten = eventBus.subscribe( GH_AUTH_LOGIN_PROMPT_EVENT, - (event) => mergeLoginPrompt(event.payload), + mergeLoginPrompt, ); await loginGhWithBrowserMutation.mutateAsync(); toast.success("GitHub CLI signed in"); From ca651d98b55b818088851bd0a8e08c2ec1dac03a Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:05:17 +0300 Subject: [PATCH 021/416] feat: migrate UpdateChecker native events to the shared event bus Replace raw Tauri listen() calls for the update-check and release-notes menu events with useEventBus().subscribe(), keeping the enabled/dependency-driven unmount cleanup unchanged. --- .../src/components/UpdateChecker.events.ts | 29 ++++-------- .../src/components/UpdateChecker.test.tsx | 44 ++++++++++++++++--- 2 files changed, 46 insertions(+), 27 deletions(-) diff --git a/frontend/src/components/UpdateChecker.events.ts b/frontend/src/components/UpdateChecker.events.ts index 94b050b097..d3f5b649e9 100644 --- a/frontend/src/components/UpdateChecker.events.ts +++ b/frontend/src/components/UpdateChecker.events.ts @@ -1,5 +1,5 @@ import { useEffect } from "react"; -import { listen, type UnlistenFn } from "@tauri-apps/api/event"; +import { useEventBus } from "@/providers/EventProvider"; const UPDATE_CHECK_EVENT = "ralphx://check-for-updates"; const RELEASE_NOTES_EVENT = "ralphx://show-release-notes"; @@ -13,37 +13,24 @@ export function useUpdateCheckerNativeEvents({ enabled?: boolean; openCurrentReleaseNotes: () => void; }) { + const eventBus = useEventBus(); + useEffect(() => { if (!enabled) { return undefined; } - const unlisteners: UnlistenFn[] = []; - let isMounted = true; - - void listen(UPDATE_CHECK_EVENT, () => { + const unsubscribeUpdateCheck = eventBus.subscribe(UPDATE_CHECK_EVENT, () => { void checkForUpdates({ manual: true, force: true }); - }).then((unlisten) => { - if (isMounted) { - unlisteners.push(unlisten); - } else { - unlisten(); - } }); - void listen(RELEASE_NOTES_EVENT, () => { + const unsubscribeReleaseNotes = eventBus.subscribe(RELEASE_NOTES_EVENT, () => { void openCurrentReleaseNotes(); - }).then((unlisten) => { - if (isMounted) { - unlisteners.push(unlisten); - } else { - unlisten(); - } }); return () => { - isMounted = false; - unlisteners.forEach((unlisten) => unlisten()); + unsubscribeUpdateCheck(); + unsubscribeReleaseNotes(); }; - }, [checkForUpdates, enabled, openCurrentReleaseNotes]); + }, [checkForUpdates, enabled, eventBus, openCurrentReleaseNotes]); } diff --git a/frontend/src/components/UpdateChecker.test.tsx b/frontend/src/components/UpdateChecker.test.tsx index 1b7f6d465d..1013616505 100644 --- a/frontend/src/components/UpdateChecker.test.tsx +++ b/frontend/src/components/UpdateChecker.test.tsx @@ -7,7 +7,7 @@ import { UpdateChecker } from "./UpdateChecker"; const mocks = vi.hoisted(() => ({ check: vi.fn(), - listen: vi.fn(), + subscribe: vi.fn(), relaunch: vi.fn(), toast: vi.fn(), toastDismiss: vi.fn(), @@ -21,8 +21,11 @@ const mocks = vi.hoisted(() => ({ getReleaseNotesForVersion: vi.fn(), fetchReleaseMetadata: vi.fn(), getVersion: vi.fn(), + eventBus: { subscribe: vi.fn(), emit: vi.fn() }, })); +mocks.eventBus.subscribe = mocks.subscribe; + const updateChannelState = vi.hoisted(() => ({ channel: "stable" as "stable" | "nightly", isSettled: true, @@ -47,8 +50,8 @@ vi.mock("@tauri-apps/plugin-process", () => ({ relaunch: (...args: unknown[]) => mocks.relaunch(...args), })); -vi.mock("@tauri-apps/api/event", () => ({ - listen: (...args: unknown[]) => mocks.listen(...args), +vi.mock("@/providers/EventProvider", () => ({ + useEventBus: () => mocks.eventBus, })); vi.mock("@tauri-apps/api/app", () => ({ @@ -124,7 +127,7 @@ describe("UpdateChecker", () => { eventListeners.clear(); mocks.check.mockReset(); - mocks.listen.mockReset(); + mocks.subscribe.mockReset(); mocks.relaunch.mockReset(); mocks.toast.mockReset(); mocks.toastDismiss.mockReset(); @@ -148,8 +151,10 @@ describe("UpdateChecker", () => { mocks.check.mockResolvedValue(update); mocks.relaunch.mockResolvedValue(undefined); - mocks.listen.mockImplementation(async (event: string, handler: (event: unknown) => unknown) => { - eventListeners.set(event, handler); + mocks.subscribe.mockImplementation((event: string, handler: (payload: unknown) => unknown) => { + eventListeners.set(event, (received: unknown) => + handler((received as { payload: unknown }).payload), + ); return vi.fn(); }); mocks.getCurrentReleaseNotes.mockResolvedValue({ @@ -169,6 +174,33 @@ describe("UpdateChecker", () => { })); }); + it("subscribes to both native menu events and unsubscribes on unmount", async () => { + const unsubscribeCheck = vi.fn(); + const unsubscribeReleaseNotes = vi.fn(); + mocks.subscribe + .mockReturnValueOnce(unsubscribeCheck) + .mockReturnValueOnce(unsubscribeReleaseNotes); + + const { unmount } = render(); + await flushAsyncWork(); + + expect(mocks.subscribe).toHaveBeenNthCalledWith( + 1, + "ralphx://check-for-updates", + expect.any(Function), + ); + expect(mocks.subscribe).toHaveBeenNthCalledWith( + 2, + "ralphx://show-release-notes", + expect.any(Function), + ); + + unmount(); + + expect(unsubscribeCheck).toHaveBeenCalledTimes(1); + expect(unsubscribeReleaseNotes).toHaveBeenCalledTimes(1); + }); + afterEach(() => { for (const [options] of mocks.check.mock.calls) { if (options !== undefined) { From 15ae26ef1489ba9e4496ae03fd1d8d75f739cb36 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:05:30 +0300 Subject: [PATCH 022/416] chore: add CI guard against raw Tauri event listen usage Add scripts/check-raw-tauri-event-listen.mjs, which fails when a production frontend file imports listen from @tauri-apps/api/event outside the explicit event-bus allowlist. Wire it into the frontend typecheck script via pretypecheck, and add a fixture-based self-test proving the guard rejects a raw-listen reintroduction and passes on the current tree. --- frontend/package.json | 1 + scripts/check-raw-tauri-event-listen.mjs | 80 +++++++++++++++++++ .../test-raw-tauri-event-listen-guard.sh | 34 ++++++++ 3 files changed, 115 insertions(+) create mode 100644 scripts/check-raw-tauri-event-listen.mjs create mode 100644 scripts/tests/test-raw-tauri-event-listen-guard.sh diff --git a/frontend/package.json b/frontend/package.json index 78be73bea1..079224df72 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "test:check-warnings": "./scripts/check-vitest-warnings.sh", "test:visual": "playwright test tests/visual --workers=1", "test:coverage": "vitest run --coverage --testTimeout=15000 --retry=1", + "pretypecheck": "node ../scripts/check-raw-tauri-event-listen.mjs ..", "typecheck": "tsc --noEmit", "lint": "eslint src", "lint:fix": "eslint src --fix", diff --git a/scripts/check-raw-tauri-event-listen.mjs b/scripts/check-raw-tauri-event-listen.mjs new file mode 100644 index 0000000000..037257ef83 --- /dev/null +++ b/scripts/check-raw-tauri-event-listen.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; + +const repoRoot = path.resolve(process.argv[2] ?? process.cwd()); +const sourceRoot = path.join(repoRoot, "frontend", "src"); + +const RAW_LISTEN_ALLOWLIST = new Set([ + "frontend/src/lib/event-bus.ts", +]); +const EXCLUDED_DIRECTORIES = new Set([ + "frontend/src/mocks", + "frontend/src/test", +]); +const TEST_FILE_PATTERN = /\.(?:test|spec)\.[cm]?[jt]sx?$/; +const SOURCE_FILE_PATTERN = /\.[cm]?[jt]sx?$/; + +function toRepoPath(filePath) { + return path.relative(repoRoot, filePath).split(path.sep).join("/"); +} + +function isExcluded(filePath) { + const repoPath = toRepoPath(filePath); + return ( + TEST_FILE_PATTERN.test(repoPath) || + [...EXCLUDED_DIRECTORIES].some( + (directory) => repoPath === directory || repoPath.startsWith(`${directory}/`), + ) + ); +} + +function sourceFiles(directory) { + return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + return isExcluded(entryPath) ? [] : sourceFiles(entryPath); + } + return entry.isFile() && SOURCE_FILE_PATTERN.test(entry.name) && !isExcluded(entryPath) + ? [entryPath] + : []; + }); +} + +function rawListenImports(source) { + const importPattern = /import\s+([\s\S]*?)\s+from\s+["']@tauri-apps\/api\/event["']/g; + return [...source.matchAll(importPattern)].filter((match) => { + const bindings = match[1] ?? ""; + return /(?:^|[,{\s])listen(?:\s+as\s+[A-Za-z_$][\w$]*)?(?=\s*[,}])/.test(bindings) + || /^\s*\*\s+as\s+/.test(bindings); + }); +} + +if (!fs.existsSync(sourceRoot)) { + console.error(`FAIL: missing frontend source directory: ${toRepoPath(sourceRoot)}`); + process.exit(1); +} + +const violations = []; +for (const filePath of sourceFiles(sourceRoot)) { + const repoPath = toRepoPath(filePath); + if (RAW_LISTEN_ALLOWLIST.has(repoPath)) { + continue; + } + + const source = fs.readFileSync(filePath, "utf8"); + for (const match of rawListenImports(source)) { + const line = source.slice(0, match.index).split("\n").length; + violations.push(`${repoPath}:${line}`); + } +} + +if (violations.length > 0) { + console.error("FAIL: raw Tauri event listen imports must go through useEventBus()."); + console.error(`Allowed raw-listen module: ${[...RAW_LISTEN_ALLOWLIST].join(", ")}`); + violations.forEach((violation) => console.error(` ${violation}`)); + process.exit(1); +} + +console.log("PASS: raw Tauri event listen is confined to frontend/src/lib/event-bus.ts"); diff --git a/scripts/tests/test-raw-tauri-event-listen-guard.sh b/scripts/tests/test-raw-tauri-event-listen-guard.sh new file mode 100644 index 0000000000..60e4501341 --- /dev/null +++ b/scripts/tests/test-raw-tauri-event-listen-guard.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +FIXTURE_ROOT="$(mktemp -d "${ROOT_DIR}/.artifacts/raw-listen-guard.XXXXXX")" +trap 'rm -rf "${FIXTURE_ROOT}"' EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +mkdir -p "${FIXTURE_ROOT}/frontend/src/lib" "${FIXTURE_ROOT}/frontend/src/components" +printf '%s\n' 'import { listen } from "@tauri-apps/api/event";' \ + >"${FIXTURE_ROOT}/frontend/src/lib/event-bus.ts" + +node "${ROOT_DIR}/scripts/check-raw-tauri-event-listen.mjs" "${FIXTURE_ROOT}" \ + >/dev/null || fail "guard rejected its explicit event-bus allowlist" + +printf '%s\n' 'import { listen } from "@tauri-apps/api/event";' \ + 'void listen("drift", () => undefined);' \ + >"${FIXTURE_ROOT}/frontend/src/components/RawListener.tsx" + +if node "${ROOT_DIR}/scripts/check-raw-tauri-event-listen.mjs" "${FIXTURE_ROOT}" \ + >"${FIXTURE_ROOT}/guard.out" 2>&1; then + fail "guard accepted a raw-listen reintroduction" +fi +grep -Fq "frontend/src/components/RawListener.tsx:1" "${FIXTURE_ROOT}/guard.out" \ + || fail "guard failure did not identify the raw-listen fixture" + +node "${ROOT_DIR}/scripts/check-raw-tauri-event-listen.mjs" "${ROOT_DIR}" \ + >/dev/null || fail "guard rejected the current repository tree" + +echo "PASS: raw Tauri listen guard rejects drift and accepts the current tree" From a783e364466a38b7b71bdfdea0d3ce8c8dc1aafa Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:07:20 +0300 Subject: [PATCH 023/416] feat: enable the axum websocket feature for the remote listener PR 1.1 stands up the :3849 router; PR 1.4 adds the event-stream WS upgrade on top of it. No WebSocket code exists under src-tauri/src/http_server/ and the :3847 router is unaffected. --- src-tauri/Cargo.lock | 50 ++++++++++++++++++++++++++++++++++++++++++++ src-tauri/Cargo.toml | 2 +- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 07d6021e65..c46b053fc8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -277,6 +277,7 @@ dependencies = [ "async-trait", "axum-core", "axum-macros", + "base64 0.22.1", "bytes", "futures-util", "http", @@ -295,8 +296,10 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", + "sha1", "sync_wrapper", "tokio", + "tokio-tungstenite", "tower 0.5.3", "tower-layer", "tower-service", @@ -860,6 +863,12 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + [[package]] name = "dbus" version = "0.9.11" @@ -4531,6 +4540,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -5470,6 +5490,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -5759,6 +5791,24 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18e5b8366ee7a95b16d32197d0b2604b43a0be89dc5fac9f8e96ccafbaedda8a" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.8.6", + "sha1", + "thiserror 1.0.69", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 442a77937f..da76e8e49e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -64,7 +64,7 @@ lazy_static = "1.5" tauri-plugin-dialog = "2.7.0" tauri-plugin-global-shortcut = "2.3.1" tauri-plugin-window-state = "2.4.1" -axum = { version = "0.7", features = ["macros"] } +axum = { version = "0.7", features = ["macros", "ws"] } tower-http = { version = "0.5", features = ["cors"] } tauri-plugin-fs = "2.5.0" tauri-plugin-notification = "2.3.3" From eabc2a7f5a84fca3382646b8ec5486a8b5f0651c Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:07:33 +0300 Subject: [PATCH 024/416] feat: add the remote bind-address policy and host settings writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the §4.4 bind policy to remote_server::settings: Serve mode resolves 127.0.0.1 only, tailnet-direct resolves a CGNAT 100.64.0.0/10 address that the host actually owns, and wildcard/LAN/loopback/foreign-tailnet candidates are refused in code through a typed RemoteBindError rather than by UI convention. Tailnet membership sits behind a TailnetSelfAddressProvider seam; the shipped stub reports no tailnet, so direct exposure stays refused until PR 1.6 lands the tailscale status --json provider. A provider read failure is a distinct typed error so it can never be mistaken for "this host owns no tailnet address". RALPHX_REMOTE_PORT mirrors BACKEND_PORT_ENV but is shape-validated into a u16 before it can reach a bind sink; an unparseable override is logged and ignored. Also adds the enabled/exposure-mode writers and a non-minting read used by startup, and drops the two settings-store constructors that had no production caller. --- src-tauri/src/remote_server/settings.rs | 267 ++++++++++++++++-- src-tauri/src/remote_server/settings_tests.rs | 261 ++++++++++++++++- 2 files changed, 493 insertions(+), 35 deletions(-) diff --git a/src-tauri/src/remote_server/settings.rs b/src-tauri/src/remote_server/settings.rs index 28492f2b62..8e2f7add15 100644 --- a/src-tauri/src/remote_server/settings.rs +++ b/src-tauri/src/remote_server/settings.rs @@ -1,17 +1,21 @@ -use std::sync::Arc; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; use rusqlite::Connection; -use tokio::sync::Mutex; +use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::error::{AppError, AppResult}; use crate::infrastructure::sqlite::DbConnection; pub(crate) const DEFAULT_REMOTE_PORT: u16 = 3849; +/// Dev-parity override for the remote listener port, mirroring `BACKEND_PORT_ENV`. +pub(crate) const REMOTE_PORT_ENV: &str = "RALPHX_REMOTE_PORT"; const SETTINGS_ROW_ID: i64 = 1; +const LOOPBACK_BIND_IP: Ipv4Addr = Ipv4Addr::new(127, 0, 0, 1); -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum RemoteExposureMode { +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RemoteExposureMode { Serve, TailnetDirect, } @@ -49,48 +53,249 @@ pub(crate) struct RemoteHostSettingsStore { } impl RemoteHostSettingsStore { - pub(crate) fn new(conn: Connection) -> Self { - Self { - db: DbConnection::new(conn), - } - } - - pub(crate) fn from_shared(conn: Arc>) -> Self { - Self { - db: DbConnection::from_shared(conn), - } + pub(crate) fn from_db(db: DbConnection) -> Self { + Self { db } } /// Returns the singleton settings, creating the disabled default on first access. pub(crate) async fn get_or_create(&self) -> AppResult { self.db - .run_transaction(move |conn| { - if let Some(settings) = read_settings(conn)? { - return Ok(settings); - } + .run_transaction(move |conn| ensure_settings_row(conn)) + .await + } - let environment_id = Uuid::new_v4().to_string(); + /// Reads the singleton settings without minting them. + /// + /// Startup auto-start uses this so a host that never configured remote access keeps an + /// absent row (and therefore never listens). + pub(crate) async fn get(&self) -> AppResult> { + self.db.run(move |conn| read_settings(conn)).await + } + + /// Persists the listener enablement flag, minting the settings row when absent. + pub(crate) async fn set_enabled(&self, enabled: bool) -> AppResult { + self.db + .run_transaction(move |conn| { + ensure_settings_row(conn)?; conn.execute( - "INSERT INTO remote_host_settings ( - id, enabled, exposure_mode, port, environment_id - ) VALUES (?1, 0, ?2, ?3, ?4)", - rusqlite::params![ - SETTINGS_ROW_ID, - RemoteExposureMode::Serve.as_db_value(), - i64::from(DEFAULT_REMOTE_PORT), - environment_id, - ], + "UPDATE remote_host_settings SET enabled = ?1 WHERE id = ?2", + rusqlite::params![i64::from(enabled), SETTINGS_ROW_ID], ) .map_err(|error| AppError::Database(error.to_string()))?; + read_settings_row(conn) + }) + .await + } - read_settings(conn)?.ok_or_else(|| { - AppError::Database("remote host settings row missing after insert".to_string()) - }) + /// Persists the exposure mode, minting the settings row when absent. + pub(crate) async fn set_exposure_mode( + &self, + exposure_mode: RemoteExposureMode, + ) -> AppResult { + self.db + .run_transaction(move |conn| { + ensure_settings_row(conn)?; + conn.execute( + "UPDATE remote_host_settings SET exposure_mode = ?1 WHERE id = ?2", + rusqlite::params![exposure_mode.as_db_value(), SETTINGS_ROW_ID], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + read_settings_row(conn) }) .await } } +fn ensure_settings_row(conn: &Connection) -> AppResult { + if let Some(settings) = read_settings(conn)? { + return Ok(settings); + } + + let environment_id = Uuid::new_v4().to_string(); + conn.execute( + "INSERT INTO remote_host_settings ( + id, enabled, exposure_mode, port, environment_id + ) VALUES (?1, 0, ?2, ?3, ?4)", + rusqlite::params![ + SETTINGS_ROW_ID, + RemoteExposureMode::Serve.as_db_value(), + i64::from(DEFAULT_REMOTE_PORT), + environment_id, + ], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + + read_settings_row(conn) +} + +fn read_settings_row(conn: &Connection) -> AppResult { + read_settings(conn)? + .ok_or_else(|| AppError::Database("remote host settings row is missing".to_string())) +} + +/// Failure to read the host's tailnet membership. +/// +/// The real `tailscale status --json` provider arrives in PR 1.6; this type exists so a +/// provider failure can never be confused with "this host owns no tailnet address". +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub(crate) enum TailnetProviderError { + /// Constructed by PR 1.6's `tailscale status --json` provider; the pre-1.6 stub never fails. + #[allow(dead_code)] + #[error("tailnet status is unavailable: {0}")] + Unavailable(String), +} + +/// Typed refusal reasons for the §4.4 bind-address policy. +/// +/// Every variant is an in-code refusal: the listener never binds a wildcard, LAN, or +/// unverified address regardless of what the settings row or UI asked for. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub(crate) enum RemoteBindError { + #[error("remote listener refuses the wildcard bind address {0}")] + WildcardRefused(IpAddr), + #[error("remote listener refuses the non-tailnet bind address {0}")] + NonTailnetAddressRefused(IpAddr), + #[error("remote listener refuses tailnet address {0}, which this host does not own")] + ForeignTailnetAddressRefused(IpAddr), + #[error("remote listener has no usable tailnet address for direct exposure")] + TailnetAddressUnavailable, + #[error(transparent)] + TailnetStatus(#[from] TailnetProviderError), +} + +/// Source of this host's own tailnet addresses. +/// +/// PR 1.6 replaces the stub implementation with a `tailscale status --json` provider resolved +/// through the shared production CLI resolver; the seam exists now so the bind policy is +/// testable and direct exposure stays refused until that provider lands. +#[async_trait::async_trait] +pub(crate) trait TailnetSelfAddressProvider: Send + Sync { + async fn self_addresses(&self) -> Result, TailnetProviderError>; +} + +/// Stub provider reporting that this host has no tailnet addresses. +/// +/// Consequence: `RemoteExposureMode::TailnetDirect` is refused until PR 1.6 ships the real +/// provider. Serve mode (loopback) is unaffected. +pub(crate) struct UnconfiguredTailnetProvider; + +#[async_trait::async_trait] +impl TailnetSelfAddressProvider for UnconfiguredTailnetProvider { + async fn self_addresses(&self) -> Result, TailnetProviderError> { + Ok(Vec::new()) + } +} + +/// True when `address` falls inside the tailnet CGNAT range `100.64.0.0/10`. +pub(crate) fn is_tailnet_cgnat_ipv4(address: Ipv4Addr) -> bool { + let octets = address.octets(); + octets[0] == 100 && (64..=127).contains(&octets[1]) +} + +/// Validates a candidate direct-exposure bind address against the §4.4 policy. +/// +/// Refuses wildcard addresses, anything outside `100.64.0.0/10` (LAN, loopback, IPv6), and +/// CGNAT addresses this host does not actually own. +pub(crate) fn validate_tailnet_bind_ip( + candidate: IpAddr, + self_addresses: &[IpAddr], +) -> Result { + if candidate.is_unspecified() { + return Err(RemoteBindError::WildcardRefused(candidate)); + } + let IpAddr::V4(candidate_v4) = candidate else { + return Err(RemoteBindError::NonTailnetAddressRefused(candidate)); + }; + if !is_tailnet_cgnat_ipv4(candidate_v4) { + return Err(RemoteBindError::NonTailnetAddressRefused(candidate)); + } + if !self_addresses.contains(&candidate) { + return Err(RemoteBindError::ForeignTailnetAddressRefused(candidate)); + } + Ok(candidate) +} + +/// Computes the socket address the listener may bind for `exposure_mode`. +/// +/// Serve mode is pinned to loopback; direct mode resolves and re-validates a CGNAT self +/// address. There is no code path that yields a wildcard or LAN address. +pub(crate) async fn resolve_bind_address( + exposure_mode: RemoteExposureMode, + port: u16, + provider: &dyn TailnetSelfAddressProvider, +) -> Result { + match exposure_mode { + RemoteExposureMode::Serve => Ok(SocketAddr::from((LOOPBACK_BIND_IP, port))), + RemoteExposureMode::TailnetDirect => { + let self_addresses = provider.self_addresses().await?; + let candidate = self_addresses + .iter() + .copied() + .find(|address| match address { + IpAddr::V4(address) => is_tailnet_cgnat_ipv4(*address), + IpAddr::V6(_) => false, + }) + .ok_or(RemoteBindError::TailnetAddressUnavailable)?; + let validated = validate_tailnet_bind_ip(candidate, &self_addresses)?; + Ok(SocketAddr::new(validated, port)) + } + } +} + +/// Typed rejection reasons for a malformed `RALPHX_REMOTE_PORT` value. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub(crate) enum RemotePortOverrideError { + #[error("value is empty")] + Empty, + #[error("value is not a valid u16 port")] + NotAPort, + #[error("port must be greater than zero")] + Zero, +} + +/// Shape-validates a raw `RALPHX_REMOTE_PORT` value before it can reach a bind sink. +pub(crate) fn parse_remote_port( + raw: Option<&str>, + configured_port: u16, +) -> Result { + let Some(raw) = raw else { + return Ok(configured_port); + }; + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(RemotePortOverrideError::Empty); + } + let port = trimmed + .parse::() + .map_err(|_| RemotePortOverrideError::NotAPort)?; + if port == 0 { + return Err(RemotePortOverrideError::Zero); + } + Ok(port) +} + +/// Resolves the port the listener should use, honouring a validated env override. +/// +/// An unparseable override is logged and ignored rather than propagated to the bind sink. +pub(crate) fn effective_remote_port(configured_port: u16) -> u16 { + match std::env::var(REMOTE_PORT_ENV) { + Ok(value) => match parse_remote_port(Some(value.as_str()), configured_port) { + Ok(port) => port, + Err(error) => { + tracing::warn!( + env_var = REMOTE_PORT_ENV, + value = %value, + configured_port, + %error, + "Ignoring invalid remote listener port override" + ); + configured_port + } + }, + Err(_) => configured_port, + } +} + fn read_settings(conn: &Connection) -> AppResult> { let result = conn.query_row( "SELECT enabled, exposure_mode, port, environment_id diff --git a/src-tauri/src/remote_server/settings_tests.rs b/src-tauri/src/remote_server/settings_tests.rs index ca40b7b31e..57636471a7 100644 --- a/src-tauri/src/remote_server/settings_tests.rs +++ b/src-tauri/src/remote_server/settings_tests.rs @@ -1,7 +1,46 @@ -use super::settings::{RemoteExposureMode, RemoteHostSettingsStore, DEFAULT_REMOTE_PORT}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; + +use super::settings::{ + effective_remote_port, parse_remote_port, resolve_bind_address, validate_tailnet_bind_ip, + RemoteBindError, RemoteExposureMode, RemoteHostSettingsStore, RemotePortOverrideError, + TailnetProviderError, TailnetSelfAddressProvider, UnconfiguredTailnetProvider, + DEFAULT_REMOTE_PORT, +}; +use crate::infrastructure::sqlite::DbConnection; use crate::testing::SqliteTestDb; use uuid::Uuid; +/// Test double standing in for PR 1.6's `tailscale status --json` provider. +struct StaticTailnetProvider { + result: Result, TailnetProviderError>, +} + +impl StaticTailnetProvider { + fn with_addresses(addresses: &[&str]) -> Self { + Self { + result: Ok(addresses + .iter() + .map(|address| address.parse().expect("test address should parse")) + .collect()), + } + } + + fn unavailable() -> Self { + Self { + result: Err(TailnetProviderError::Unavailable( + "tailscale binary not found".to_string(), + )), + } + } +} + +#[async_trait::async_trait] +impl TailnetSelfAddressProvider for StaticTailnetProvider { + async fn self_addresses(&self) -> Result, TailnetProviderError> { + self.result.clone() + } +} + #[tokio::test] async fn first_access_mints_a_disabled_singleton_with_valid_defaults() { let db = SqliteTestDb::new("remote-host-settings-first-access"); @@ -13,7 +52,7 @@ async fn first_access_mints_a_disabled_singleton_with_valid_defaults() { .expect("migration should leave the singleton row absent"); assert_eq!(row_count, 0); }); - let store = RemoteHostSettingsStore::from_shared(db.shared_conn()); + let store = RemoteHostSettingsStore::from_db(DbConnection::from_shared(db.shared_conn())); let settings = store .get_or_create() @@ -37,7 +76,7 @@ async fn first_access_mints_a_disabled_singleton_with_valid_defaults() { #[tokio::test] async fn repeated_access_and_a_reopened_connection_keep_the_environment_id() { let db = SqliteTestDb::new("remote-host-settings-stable-environment-id"); - let first_store = RemoteHostSettingsStore::from_shared(db.shared_conn()); + let first_store = RemoteHostSettingsStore::from_db(DbConnection::from_shared(db.shared_conn())); let first = first_store .get_or_create() .await @@ -46,7 +85,7 @@ async fn repeated_access_and_a_reopened_connection_keep_the_environment_id() { .get_or_create() .await .expect("second access should read settings"); - let reopened_store = RemoteHostSettingsStore::new(db.new_connection()); + let reopened_store = RemoteHostSettingsStore::from_db(DbConnection::new(db.new_connection())); let reopened = reopened_store .get_or_create() .await @@ -64,3 +103,217 @@ async fn repeated_access_and_a_reopened_connection_keep_the_environment_id() { assert_eq!(row_count, 1); }); } + +#[tokio::test] +async fn setters_persist_enablement_and_exposure_mode_on_the_singleton_row() { + let db = SqliteTestDb::new("remote-host-settings-setters"); + let store = RemoteHostSettingsStore::from_db(DbConnection::from_shared(db.shared_conn())); + + let enabled = store + .set_enabled(true) + .await + .expect("enabling should mint and update the row"); + let switched = store + .set_exposure_mode(RemoteExposureMode::TailnetDirect) + .await + .expect("exposure mode should persist"); + let disabled = store + .set_enabled(false) + .await + .expect("disabling should persist"); + + assert!(enabled.enabled); + assert_eq!(enabled.exposure_mode, RemoteExposureMode::Serve); + assert!(switched.enabled); + assert_eq!(switched.exposure_mode, RemoteExposureMode::TailnetDirect); + assert!(!disabled.enabled); + assert_eq!(disabled.exposure_mode, RemoteExposureMode::TailnetDirect); + assert_eq!(enabled.environment_id, disabled.environment_id); + db.with_connection(|conn| { + let row_count: i64 = conn + .query_row("SELECT COUNT(*) FROM remote_host_settings", [], |row| { + row.get(0) + }) + .expect("singleton row should be queryable"); + assert_eq!(row_count, 1); + }); +} + +#[tokio::test] +async fn reading_settings_never_mints_the_row() { + let db = SqliteTestDb::new("remote-host-settings-read-only"); + let store = RemoteHostSettingsStore::from_db(DbConnection::from_shared(db.shared_conn())); + + let missing = store.get().await.expect("read should succeed"); + + assert!(missing.is_none()); + db.with_connection(|conn| { + let row_count: i64 = conn + .query_row("SELECT COUNT(*) FROM remote_host_settings", [], |row| { + row.get(0) + }) + .expect("singleton row should be queryable"); + assert_eq!(row_count, 0); + }); +} + +#[tokio::test] +async fn serve_mode_binds_loopback_only() { + let provider = StaticTailnetProvider::with_addresses(&["100.101.102.103"]); + + let address = resolve_bind_address(RemoteExposureMode::Serve, DEFAULT_REMOTE_PORT, &provider) + .await + .expect("serve mode should resolve a bind address"); + + assert_eq!( + address, + SocketAddr::from((Ipv4Addr::new(127, 0, 0, 1), DEFAULT_REMOTE_PORT)) + ); + assert!(address.ip().is_loopback()); +} + +#[tokio::test] +async fn tailnet_direct_binds_a_validated_cgnat_self_address() { + let provider = StaticTailnetProvider::with_addresses(&["fd7a:115c:a1e0::1", "100.101.102.103"]); + + let address = resolve_bind_address( + RemoteExposureMode::TailnetDirect, + DEFAULT_REMOTE_PORT, + &provider, + ) + .await + .expect("a CGNAT self address should be bindable"); + + assert_eq!( + address, + SocketAddr::from((Ipv4Addr::new(100, 101, 102, 103), DEFAULT_REMOTE_PORT)) + ); +} + +#[tokio::test] +async fn tailnet_direct_is_refused_without_a_tailnet_self_address() { + let lan_only = StaticTailnetProvider::with_addresses(&["192.168.1.20"]); + let stub = UnconfiguredTailnetProvider; + + let lan_error = resolve_bind_address( + RemoteExposureMode::TailnetDirect, + DEFAULT_REMOTE_PORT, + &lan_only, + ) + .await + .expect_err("a LAN address must never satisfy direct exposure"); + let stub_error = resolve_bind_address( + RemoteExposureMode::TailnetDirect, + DEFAULT_REMOTE_PORT, + &stub, + ) + .await + .expect_err("the pre-1.6 stub provider must refuse direct exposure"); + + assert_eq!(lan_error, RemoteBindError::TailnetAddressUnavailable); + assert_eq!(stub_error, RemoteBindError::TailnetAddressUnavailable); +} + +#[tokio::test] +async fn tailnet_direct_fails_closed_when_tailnet_status_cannot_be_read() { + let provider = StaticTailnetProvider::unavailable(); + + let error = resolve_bind_address( + RemoteExposureMode::TailnetDirect, + DEFAULT_REMOTE_PORT, + &provider, + ) + .await + .expect_err("an unreadable tailnet status must not fall back to a bind"); + + assert!(matches!(error, RemoteBindError::TailnetStatus(_))); +} + +#[test] +fn bind_validation_refuses_wildcard_lan_loopback_and_foreign_tailnet_addresses() { + let self_addresses: Vec = vec!["100.101.102.103".parse().expect("valid address")]; + let refuse = |raw: &str| { + validate_tailnet_bind_ip(raw.parse().expect("valid address"), &self_addresses) + .expect_err("address must be refused") + }; + + assert_eq!( + refuse("0.0.0.0"), + RemoteBindError::WildcardRefused("0.0.0.0".parse().expect("valid address")) + ); + assert_eq!( + refuse("::"), + RemoteBindError::WildcardRefused("::".parse().expect("valid address")) + ); + assert_eq!( + refuse("192.168.1.20"), + RemoteBindError::NonTailnetAddressRefused("192.168.1.20".parse().expect("valid address")) + ); + assert_eq!( + refuse("10.0.0.5"), + RemoteBindError::NonTailnetAddressRefused("10.0.0.5".parse().expect("valid address")) + ); + assert_eq!( + refuse("100.128.0.1"), + RemoteBindError::NonTailnetAddressRefused("100.128.0.1".parse().expect("valid address")) + ); + assert_eq!( + refuse("127.0.0.1"), + RemoteBindError::NonTailnetAddressRefused("127.0.0.1".parse().expect("valid address")) + ); + assert_eq!( + refuse("100.64.0.1"), + RemoteBindError::ForeignTailnetAddressRefused("100.64.0.1".parse().expect("valid address")) + ); + assert_eq!( + validate_tailnet_bind_ip( + "100.101.102.103".parse().expect("valid address"), + &self_addresses + ), + Ok("100.101.102.103".parse().expect("valid address")) + ); +} + +#[test] +fn remote_port_override_is_shape_validated_before_any_bind() { + assert_eq!(parse_remote_port(None, DEFAULT_REMOTE_PORT), Ok(3849)); + assert_eq!( + parse_remote_port(Some(" 4100 "), DEFAULT_REMOTE_PORT), + Ok(4100) + ); + assert_eq!( + parse_remote_port(Some(""), DEFAULT_REMOTE_PORT), + Err(RemotePortOverrideError::Empty) + ); + assert_eq!( + parse_remote_port(Some("0"), DEFAULT_REMOTE_PORT), + Err(RemotePortOverrideError::Zero) + ); + assert_eq!( + parse_remote_port(Some("70000"), DEFAULT_REMOTE_PORT), + Err(RemotePortOverrideError::NotAPort) + ); + assert_eq!( + parse_remote_port(Some("3849; rm -rf /"), DEFAULT_REMOTE_PORT), + Err(RemotePortOverrideError::NotAPort) + ); + assert_eq!( + parse_remote_port(Some("0.0.0.0:3849"), DEFAULT_REMOTE_PORT), + Err(RemotePortOverrideError::NotAPort) + ); +} + +#[test] +fn effective_remote_port_falls_back_to_the_configured_port_without_an_override() { + // The override env var is not set in the focused test environment; an ambient value would + // be shape-validated by `parse_remote_port` before reaching a bind sink either way. + if std::env::var(super::settings::REMOTE_PORT_ENV).is_ok() { + return; + } + + assert_eq!( + effective_remote_port(DEFAULT_REMOTE_PORT), + DEFAULT_REMOTE_PORT + ); + assert_eq!(effective_remote_port(4321), 4321); +} From 6355ddec652b96b56488456826ac9fedebb52c5c Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:07:43 +0300 Subject: [PATCH 025/416] feat: add the authenticated remote listener router and lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assembles the :3849 router per the §5.1 layout: endpoints.rs serves the five-field environment descriptor (protocol-crate EnvironmentDescriptor, camelCase, environmentId stable across restarts) plus /health, and mod.rs owns router assembly and listener lifecycle. Every route except the two-entry pre-auth allowlist (/.well-known/ralphx/environment and /remote/v1/auth/pair) is refused by the global middleware slot; PR 1.2 replaces its body with real bearer verification without widening the allowlist. CORS admits only the app origins — the :3847 allow_origin(Any) layer is deliberately not inherited — and OPTIONS passes the slot before the bearer check so preflight cannot 401. Lifecycle order is refuse -> bind -> persist -> spawn, so a refused bind never leaves enabled = true behind and a failed persist releases the socket. Stop persists first, then drains through the CancellationToken and waits for the serve task, so a disable genuinely releases the port before the next enable. Startup auto-start reads the settings row without minting it: an absent or disabled row means nothing listens. Includes the P-16 regression test pinning :3847 to loopback. --- src-tauri/src/remote_server/endpoints.rs | 61 +++ src-tauri/src/remote_server/listener_tests.rs | 499 ++++++++++++++++++ src-tauri/src/remote_server/mod.rs | 405 +++++++++++++- 3 files changed, 963 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/remote_server/endpoints.rs create mode 100644 src-tauri/src/remote_server/listener_tests.rs diff --git a/src-tauri/src/remote_server/endpoints.rs b/src-tauri/src/remote_server/endpoints.rs new file mode 100644 index 0000000000..b30ff77495 --- /dev/null +++ b/src-tauri/src/remote_server/endpoints.rs @@ -0,0 +1,61 @@ +//! Unauthenticated discovery surface for the remote listener. +//! +//! The environment descriptor is deliberately minimal: it is the one pre-auth response a +//! stranger can read, so it publishes identity and version negotiation data only (§3.1, §4.6). + +use std::sync::Arc; + +use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; +use ralphx_remote_protocol::{EnvironmentDescriptor, PROTOCOL_VERSION}; +use serde::Serialize; + +/// Oldest client protocol this host will negotiate with. +/// +/// Host acceptance policy, not protocol shape — it lives here rather than in the protocol +/// crate so a host can tighten it without a protocol revision. +pub(crate) const MIN_CLIENT_PROTOCOL: u32 = PROTOCOL_VERSION; + +/// Shared state for the remote router. +#[derive(Clone)] +pub(crate) struct RemoteRouterState { + environment_id: Arc, +} + +impl RemoteRouterState { + pub(crate) fn new(environment_id: impl Into>) -> Self { + Self { + environment_id: environment_id.into(), + } + } + + pub(crate) fn environment_id(&self) -> &str { + &self.environment_id + } +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RemoteHealthBody { + pub status: &'static str, +} + +/// Builds the five-field descriptor published at `/.well-known/ralphx/environment`. +pub(crate) fn environment_descriptor(environment_id: &str) -> EnvironmentDescriptor { + EnvironmentDescriptor { + environment_id: environment_id.to_string(), + app_version: env!("CARGO_PKG_VERSION").to_string(), + protocol_version: PROTOCOL_VERSION, + min_client_protocol: MIN_CLIENT_PROTOCOL, + platform: std::env::consts::OS.to_string(), + } +} + +pub(crate) async fn environment_descriptor_handler( + State(state): State, +) -> Json { + Json(environment_descriptor(state.environment_id())) +} + +pub(crate) async fn health_handler() -> impl IntoResponse { + (StatusCode::OK, Json(RemoteHealthBody { status: "ok" })) +} diff --git a/src-tauri/src/remote_server/listener_tests.rs b/src-tauri/src/remote_server/listener_tests.rs new file mode 100644 index 0000000000..f252a2c9af --- /dev/null +++ b/src-tauri/src/remote_server/listener_tests.rs @@ -0,0 +1,499 @@ +use std::collections::BTreeSet; +use std::net::SocketAddr; + +use axum::{ + body::Body, + http::{header, Method, Request, StatusCode}, +}; +use ralphx_remote_protocol::PROTOCOL_VERSION; +use serde_json::Value; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tower::ServiceExt; + +use super::endpoints::{environment_descriptor, RemoteRouterState, MIN_CLIENT_PROTOCOL}; +use super::settings::{ + RemoteExposureMode, RemoteHostSettingsStore, UnconfiguredTailnetProvider, REMOTE_PORT_ENV, +}; +use super::{ + allowed_app_origins, apply_exposure_mode, authenticated_remote_routes, auto_start_if_enabled, + remote_router, start_listener, stop_listener, RemoteListenerError, RemoteListenerHandle, + DESCRIPTOR_PATH, HEALTH_PATH, PAIR_PATH, PRE_AUTH_ALLOWLIST, +}; +use crate::infrastructure::sqlite::DbConnection; +use crate::testing::SqliteTestDb; +use crate::utils::backend_endpoint::{ + backend_http_base_url, backend_http_bind_addr, backend_http_port, PRODUCTION_BACKEND_PORT, +}; + +const TEST_APP_ORIGIN: &str = "tauri://localhost"; + +async fn response_body(response: axum::response::Response) -> Value { + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body should be readable"); + serde_json::from_slice(&bytes).expect("response body should be JSON") +} + +fn descriptor_state() -> RemoteRouterState { + RemoteRouterState::new("11111111-2222-3333-4444-555555555555") +} + +fn preflight_request(path: &str, origin: &str) -> Request { + Request::builder() + .method(Method::OPTIONS) + .uri(path) + .header(header::ORIGIN, origin) + .header(header::ACCESS_CONTROL_REQUEST_METHOD, Method::POST.as_str()) + .header( + header::ACCESS_CONTROL_REQUEST_HEADERS, + "authorization,content-type", + ) + .body(Body::empty()) + .expect("preflight request should be valid") +} + +fn get_request(path: &str) -> Request { + Request::builder() + .method(Method::GET) + .uri(path) + .body(Body::empty()) + .expect("request should be valid") +} + +/// Reserves a loopback port and releases it so the listener can claim it. +async fn reserve_loopback_port() -> u16 { + let probe = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("loopback probe socket should bind"); + probe + .local_addr() + .expect("probe socket should report its address") + .port() +} + +fn set_configured_port(db: &SqliteTestDb, port: u16) { + db.with_connection(|conn| { + conn.execute( + "UPDATE remote_host_settings SET port = ?1 WHERE id = 1", + rusqlite::params![i64::from(port)], + ) + .expect("configured port should update"); + }); +} + +async fn http_get_over_socket(address: SocketAddr, path: &str) -> std::io::Result<(u16, String)> { + let mut stream = tokio::net::TcpStream::connect(address).await?; + let request = format!("GET {path} HTTP/1.0\r\nHost: {address}\r\n\r\n"); + stream.write_all(request.as_bytes()).await?; + let mut buffer = Vec::new(); + stream.read_to_end(&mut buffer).await?; + let response = String::from_utf8_lossy(&buffer).to_string(); + let status = response + .split_whitespace() + .nth(1) + .and_then(|status| status.parse::().ok()) + .unwrap_or_default(); + Ok((status, response)) +} + +#[tokio::test] +async fn descriptor_returns_exactly_the_five_camel_case_fields() { + let router = remote_router(descriptor_state()); + + let response = router + .oneshot(get_request(DESCRIPTOR_PATH)) + .await + .expect("descriptor request should complete"); + + assert_eq!(response.status(), StatusCode::OK); + let body = response_body(response).await; + let object = body.as_object().expect("descriptor should be an object"); + let fields = object.keys().cloned().collect::>(); + assert_eq!( + fields, + BTreeSet::from([ + "appVersion".to_string(), + "environmentId".to_string(), + "minClientProtocol".to_string(), + "platform".to_string(), + "protocolVersion".to_string(), + ]) + ); + assert_eq!( + object["environmentId"], + Value::from("11111111-2222-3333-4444-555555555555") + ); + assert_eq!(object["appVersion"], Value::from(env!("CARGO_PKG_VERSION"))); + assert_eq!(object["protocolVersion"], Value::from(PROTOCOL_VERSION)); + assert_eq!( + object["minClientProtocol"], + Value::from(MIN_CLIENT_PROTOCOL) + ); + assert_eq!(object["platform"], Value::from(std::env::consts::OS)); +} + +#[tokio::test] +async fn descriptor_environment_id_survives_a_settings_store_restart() { + let db = SqliteTestDb::new("remote-listener-descriptor-identity"); + let first_store = RemoteHostSettingsStore::from_db(DbConnection::from_shared(db.shared_conn())); + let first = first_store + .get_or_create() + .await + .expect("first access should mint settings"); + let reopened_store = RemoteHostSettingsStore::from_db(DbConnection::new(db.new_connection())); + let reopened = reopened_store + .get_or_create() + .await + .expect("reopened access should read settings"); + + let first_descriptor = environment_descriptor(&first.environment_id); + let reopened_descriptor = environment_descriptor(&reopened.environment_id); + + assert_eq!( + first_descriptor.environment_id, + reopened_descriptor.environment_id + ); + assert_eq!(first_descriptor, reopened_descriptor); +} + +#[tokio::test] +async fn every_non_allowlisted_route_fails_closed_without_a_bearer() { + let router = remote_router(descriptor_state()); + + let health = router + .clone() + .oneshot(get_request(HEALTH_PATH)) + .await + .expect("health request should complete"); + let invoke = router + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/remote/v1/invoke") + .body(Body::empty()) + .expect("request should be valid"), + ) + .await + .expect("invoke request should complete"); + + assert_eq!(health.status(), StatusCode::UNAUTHORIZED); + assert_eq!(invoke.status(), StatusCode::UNAUTHORIZED); + let body = response_body(health).await; + assert_eq!(body["code"], Value::from("REMOTE_UNAUTHORIZED")); +} + +#[test] +fn the_pre_auth_allowlist_holds_exactly_the_descriptor_and_pairing_routes() { + assert_eq!(PRE_AUTH_ALLOWLIST, &[DESCRIPTOR_PATH, PAIR_PATH]); + assert_eq!(DESCRIPTOR_PATH, "/.well-known/ralphx/environment"); + assert_eq!(PAIR_PATH, "/remote/v1/auth/pair"); +} + +#[tokio::test] +async fn preflight_succeeds_without_a_bearer_on_any_remote_route() { + let router = remote_router(descriptor_state()); + + for path in [DESCRIPTOR_PATH, HEALTH_PATH, "/remote/v1/invoke"] { + let response = router + .clone() + .oneshot(preflight_request(path, TEST_APP_ORIGIN)) + .await + .expect("preflight should complete"); + + assert!( + response.status().is_success(), + "preflight for {path} returned {}", + response.status() + ); + assert_eq!( + response + .headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .and_then(|value| value.to_str().ok()), + Some(TEST_APP_ORIGIN) + ); + } +} + +#[tokio::test] +async fn the_auth_slot_itself_lets_options_through_before_the_bearer_check() { + // Proves the allowlist ordering rather than relying on the CORS layer short-circuiting. + let routes = authenticated_remote_routes(descriptor_state()); + + for path in [DESCRIPTOR_PATH, HEALTH_PATH, "/remote/v1/invoke"] { + let response = routes + .clone() + .oneshot( + Request::builder() + .method(Method::OPTIONS) + .uri(path) + .body(Body::empty()) + .expect("request should be valid"), + ) + .await + .expect("options request should complete"); + + assert!( + response.status().is_success(), + "unlayered OPTIONS for {path} returned {}", + response.status() + ); + } +} + +#[tokio::test] +async fn cors_refuses_origins_outside_the_app_origin_list() { + let router = remote_router(descriptor_state()); + + let response = router + .oneshot(preflight_request(DESCRIPTOR_PATH, "https://evil.example")) + .await + .expect("preflight should complete"); + + assert!(response + .headers() + .get(header::ACCESS_CONTROL_ALLOW_ORIGIN) + .is_none()); +} + +#[test] +fn the_app_origin_list_never_admits_arbitrary_origins() { + let origins = allowed_app_origins(); + + assert!(origins.contains(&"tauri://localhost")); + assert!(!origins.contains(&"*")); + assert!(origins.iter().all(|origin| origin.starts_with("tauri://") + || origin.starts_with("http://127.0.0.1:") + || origin.starts_with("http://localhost:"))); +} + +#[tokio::test] +async fn enable_disable_enable_releases_and_reacquires_the_port() { + // Depends on `RALPHX_REMOTE_PORT` being unset, as the focused Rust gate specifies. + assert!( + std::env::var(REMOTE_PORT_ENV).is_err(), + "{REMOTE_PORT_ENV} must be unset for the lifecycle gate" + ); + let db = SqliteTestDb::new("remote-listener-lifecycle"); + let store = RemoteHostSettingsStore::from_db(DbConnection::from_shared(db.shared_conn())); + store + .get_or_create() + .await + .expect("settings should mint before the port is pinned"); + let port = reserve_loopback_port().await; + set_configured_port(&db, port); + let handle = RemoteListenerHandle::new(); + + let first = start_listener(&handle, &store, &UnconfiguredTailnetProvider) + .await + .expect("serve mode should start"); + let (first_status, _) = http_get_over_socket(first, DESCRIPTOR_PATH) + .await + .expect("descriptor should answer on the bound port"); + let enabled_after_start = store + .get() + .await + .expect("settings should read") + .expect("settings row should exist"); + let stopped = stop_listener(&handle, &store) + .await + .expect("stop should succeed"); + let disabled_after_stop = store + .get() + .await + .expect("settings should read") + .expect("settings row should exist"); + let closed = http_get_over_socket(first, DESCRIPTOR_PATH).await; + let second = start_listener(&handle, &store, &UnconfiguredTailnetProvider) + .await + .expect("serve mode should start again on the released port"); + let (second_status, _) = http_get_over_socket(second, DESCRIPTOR_PATH) + .await + .expect("descriptor should answer after the restart"); + stop_listener(&handle, &store) + .await + .expect("final stop should succeed"); + + assert_eq!(first, SocketAddr::from(([127, 0, 0, 1], port))); + assert_eq!(first_status, 200); + assert!(enabled_after_start.enabled); + assert!(stopped); + assert!(!disabled_after_stop.enabled); + assert!(closed.is_err(), "port should be released after a stop"); + assert_eq!(second, first); + assert_eq!(second_status, 200); + assert!(!handle.is_running().await); +} + +#[tokio::test] +async fn starting_an_already_running_listener_is_idempotent() { + let db = SqliteTestDb::new("remote-listener-idempotent-start"); + let store = RemoteHostSettingsStore::from_db(DbConnection::from_shared(db.shared_conn())); + store.get_or_create().await.expect("settings should mint"); + set_configured_port(&db, reserve_loopback_port().await); + let handle = RemoteListenerHandle::new(); + + let first = start_listener(&handle, &store, &UnconfiguredTailnetProvider) + .await + .expect("first start should succeed"); + let second = start_listener(&handle, &store, &UnconfiguredTailnetProvider) + .await + .expect("second start should reuse the running listener"); + stop_listener(&handle, &store) + .await + .expect("stop should succeed"); + + assert_eq!(first, second); +} + +#[tokio::test] +async fn tailnet_direct_start_is_refused_while_the_provider_reports_no_tailnet() { + let db = SqliteTestDb::new("remote-listener-tailnet-refusal"); + let store = RemoteHostSettingsStore::from_db(DbConnection::from_shared(db.shared_conn())); + store + .set_exposure_mode(RemoteExposureMode::TailnetDirect) + .await + .expect("exposure mode should persist"); + let handle = RemoteListenerHandle::new(); + + let error = start_listener(&handle, &store, &UnconfiguredTailnetProvider) + .await + .expect_err("direct exposure must be refused without a validated tailnet address"); + let settings = store + .get() + .await + .expect("settings should read") + .expect("settings row should exist"); + + assert!(matches!(error, RemoteListenerError::Bind(_))); + assert!( + !settings.enabled, + "a refused bind must never persist an enabled listener" + ); + assert!(!handle.is_running().await); +} + +#[tokio::test] +async fn auto_start_does_nothing_without_an_enabling_settings_row() { + let absent_db = SqliteTestDb::new("remote-listener-auto-start-absent"); + let absent_store = + RemoteHostSettingsStore::from_db(DbConnection::from_shared(absent_db.shared_conn())); + let absent_handle = RemoteListenerHandle::new(); + let disabled_db = SqliteTestDb::new("remote-listener-auto-start-disabled"); + let disabled_store = + RemoteHostSettingsStore::from_db(DbConnection::from_shared(disabled_db.shared_conn())); + disabled_store + .get_or_create() + .await + .expect("settings should mint disabled"); + let disabled_handle = RemoteListenerHandle::new(); + + let absent = auto_start_if_enabled(&absent_handle, &absent_store, &UnconfiguredTailnetProvider) + .await + .expect("an absent row is not an error"); + let disabled = auto_start_if_enabled( + &disabled_handle, + &disabled_store, + &UnconfiguredTailnetProvider, + ) + .await + .expect("a disabled row is not an error"); + + assert!(absent.is_none()); + assert!(disabled.is_none()); + assert!(!absent_handle.is_running().await); + assert!(!disabled_handle.is_running().await); + absent_db.with_connection(|conn| { + let row_count: i64 = conn + .query_row("SELECT COUNT(*) FROM remote_host_settings", [], |row| { + row.get(0) + }) + .expect("row count should read"); + assert_eq!(row_count, 0, "auto-start must not mint the settings row"); + }); +} + +#[tokio::test] +async fn auto_start_binds_when_the_persisted_row_enables_the_listener() { + let db = SqliteTestDb::new("remote-listener-auto-start-enabled"); + let store = RemoteHostSettingsStore::from_db(DbConnection::from_shared(db.shared_conn())); + store.set_enabled(true).await.expect("settings should mint"); + set_configured_port(&db, reserve_loopback_port().await); + let handle = RemoteListenerHandle::new(); + + let started = auto_start_if_enabled(&handle, &store, &UnconfiguredTailnetProvider) + .await + .expect("an enabled row should auto-start"); + stop_listener(&handle, &store) + .await + .expect("stop should succeed"); + + assert!(started.is_some()); +} + +#[tokio::test] +async fn changing_exposure_mode_persists_while_the_listener_is_stopped() { + let db = SqliteTestDb::new("remote-listener-exposure-mode"); + let store = RemoteHostSettingsStore::from_db(DbConnection::from_shared(db.shared_conn())); + let handle = RemoteListenerHandle::new(); + + let settings = apply_exposure_mode( + &handle, + &store, + &UnconfiguredTailnetProvider, + RemoteExposureMode::TailnetDirect, + ) + .await + .expect("mode change should persist while stopped"); + + assert_eq!(settings.exposure_mode, RemoteExposureMode::TailnetDirect); + assert!(!handle.is_running().await); +} + +#[tokio::test] +async fn a_refused_exposure_mode_change_leaves_remote_access_disabled() { + let db = SqliteTestDb::new("remote-listener-exposure-mode-refused"); + let store = RemoteHostSettingsStore::from_db(DbConnection::from_shared(db.shared_conn())); + store.get_or_create().await.expect("settings should mint"); + set_configured_port(&db, reserve_loopback_port().await); + let handle = RemoteListenerHandle::new(); + start_listener(&handle, &store, &UnconfiguredTailnetProvider) + .await + .expect("serve mode should start"); + + let error = apply_exposure_mode( + &handle, + &store, + &UnconfiguredTailnetProvider, + RemoteExposureMode::TailnetDirect, + ) + .await + .expect_err("the restart must be refused without a tailnet address"); + let settings = store + .get() + .await + .expect("settings should read") + .expect("settings row should exist"); + + assert!(matches!(error, RemoteListenerError::Bind(_))); + assert!(!settings.enabled); + assert_eq!(settings.exposure_mode, RemoteExposureMode::TailnetDirect); + assert!(!handle.is_running().await); +} + +/// P-16: the :3847 backend stays loopback-pinned; the remote listener never rebinds it. +#[test] +fn the_backend_listener_stays_pinned_to_loopback() { + assert_eq!(PRODUCTION_BACKEND_PORT, 3847); + assert!(backend_http_bind_addr().starts_with("127.0.0.1:")); + assert!(backend_http_base_url().starts_with("http://127.0.0.1:")); + assert_eq!( + backend_http_bind_addr(), + format!("127.0.0.1:{}", backend_http_port()) + ); + assert_ne!( + backend_http_port(), + super::settings::DEFAULT_REMOTE_PORT, + "the remote listener must never share the backend port" + ); +} diff --git a/src-tauri/src/remote_server/mod.rs b/src-tauri/src/remote_server/mod.rs index 508c82ec1b..253c51bc35 100644 --- a/src-tauri/src/remote_server/mod.rs +++ b/src-tauri/src/remote_server/mod.rs @@ -1,8 +1,409 @@ +//! Remote host listener (:3849). +//! +//! Deliberately separate from `http_server` (:3847): that router is trust-by-localhost with no +//! auth middleware and permissive CORS. This one authenticates every route except a two-entry +//! pre-auth allowlist, binds only loopback or a validated tailnet address, and never mounts a +//! :3847 trust-header handler (§2.3, §4.4). + pub mod capture; +pub mod endpoints; +#[cfg(test)] +mod listener_tests; pub mod settings; +#[cfg(test)] +mod settings_tests; #[cfg(debug_assertions)] pub mod transport_spike; #[cfg(all(test, debug_assertions))] mod transport_spike_tests; -#[cfg(test)] -mod settings_tests; + +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::{ + extract::Request, + http::{header, HeaderValue, Method, StatusCode}, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::get, + Json, Router, +}; +use ralphx_remote_protocol::ErrorCode; +use serde::Serialize; +use tauri::Manager; +use tokio::net::TcpListener; +use tokio::sync::{oneshot, Mutex}; +use tokio_util::sync::CancellationToken; +use tower_http::cors::{AllowOrigin, CorsLayer}; + +use crate::error::AppError; +use crate::remote_server::endpoints::{ + environment_descriptor_handler, health_handler, RemoteRouterState, +}; +use crate::remote_server::settings::{ + effective_remote_port, resolve_bind_address, RemoteBindError, RemoteExposureMode, + RemoteHostSettings, RemoteHostSettingsStore, TailnetSelfAddressProvider, + UnconfiguredTailnetProvider, +}; + +pub(crate) const DESCRIPTOR_PATH: &str = "/.well-known/ralphx/environment"; +pub(crate) const PAIR_PATH: &str = "/remote/v1/auth/pair"; +pub(crate) const HEALTH_PATH: &str = "/health"; + +/// Routes reachable before the bearer check. +/// +/// Exactly two: discovery and pairing. PR 1.2 replaces [`remote_auth_slot`]'s body with real +/// bearer verification but must keep this allowlist unchanged (§4.4, A-2). +pub(crate) const PRE_AUTH_ALLOWLIST: &[&str] = &[DESCRIPTOR_PATH, PAIR_PATH]; + +/// Origins the shipped app itself uses. +pub(crate) const PRODUCTION_APP_ORIGINS: &[&str] = &["tauri://localhost"]; + +/// Dev-server origins, admitted only in debug builds. +pub(crate) const DEVELOPMENT_APP_ORIGINS: &[&str] = &[ + "http://127.0.0.1:1420", + "http://localhost:1420", + "http://127.0.0.1:5173", + "http://localhost:5173", +]; + +/// The exact origin list the remote CORS layer admits. +/// +/// Unlike :3847 (`http_server/mod.rs` `allow_origin(Any)`), the remote router never admits an +/// arbitrary origin (C-15). +pub(crate) fn allowed_app_origins() -> Vec<&'static str> { + let mut origins = PRODUCTION_APP_ORIGINS.to_vec(); + if cfg!(debug_assertions) { + origins.extend_from_slice(DEVELOPMENT_APP_ORIGINS); + } + origins +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct RemoteErrorBody { + pub code: ErrorCode, + pub message: String, +} + +/// Typed failure modes for the listener lifecycle. +#[derive(Debug, thiserror::Error)] +pub(crate) enum RemoteListenerError { + #[error(transparent)] + Bind(#[from] RemoteBindError), + #[error("remote listener could not bind {address}: {source}")] + Socket { + address: SocketAddr, + source: std::io::Error, + }, + #[error(transparent)] + Settings(#[from] AppError), +} + +struct ActiveRemoteListener { + shutdown: CancellationToken, + stopped: oneshot::Receiver<()>, + bind_address: SocketAddr, +} + +/// Process-owned handle for the single remote listener. +/// +/// Registered as Tauri managed state so the enable/disable commands and startup auto-start +/// share one listener rather than racing separate binds. +#[derive(Clone)] +pub(crate) struct RemoteListenerHandle { + active: Arc>>, +} + +impl RemoteListenerHandle { + pub(crate) fn new() -> Self { + Self { + active: Arc::new(Mutex::new(None)), + } + } + + pub(crate) async fn bound_address(&self) -> Option { + self.active + .lock() + .await + .as_ref() + .map(|listener| listener.bind_address) + } + + pub(crate) async fn is_running(&self) -> bool { + self.bound_address().await.is_some() + } +} + +impl Default for RemoteListenerHandle { + fn default() -> Self { + Self::new() + } +} + +/// Returns the process-wide listener handle, registering it on first use. +pub(crate) fn remote_listener_handle(app_handle: &tauri::AppHandle) -> RemoteListenerHandle { + if let Some(existing) = app_handle.try_state::() { + return existing.inner().clone(); + } + let created = RemoteListenerHandle::new(); + if app_handle.manage(created.clone()) { + created + } else { + app_handle.state::().inner().clone() + } +} + +/// Full remote router: routes, fail-closed auth slot, restrictive CORS. +pub(crate) fn remote_router(state: RemoteRouterState) -> Router { + authenticated_remote_routes(state).layer(remote_cors_layer()) +} + +/// The remote route stack without the CORS layer. +/// +/// Exposed so tests can prove the auth slot itself lets `OPTIONS` through instead of relying +/// on the CORS layer short-circuiting preflight ahead of it. +pub(crate) fn authenticated_remote_routes(state: RemoteRouterState) -> Router { + Router::new() + .route( + DESCRIPTOR_PATH, + get(environment_descriptor_handler).options(remote_preflight_handler), + ) + .route( + HEALTH_PATH, + get(health_handler).options(remote_preflight_handler), + ) + .fallback(remote_fallback_handler) + .layer(middleware::from_fn(remote_auth_slot)) + .with_state(state) +} + +fn remote_cors_layer() -> CorsLayer { + let origins = allowed_app_origins() + .into_iter() + .filter_map(|origin| HeaderValue::from_str(origin).ok()) + .collect::>(); + + CorsLayer::new() + .allow_origin(AllowOrigin::list(origins)) + .allow_methods([Method::GET, Method::POST, Method::DELETE, Method::OPTIONS]) + .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]) +} + +/// Global fail-closed middleware slot. +/// +/// PR 1.2 lands bearer extraction, hashing, device lookup, and header stripping here. Until +/// then every non-allowlisted route is refused, so no route can accidentally ship unauthenticated. +async fn remote_auth_slot(request: Request, next: Next) -> Response { + if request.method() == Method::OPTIONS { + return next.run(request).await; + } + if PRE_AUTH_ALLOWLIST.contains(&request.uri().path()) { + return next.run(request).await; + } + remote_error_response( + StatusCode::UNAUTHORIZED, + ErrorCode::RemoteUnauthorized, + "Remote authentication is required.", + ) +} + +async fn remote_preflight_handler() -> Response { + StatusCode::NO_CONTENT.into_response() +} + +async fn remote_fallback_handler(method: Method) -> Response { + if method == Method::OPTIONS { + return StatusCode::NO_CONTENT.into_response(); + } + remote_error_response( + StatusCode::NOT_FOUND, + ErrorCode::RemoteCommandUnavailable, + "This remote route is not available.", + ) +} + +fn remote_error_response(status: StatusCode, code: ErrorCode, message: &'static str) -> Response { + ( + status, + Json(RemoteErrorBody { + code, + message: message.to_string(), + }), + ) + .into_response() +} + +/// Binds and serves the remote listener, persisting the enablement flag once the bind succeeds. +/// +/// Ordering is deliberate: refuse → bind → persist → spawn. A refused or failed bind never +/// leaves `enabled = true` behind, and a failed persist releases the socket. +pub(crate) async fn start_listener( + handle: &RemoteListenerHandle, + store: &RemoteHostSettingsStore, + provider: &dyn TailnetSelfAddressProvider, +) -> Result { + let mut active = handle.active.lock().await; + if let Some(listener) = active.as_ref() { + return Ok(listener.bind_address); + } + + let settings = store.get_or_create().await?; + let port = effective_remote_port(settings.port); + let bind_address = match resolve_bind_address(settings.exposure_mode, port, provider).await { + Ok(address) => address, + Err(error) => { + tracing::error!( + %error, + exposure_mode = ?settings.exposure_mode, + "Remote listener bind refused" + ); + return Err(RemoteListenerError::Bind(error)); + } + }; + + let listener = match TcpListener::bind(bind_address).await { + Ok(listener) => listener, + Err(source) => { + tracing::error!(address = %bind_address, %source, "Remote listener failed to bind"); + return Err(RemoteListenerError::Socket { + address: bind_address, + source, + }); + } + }; + let bound_address = match listener.local_addr() { + Ok(address) => address, + Err(source) => { + tracing::error!(address = %bind_address, %source, "Remote listener bind address unreadable"); + return Err(RemoteListenerError::Socket { + address: bind_address, + source, + }); + } + }; + + store.set_enabled(true).await?; + + let shutdown = CancellationToken::new(); + let serve_shutdown = shutdown.clone(); + let (stopped_tx, stopped) = oneshot::channel(); + let router = remote_router(RemoteRouterState::new(settings.environment_id.as_str())); + + tauri::async_runtime::spawn(async move { + match axum::serve(listener, router) + .with_graceful_shutdown(serve_shutdown.cancelled_owned()) + .await + { + Ok(()) => tracing::info!("Remote listener shut down cleanly"), + Err(error) => tracing::error!(%error, "Remote listener stopped unexpectedly"), + } + let _ = stopped_tx.send(()); + }); + + *active = Some(ActiveRemoteListener { + shutdown, + stopped, + bind_address: bound_address, + }); + tracing::info!( + address = %bound_address, + exposure_mode = ?settings.exposure_mode, + "Remote listener started" + ); + Ok(bound_address) +} + +/// Persists the disabled flag, then gracefully drains and releases the port. +/// +/// Returns whether a listener was actually running. +pub(crate) async fn stop_listener( + handle: &RemoteListenerHandle, + store: &RemoteHostSettingsStore, +) -> Result { + let mut active = handle.active.lock().await; + store.set_enabled(false).await?; + let Some(listener) = active.take() else { + tracing::debug!("Remote listener stop requested while it was not running"); + return Ok(false); + }; + + tracing::info!(address = %listener.bind_address, "Remote listener stopping"); + listener.shutdown.cancel(); + // Waiting for the serve task guarantees the port is released before the lock is released, + // so a subsequent enable can re-acquire it. + let _ = listener.stopped.await; + tracing::info!(address = %listener.bind_address, "Remote listener stopped"); + Ok(true) +} + +/// Persists a new exposure mode, restarting a running listener so the bind policy re-applies. +/// +/// A restart that gets refused leaves remote access disabled rather than silently listening on +/// the previous address. +pub(crate) async fn apply_exposure_mode( + handle: &RemoteListenerHandle, + store: &RemoteHostSettingsStore, + provider: &dyn TailnetSelfAddressProvider, + exposure_mode: RemoteExposureMode, +) -> Result { + let was_running = handle.is_running().await; + if was_running { + stop_listener(handle, store).await?; + } + + let settings = store.set_exposure_mode(exposure_mode).await?; + if !was_running { + return Ok(settings); + } + + match start_listener(handle, store, provider).await { + Ok(_) => Ok(store.get_or_create().await?), + Err(error) => { + tracing::error!( + %error, + ?exposure_mode, + "Remote listener could not restart after an exposure-mode change; remote access left disabled" + ); + Err(error) + } + } +} + +/// Startup auto-start. Never mints the settings row: an absent row means nothing listens. +pub(crate) async fn auto_start_if_enabled( + handle: &RemoteListenerHandle, + store: &RemoteHostSettingsStore, + provider: &dyn TailnetSelfAddressProvider, +) -> Result, RemoteListenerError> { + let Some(settings) = store.get().await? else { + tracing::debug!("Remote host settings are absent; remote listener stays off"); + return Ok(None); + }; + if !settings.enabled { + tracing::debug!("Remote host mode is disabled; remote listener stays off"); + return Ok(None); + } + start_listener(handle, store, provider).await.map(Some) +} + +/// Startup hook, invoked from the same setup phase that calls `start_server_boot`. +/// +/// A bind failure here is logged and left alone: the persisted intent stays enabled so a +/// transient port conflict does not silently turn remote access off. +pub(crate) async fn auto_start_remote_listener_from_handle(app_handle: &tauri::AppHandle) { + let Some(state) = app_handle.try_state::() else { + tracing::warn!("AppState is unavailable; skipping remote listener auto-start"); + return; + }; + let store = RemoteHostSettingsStore::from_db(state.db.clone()); + let handle = remote_listener_handle(app_handle); + + match auto_start_if_enabled(&handle, &store, &UnconfiguredTailnetProvider).await { + Ok(Some(address)) => { + tracing::info!(%address, "Remote listener auto-started from persisted settings"); + } + Ok(None) => {} + Err(error) => tracing::error!(%error, "Remote listener auto-start failed"), + } +} From e4daaabc5e6959713321d5f87b8ce44ab12ce82b Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:07:59 +0300 Subject: [PATCH 026/416] feat: wire the remote listener commands and startup auto-start Adds start_remote_listener / stop_remote_listener / set_remote_exposure_mode (plus a read-only status command) as host-local Tauri commands; no equivalent route is mounted on :3849. The listener spawns through tauri::async_runtime::spawn from command and setup context per rule 17. Auto-start runs in the same setup phase that calls start_server_boot, gated on the persisted enabled flag, and a bind failure there is logged without flipping the user's persisted intent off. --- src-tauri/src/application/app_setup.rs | 4 + src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/registry.rs | 4 + .../src/commands/remote_host_commands.rs | 113 ++++++++++++++++++ 4 files changed, 122 insertions(+) create mode 100644 src-tauri/src/commands/remote_host_commands.rs diff --git a/src-tauri/src/application/app_setup.rs b/src-tauri/src/application/app_setup.rs index a0dfabcb93..cfe4924972 100644 --- a/src-tauri/src/application/app_setup.rs +++ b/src-tauri/src/application/app_setup.rs @@ -421,6 +421,10 @@ fn launch_startup_attempt( } return; } + // Remote host mode starts in the same setup phase as the local runtime, but only when + // the persisted `remote_host_settings` row enables it (§5.2). With the flag off or the + // row absent, nothing listens on the remote port. + crate::remote_server::auto_start_remote_listener_from_handle(&app_handle).await; let state = app_handle.state::(); launch_startup_pipeline_from_handle( app_handle.clone(), diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 9aeb9b226b..66614a775d 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -81,6 +81,7 @@ pub mod release_notes_commands; pub mod repository_settings_commands; #[cfg(test)] mod repository_settings_commands_tests; +pub mod remote_host_commands; #[cfg(debug_assertions)] pub mod remote_transport_spike_commands; pub mod research_commands; diff --git a/src-tauri/src/commands/registry.rs b/src-tauri/src/commands/registry.rs index 0b98ffbdaf..6d3b1aac9c 100644 --- a/src-tauri/src/commands/registry.rs +++ b/src-tauri/src/commands/registry.rs @@ -21,6 +21,10 @@ macro_rules! register_tauri_commands { commands::notification_commands::get_unread_notification_count, #[cfg(debug_assertions)] commands::notification_commands::debug_send_test_notification, + commands::remote_host_commands::start_remote_listener, + commands::remote_host_commands::stop_remote_listener, + commands::remote_host_commands::set_remote_exposure_mode, + commands::remote_host_commands::get_remote_listener_status, #[cfg(debug_assertions)] commands::remote_transport_spike_commands::debug_start_remote_transport_cors_probe, #[cfg(debug_assertions)] diff --git a/src-tauri/src/commands/remote_host_commands.rs b/src-tauri/src/commands/remote_host_commands.rs new file mode 100644 index 0000000000..d764d689ce --- /dev/null +++ b/src-tauri/src/commands/remote_host_commands.rs @@ -0,0 +1,113 @@ +//! Host-local Tauri commands for the remote listener (§5.2). +//! +//! These are loopback-only by construction: no equivalent route is mounted on :3849 (§3.1). + +use serde::{Deserialize, Serialize}; +use tauri::State; + +use crate::remote_server::settings::{ + RemoteExposureMode, RemoteHostSettings, RemoteHostSettingsStore, UnconfiguredTailnetProvider, +}; +use crate::remote_server::{ + apply_exposure_mode, remote_listener_handle, start_listener, stop_listener, + RemoteListenerHandle, +}; +use crate::AppState; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteListenerStatus { + pub enabled: bool, + pub exposure_mode: RemoteExposureMode, + pub port: u16, + pub environment_id: String, + pub running: bool, + pub bind_address: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetRemoteExposureModeInput { + pub exposure_mode: RemoteExposureMode, +} + +fn settings_store(state: &State<'_, AppState>) -> RemoteHostSettingsStore { + RemoteHostSettingsStore::from_db(state.db.clone()) +} + +async fn listener_status( + settings: RemoteHostSettings, + handle: &RemoteListenerHandle, +) -> RemoteListenerStatus { + let bind_address = handle.bound_address().await; + RemoteListenerStatus { + enabled: settings.enabled, + exposure_mode: settings.exposure_mode, + port: settings.port, + environment_id: settings.environment_id, + running: bind_address.is_some(), + bind_address: bind_address.map(|address| address.to_string()), + } +} + +/// Enables remote host mode and binds the listener for the persisted exposure mode. +#[tauri::command] +pub async fn start_remote_listener( + state: State<'_, AppState>, + app: tauri::AppHandle, +) -> Result { + let store = settings_store(&state); + let handle = remote_listener_handle(&app); + start_listener(&handle, &store, &UnconfiguredTailnetProvider) + .await + .map_err(|error| error.to_string())?; + let settings = store.get_or_create().await.map_err(|e| e.to_string())?; + Ok(listener_status(settings, &handle).await) +} + +/// Disables remote host mode and releases the port. +#[tauri::command] +pub async fn stop_remote_listener( + state: State<'_, AppState>, + app: tauri::AppHandle, +) -> Result { + let store = settings_store(&state); + let handle = remote_listener_handle(&app); + stop_listener(&handle, &store) + .await + .map_err(|error| error.to_string())?; + let settings = store.get_or_create().await.map_err(|e| e.to_string())?; + Ok(listener_status(settings, &handle).await) +} + +/// Persists the exposure mode, restarting a running listener under the new bind policy. +#[tauri::command] +pub async fn set_remote_exposure_mode( + input: SetRemoteExposureModeInput, + state: State<'_, AppState>, + app: tauri::AppHandle, +) -> Result { + let store = settings_store(&state); + let handle = remote_listener_handle(&app); + let settings = apply_exposure_mode( + &handle, + &store, + &UnconfiguredTailnetProvider, + input.exposure_mode, + ) + .await + .map_err(|error| error.to_string())?; + Ok(listener_status(settings, &handle).await) +} + +/// Reads the current listener status without changing it. +#[tauri::command] +pub async fn get_remote_listener_status( + state: State<'_, AppState>, + app: tauri::AppHandle, +) -> Result { + let store = settings_store(&state); + let handle = remote_listener_handle(&app); + let settings = store.get_or_create().await.map_err(|e| e.to_string())?; + Ok(listener_status(settings, &handle).await) +} From 563b9ee2afebe84c36bd9ea51c314f08bff512d1 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:26:45 +0300 Subject: [PATCH 027/416] fix: expose non-throwing readiness signal on EventBus.subscribe Add an additive Unsubscribe.ready promise so callers can await native listener registration before starting a producer, restoring the ordering the old raw listen() await used to guarantee. TauriEventBus derives readiness from the existing handled listen() promise chain; MockEventBus resolves immediately since registration is synchronous. Also documents that the returned unsubscribe function must never throw. --- frontend/src/lib/event-bus.test.ts | 16 ++++++++++++++-- frontend/src/lib/event-bus.ts | 13 +++++++++---- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/frontend/src/lib/event-bus.test.ts b/frontend/src/lib/event-bus.test.ts index 4d4a89e7ee..df47caab95 100644 --- a/frontend/src/lib/event-bus.test.ts +++ b/frontend/src/lib/event-bus.test.ts @@ -26,13 +26,19 @@ describe("TauriEventBus", () => { const handler = vi.fn(); const unsubscribe = new TauriEventBus().subscribe("agent:test", handler); + let ready = false; + void unsubscribe.ready.then(() => { + ready = true; + }); nativeHandler?.({ payload: "early" }); expect(handler).not.toHaveBeenCalled(); + expect(ready).toBe(false); resolveListen?.(unlisten); - await flushAsyncCleanup(); + await unsubscribe.ready; + expect(ready).toBe(true); expect(handler).toHaveBeenCalledWith("early"); nativeHandler?.({ payload: "late" }); @@ -93,7 +99,7 @@ describe("TauriEventBus", () => { vi.mocked(listen).mockRejectedValueOnce(new Error("listen failed")); const unsubscribe = new TauriEventBus().subscribe("agent:test", vi.fn()); - await flushAsyncCleanup(); + await expect(unsubscribe.ready).resolves.toBeUndefined(); unsubscribe(); await flushAsyncCleanup(); @@ -155,6 +161,12 @@ describe("TauriEventBus", () => { }); describe("MockEventBus", () => { + it("reports synchronous registration as immediately ready", async () => { + const unsubscribe = new MockEventBus().subscribe("agent:test", vi.fn()); + + await expect(unsubscribe.ready).resolves.toBeUndefined(); + }); + it("supports subscribe, emit, listener count, unsubscribe, and clear", () => { const bus = new MockEventBus(); const handler = vi.fn(); diff --git a/frontend/src/lib/event-bus.ts b/frontend/src/lib/event-bus.ts index 10c730b145..58dbbfca01 100644 --- a/frontend/src/lib/event-bus.ts +++ b/frontend/src/lib/event-bus.ts @@ -13,9 +13,10 @@ import { listen, emit, type UnlistenFn, type Event } from "@tauri-apps/api/event import { isTauriMode } from "./tauri-detection"; /** - * Unsubscribe function returned by subscribe() + * Unsubscribe function returned by subscribe(). The function must never throw. + * `ready` resolves once native listener registration has settled, including failure. */ -export type Unsubscribe = () => void; +export type Unsubscribe = (() => void) & { ready: Promise }; /** * Event handler function @@ -112,7 +113,7 @@ export class TauriEventBus implements EventBus { this.unlisteners.get(event)!.add(unlistenPromise); // Return unsubscribe function - return () => { + const unsubscribe = () => { if (isUnsubscribed) { return; } @@ -135,6 +136,8 @@ export class TauriEventBus implements EventBus { this.readyListeners.delete(subscriptionId); }); }; + unsubscribe.ready = unlistenPromise.then(() => undefined); + return unsubscribe; } emit(event: string, payload: T): void { @@ -163,9 +166,11 @@ export class MockEventBus implements EventBus { this.listeners.get(event)!.add(typedHandler); // Return unsubscribe function - return () => { + const unsubscribe = () => { this.listeners.get(event)?.delete(typedHandler); }; + unsubscribe.ready = Promise.resolve(); + return unsubscribe; } emit(event: string, payload: T): void { From a83bd527b6b30f56edb618759d4aca42dfcee2e8 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:26:50 +0300 Subject: [PATCH 028/416] fix: await listener readiness before starting the agent terminal AgentTerminalDrawer started openAgentTerminal immediately after eventBus.subscribe() returned, but subscribe() is synchronous while native registration is still async, so early terminal output could be missed. Await unsubscribe.ready first, and re-anchor the disposal check to run right after that wait instead of leaving it dead. --- .../src/components/agents/AgentTerminalDrawer.test.tsx | 8 +++++--- frontend/src/components/agents/AgentTerminalDrawer.tsx | 4 +++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/agents/AgentTerminalDrawer.test.tsx b/frontend/src/components/agents/AgentTerminalDrawer.test.tsx index 8126c3e7f7..03b1b696ec 100644 --- a/frontend/src/components/agents/AgentTerminalDrawer.test.tsx +++ b/frontend/src/components/agents/AgentTerminalDrawer.test.tsx @@ -96,6 +96,8 @@ const workspace = ( ...overrides, }); +const readyUnsubscribe = () => Object.assign(vi.fn(), { ready: Promise.resolve() }); + describe("AgentTerminalDrawer", () => { let rafCallbacks: FrameRequestCallback[]; @@ -136,7 +138,7 @@ describe("AgentTerminalDrawer", () => { dragOverDock: null, }); - subscribeMock.mockReturnValue(vi.fn()); + subscribeMock.mockReturnValue(readyUnsubscribe()); terminalEventSafeParseMock.mockReturnValue({ success: false }); openAgentTerminalMock.mockResolvedValue({ status: "running", @@ -278,7 +280,7 @@ describe("AgentTerminalDrawer", () => { terminalEventListener = (event) => { (listener as (payload: unknown) => void)(event.payload); }; - return vi.fn(); + return readyUnsubscribe(); }); terminalEventSafeParseMock.mockImplementation((payload) => ({ success: true, @@ -882,7 +884,7 @@ describe("AgentTerminalDrawer", () => { it("unsubscribes from terminal events on unmount", async () => { const dockElement = document.createElement("div"); document.body.appendChild(dockElement); - const unsubscribe = vi.fn(); + const unsubscribe = readyUnsubscribe(); subscribeMock.mockReturnValue(unsubscribe); const { unmount } = render( diff --git a/frontend/src/components/agents/AgentTerminalDrawer.tsx b/frontend/src/components/agents/AgentTerminalDrawer.tsx index c2ecb868d0..de902c6e43 100644 --- a/frontend/src/components/agents/AgentTerminalDrawer.tsx +++ b/frontend/src/components/agents/AgentTerminalDrawer.tsx @@ -57,6 +57,7 @@ import { TooltipTrigger, } from "@/components/ui/tooltip"; import { formatBranchDisplay } from "@/lib/branch-utils"; +import type { Unsubscribe } from "@/lib/event-bus"; import { useEventBus } from "@/providers/EventProvider"; import { RALPHX_TERMINAL_DOCK_DRAG_TYPE, @@ -415,7 +416,7 @@ export function AgentTerminalDrawer({ let initFrame: number | null = null; let initTimer: number | null = null; let resizeObserver: ResizeObserver | null = null; - let unsubscribe: (() => void) | null = null; + let unsubscribe: Unsubscribe | null = null; const scheduleFit = () => { if (resizeFrame !== null) { @@ -458,6 +459,7 @@ export function AgentTerminalDrawer({ applyEvent(parsed.data); } }); + await unsubscribe.ready; if (disposed) { return; From 9a9b3640cd580d3081bcd42ea56ecc7e43545c55 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:26:53 +0300 Subject: [PATCH 029/416] fix: await listener readiness before starting the gh-auth login flow GitAuthRepairPanel started the browser sign-in mutation immediately after subscribing to gh-auth:login_prompt, risking a missed one-time device code if native registration hadn't settled yet. Await unlisten.ready before invoking the mutation. --- frontend/src/components/git/GitAuthRepairPanel.test.tsx | 9 ++++++--- frontend/src/components/git/GitAuthRepairPanel.tsx | 4 +++- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/git/GitAuthRepairPanel.test.tsx b/frontend/src/components/git/GitAuthRepairPanel.test.tsx index 7298aa318e..611eb40b85 100644 --- a/frontend/src/components/git/GitAuthRepairPanel.test.tsx +++ b/frontend/src/components/git/GitAuthRepairPanel.test.tsx @@ -87,8 +87,11 @@ vi.mock("@/hooks/useConfirmation", () => ({ import { GitAuthRepairPanel } from "./GitAuthRepairPanel"; +const readyUnsubscribe = () => Object.assign(vi.fn(), { ready: Promise.resolve() }); + beforeEach(() => { mocks.subscribe.mockReset(); + mocks.subscribe.mockReturnValue(readyUnsubscribe()); mocks.loginGh.mutateAsync.mockReset(); mocks.loginGh.mutateAsync.mockResolvedValue(undefined); mocks.resumeDeferred.mutateAsync.mockReset(); @@ -124,7 +127,7 @@ beforeEach(() => { describe("GitAuthRepairPanel — Sign in", () => { it("subscribes to login prompts for the sign-in operation and unsubscribes after it settles", async () => { const user = userEvent.setup(); - const unsubscribe = vi.fn(); + const unsubscribe = readyUnsubscribe(); let loginPromptHandler: ((event: { payload: unknown }) => void) | undefined; let resolveLogin: (() => void) | undefined; mocks.subscribe.mockImplementation((_event, handler) => { @@ -169,7 +172,7 @@ describe("GitAuthRepairPanel — Sign in", () => { listenCallback = (event) => { (cb as (payload: unknown) => void)(event.payload); }; - return () => undefined; + return readyUnsubscribe(); }); render(); @@ -421,7 +424,7 @@ describe("GitAuthRepairPanel — Sign in", () => { it("Sign in mutation failure surfaces an error toast", async () => { const user = userEvent.setup(); const sonner = await import("sonner"); - mocks.subscribe.mockReturnValue(() => undefined); + mocks.subscribe.mockReturnValue(readyUnsubscribe()); mocks.loginGh.mutateAsync.mockReset(); mocks.loginGh.mutateAsync.mockRejectedValue(new Error("nope")); diff --git a/frontend/src/components/git/GitAuthRepairPanel.tsx b/frontend/src/components/git/GitAuthRepairPanel.tsx index 5cd165c957..6b102e3c47 100644 --- a/frontend/src/components/git/GitAuthRepairPanel.tsx +++ b/frontend/src/components/git/GitAuthRepairPanel.tsx @@ -18,6 +18,7 @@ import { import { Button } from "@/components/ui/button"; import { useConfirmation } from "@/hooks/useConfirmation"; import { useGitHubConnectionStatus } from "@/hooks/useGitHubConnectionStatus"; +import type { Unsubscribe } from "@/lib/event-bus"; import { useEventBus } from "@/providers/EventProvider"; import { useGitAuthDiagnostics, @@ -242,13 +243,14 @@ export function GitAuthRepairPanel({ const handleLoginGhWithBrowser = async () => { setLoginPrompt(null); - let unlisten: (() => void) | undefined; + let unlisten: Unsubscribe | undefined; try { unlisten = eventBus.subscribe( GH_AUTH_LOGIN_PROMPT_EVENT, mergeLoginPrompt, ); + await unlisten.ready; await loginGhWithBrowserMutation.mutateAsync(); toast.success("GitHub CLI signed in"); await resumeDeferredStartupIfHealthy(); From 62bcb88fb71313add2b61ef15da60c6bd53c3237 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:27:01 +0300 Subject: [PATCH 030/416] fix: close guard gaps for once/re-export drift and clean checkouts The raw-listen guard only matched named `listen` imports, so `once(...)` and `export { listen } from "@tauri-apps/api/event"` re-exports could bypass it entirely. Extend detection to both import and export forms, both listen and once bindings, and namespace forms, with self-test fixtures proving each new case fails the guard. Also mkdir -p .artifacts before mktemp so the self-test doesn't fail on a clean checkout where .artifacts/ doesn't exist yet. Deferred: dynamic import(), require(), and getCurrentWebview().listen() call forms remain unguarded; tracked in .artifacts/codex-pr18-tracker.md. --- scripts/check-raw-tauri-event-listen.mjs | 12 +++++----- .../test-raw-tauri-event-listen-guard.sh | 24 +++++++++++++++++++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/scripts/check-raw-tauri-event-listen.mjs b/scripts/check-raw-tauri-event-listen.mjs index 037257ef83..9b8dbd731b 100644 --- a/scripts/check-raw-tauri-event-listen.mjs +++ b/scripts/check-raw-tauri-event-listen.mjs @@ -42,12 +42,12 @@ function sourceFiles(directory) { }); } -function rawListenImports(source) { - const importPattern = /import\s+([\s\S]*?)\s+from\s+["']@tauri-apps\/api\/event["']/g; - return [...source.matchAll(importPattern)].filter((match) => { +function rawEventImports(source) { + const importOrExportPattern = /(?:import|export)\s+([\s\S]*?)\s+from\s+["']@tauri-apps\/api\/event["']/g; + return [...source.matchAll(importOrExportPattern)].filter((match) => { const bindings = match[1] ?? ""; - return /(?:^|[,{\s])listen(?:\s+as\s+[A-Za-z_$][\w$]*)?(?=\s*[,}])/.test(bindings) - || /^\s*\*\s+as\s+/.test(bindings); + return /(?:^|[,{\s])(?:listen|once)(?:\s+as\s+[A-Za-z_$][\w$]*)?(?=\s*[,}])/.test(bindings) + || /^\s*\*/.test(bindings); }); } @@ -64,7 +64,7 @@ for (const filePath of sourceFiles(sourceRoot)) { } const source = fs.readFileSync(filePath, "utf8"); - for (const match of rawListenImports(source)) { + for (const match of rawEventImports(source)) { const line = source.slice(0, match.index).split("\n").length; violations.push(`${repoPath}:${line}`); } diff --git a/scripts/tests/test-raw-tauri-event-listen-guard.sh b/scripts/tests/test-raw-tauri-event-listen-guard.sh index 60e4501341..5d0613c30c 100644 --- a/scripts/tests/test-raw-tauri-event-listen-guard.sh +++ b/scripts/tests/test-raw-tauri-event-listen-guard.sh @@ -2,6 +2,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +mkdir -p "${ROOT_DIR}/.artifacts" FIXTURE_ROOT="$(mktemp -d "${ROOT_DIR}/.artifacts/raw-listen-guard.XXXXXX")" trap 'rm -rf "${FIXTURE_ROOT}"' EXIT @@ -27,6 +28,29 @@ if node "${ROOT_DIR}/scripts/check-raw-tauri-event-listen.mjs" "${FIXTURE_ROOT}" fi grep -Fq "frontend/src/components/RawListener.tsx:1" "${FIXTURE_ROOT}/guard.out" \ || fail "guard failure did not identify the raw-listen fixture" +rm "${FIXTURE_ROOT}/frontend/src/components/RawListener.tsx" + +printf '%s\n' 'import { once } from "@tauri-apps/api/event";' \ + 'void once("drift", () => undefined);' \ + >"${FIXTURE_ROOT}/frontend/src/components/RawOnceListener.tsx" + +if node "${ROOT_DIR}/scripts/check-raw-tauri-event-listen.mjs" "${FIXTURE_ROOT}" \ + >"${FIXTURE_ROOT}/guard.out" 2>&1; then + fail "guard accepted a raw-once reintroduction" +fi +grep -Fq "frontend/src/components/RawOnceListener.tsx:1" "${FIXTURE_ROOT}/guard.out" \ + || fail "guard failure did not identify the raw-once fixture" +rm "${FIXTURE_ROOT}/frontend/src/components/RawOnceListener.tsx" + +printf '%s\n' 'export { listen } from "@tauri-apps/api/event";' \ + >"${FIXTURE_ROOT}/frontend/src/components/RawListenerExport.ts" + +if node "${ROOT_DIR}/scripts/check-raw-tauri-event-listen.mjs" "${FIXTURE_ROOT}" \ + >"${FIXTURE_ROOT}/guard.out" 2>&1; then + fail "guard accepted a raw-listen re-export" +fi +grep -Fq "frontend/src/components/RawListenerExport.ts:1" "${FIXTURE_ROOT}/guard.out" \ + || fail "guard failure did not identify the raw-listen re-export fixture" node "${ROOT_DIR}/scripts/check-raw-tauri-event-listen.mjs" "${ROOT_DIR}" \ >/dev/null || fail "guard rejected the current repository tree" From a73cbb510dc40d42daf55fc49de442c91d146952 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:27:06 +0300 Subject: [PATCH 031/416] chore: run the raw-listen guard self-test in CI Wire scripts/tests/test-raw-tauri-event-listen-guard.sh into the Automation Config job, following the existing single-purpose scripts/tests/test-*.sh step pattern, and add both guard files to the automation paths-filter so the job runs when they change. --- .github/workflows/ci.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 748660ff28..d1093c9f0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,6 +44,8 @@ jobs: - 'scripts/test-rust-fast.sh' - 'scripts/tests/test-ci-rust-full-integration-targets.sh' - 'scripts/tests/test-coverage-rust-shards.sh' + - 'scripts/check-raw-tauri-event-listen.mjs' + - 'scripts/tests/test-raw-tauri-event-listen-guard.sh' - 'scripts/event-manifest-scanner/**' - 'scripts/event-manifest.json' - 'scripts/build-prod-release.sh' @@ -160,6 +162,10 @@ jobs: shell: bash run: bash scripts/tests/test-coverage-rust-shards.sh + - name: Validate raw Tauri event listen guard + shell: bash + run: bash scripts/tests/test-raw-tauri-event-listen-guard.sh + - name: Validate release scripts shell: bash run: | From 5bd664c8d40c90b4948f7d2a769071367c4bb8a0 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:44:00 +0300 Subject: [PATCH 032/416] feat: share the key_crypto generator across remote credential prefixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extracts generate_prefixed_key so the :3849 device tokens (rxd_live_), pairing codes (rxp_), and WS tickets (rxt_) mint from the same 32-alphanumeric random source as the :3848 api keys, with hash_key reused unchanged. ~190 bits of entropy is why an unsalted SHA-256 stays sufficient and no KDF is added (§4.1). Scope also gains Hash/Ord so a device's grant can be held as a canonical, de-duplicated set; the serialized vocabulary is untouched. --- .../crates/ralphx-remote-protocol/src/lib.rs | 2 +- src-tauri/src/domain/services/key_crypto.rs | 21 +++++++++++++++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src-tauri/crates/ralphx-remote-protocol/src/lib.rs b/src-tauri/crates/ralphx-remote-protocol/src/lib.rs index 8df929510c..58faa078e7 100644 --- a/src-tauri/crates/ralphx-remote-protocol/src/lib.rs +++ b/src-tauri/crates/ralphx-remote-protocol/src/lib.rs @@ -13,7 +13,7 @@ pub struct EnvironmentDescriptor { pub platform: String, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub enum Scope { #[serde(rename = "ui:read")] UiRead, diff --git a/src-tauri/src/domain/services/key_crypto.rs b/src-tauri/src/domain/services/key_crypto.rs index 76540b5a81..042b96155b 100644 --- a/src-tauri/src/domain/services/key_crypto.rs +++ b/src-tauri/src/domain/services/key_crypto.rs @@ -1,18 +1,35 @@ // Pure crypto functions for API key management. // No infrastructure dependencies — safe to use from any layer. +/// Number of random alphanumeric characters in every raw credential this module mints. +/// +/// 32 characters drawn from a 62-symbol alphabet is ~190 bits of entropy, which is why +/// [`hash_key`]'s unsalted SHA-256 is sufficient for credentials minted here and no KDF is +/// needed (remote-multi-env §4.1). +pub const RAW_KEY_RANDOM_CHARS: usize = 32; + /// Generate a new raw API key in the format: rxk_live_{32 random alphanumeric chars} pub fn generate_raw_key() -> String { + generate_prefixed_key("rxk_live_") +} + +/// Generate a raw credential as `{prefix}{32 random alphanumeric chars}`. +/// +/// Shared generator behind the :3848 API keys (`rxk_live_`) and the :3849 remote-access +/// credentials — device tokens (`rxd_live_`), pairing codes (`rxp_`), and WS tickets +/// (`rxt_`). Only the prefix differs so credentials stay visually distinguishable and +/// greppable while the entropy source stays in one place. +pub fn generate_prefixed_key(prefix: &str) -> String { use rand::Rng; const CHARSET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; let mut rng = rand::thread_rng(); - let random_part: String = (0..32) + let random_part: String = (0..RAW_KEY_RANDOM_CHARS) .map(|_| { let idx = rng.gen_range(0..CHARSET.len()); CHARSET[idx] as char }) .collect(); - format!("rxk_live_{}", random_part) + format!("{prefix}{random_part}") } /// SHA-256 hash a raw key for storage (only hash is stored, never raw key) From ddd6112d06e3f6bc20166d069f515fef4dc4ab4b Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:44:07 +0300 Subject: [PATCH 033/416] feat: add the remote access auth schema migration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forward-only migration creating the five §4.3 tables — remote_devices, remote_pairing_codes, remote_sessions, remote_ws_tickets, remote_audit_log — versioned after the PR 1.1 remote_host_settings migration. Credential columns are hash-only and UNIQUE; there is no plaintext code or token column and no seeded device, so a freshly migrated host has zero paired devices and no bootstrap exception (A-2, A-9). --- .../infrastructure/sqlite/migrations/mod.rs | 10 +- .../migrations/v20260727180000_remote_auth.rs | 76 ++++++++++ .../v20260727180000_remote_auth_tests.rs | 139 ++++++++++++++++++ 3 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 src-tauri/src/infrastructure/sqlite/migrations/v20260727180000_remote_auth.rs create mode 100644 src-tauri/src/infrastructure/sqlite/migrations/v20260727180000_remote_auth_tests.rs diff --git a/src-tauri/src/infrastructure/sqlite/migrations/mod.rs b/src-tauri/src/infrastructure/sqlite/migrations/mod.rs index 095b0204c1..41d122f3f0 100644 --- a/src-tauri/src/infrastructure/sqlite/migrations/mod.rs +++ b/src-tauri/src/infrastructure/sqlite/migrations/mod.rs @@ -554,6 +554,9 @@ mod v20260724222347_agent_task_assignment_planned_run_identity_tests; mod v20260727161131_remote_host_settings; #[cfg(test)] mod v20260727161131_remote_host_settings_tests; +mod v20260727180000_remote_auth; +#[cfg(test)] +mod v20260727180000_remote_auth_tests; #[cfg(test)] pub(super) fn migrate_scripted_agent_workflows_for_test(conn: &Connection) -> AppResult<()> { v20260715194617_scripted_agent_workflows::migrate(conn) @@ -648,7 +651,7 @@ mod v8_task_git_fields_tests; mod v9_project_git_fields_tests; /// Current schema version - bump this when adding a new migration -pub const SCHEMA_VERSION: i64 = 20260727161131; +pub const SCHEMA_VERSION: i64 = 20260727180000; /// Migration function signature type MigrationFn = fn(&Connection) -> AppResult<()>; @@ -1784,6 +1787,11 @@ const MIGRATIONS: &[Migration] = &[ name: "remote_host_settings", migrate: v20260727161131_remote_host_settings::migrate, }, + Migration { + version: 20260727180000, + name: "remote_auth", + migrate: v20260727180000_remote_auth::migrate, + }, ]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src-tauri/src/infrastructure/sqlite/migrations/v20260727180000_remote_auth.rs b/src-tauri/src/infrastructure/sqlite/migrations/v20260727180000_remote_auth.rs new file mode 100644 index 0000000000..af6022ef92 --- /dev/null +++ b/src-tauri/src/infrastructure/sqlite/migrations/v20260727180000_remote_auth.rs @@ -0,0 +1,76 @@ +// Migration v20260727180000: remote access auth tables (§4.3) +// +// Five tables backing the :3849 device-auth surface. Deliberately disjoint from `api_keys`: +// those are :3848 bot credentials with a permission bitmask, project scoping, and a +// zero-keys bootstrap bypass, none of which may apply to human device sessions (§4.1, §4.5). + +use rusqlite::Connection; + +use crate::error::{AppError, AppResult}; + +pub fn migrate(conn: &Connection) -> AppResult<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS remote_devices ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + token_prefix TEXT NOT NULL, + scopes TEXT NOT NULL, + created_at TEXT NOT NULL, + last_seen_at TEXT, + revoked_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_remote_devices_token_hash + ON remote_devices(token_hash); + CREATE INDEX IF NOT EXISTS idx_remote_devices_revoked + ON remote_devices(revoked_at); + + CREATE TABLE IF NOT EXISTS remote_pairing_codes ( + id TEXT PRIMARY KEY, + code_hash TEXT NOT NULL UNIQUE, + scopes TEXT NOT NULL, + created_at TEXT NOT NULL, + expires_at TEXT NOT NULL, + consumed_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_remote_pairing_codes_hash + ON remote_pairing_codes(code_hash); + CREATE INDEX IF NOT EXISTS idx_remote_pairing_codes_outstanding + ON remote_pairing_codes(consumed_at, expires_at); + + CREATE TABLE IF NOT EXISTS remote_sessions ( + id TEXT PRIMARY KEY, + device_id TEXT NOT NULL REFERENCES remote_devices(id), + connected_at TEXT NOT NULL, + last_active_at TEXT NOT NULL, + remote_addr TEXT NOT NULL, + closed_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_remote_sessions_device + ON remote_sessions(device_id, closed_at); + + CREATE TABLE IF NOT EXISTS remote_ws_tickets ( + ticket_hash TEXT PRIMARY KEY, + device_id TEXT NOT NULL, + expires_at TEXT NOT NULL, + consumed_at TEXT + ); + CREATE INDEX IF NOT EXISTS idx_remote_ws_tickets_device + ON remote_ws_tickets(device_id, consumed_at); + + CREATE TABLE IF NOT EXISTS remote_audit_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + device_id TEXT, + action TEXT NOT NULL, + detail TEXT, + created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_remote_audit_log_device + ON remote_audit_log(device_id, id); + CREATE INDEX IF NOT EXISTS idx_remote_audit_log_created + ON remote_audit_log(created_at);", + ) + .map_err(|error| AppError::Database(error.to_string()))?; + tracing::info!("v20260727180000: created remote access auth tables"); + Ok(()) +} diff --git a/src-tauri/src/infrastructure/sqlite/migrations/v20260727180000_remote_auth_tests.rs b/src-tauri/src/infrastructure/sqlite/migrations/v20260727180000_remote_auth_tests.rs new file mode 100644 index 0000000000..4762a8bc60 --- /dev/null +++ b/src-tauri/src/infrastructure/sqlite/migrations/v20260727180000_remote_auth_tests.rs @@ -0,0 +1,139 @@ +//! Tests for migration v20260727180000: remote access auth tables + +use rusqlite::Connection; + +use super::{helpers, v20260727180000_remote_auth}; + +fn setup_test_db() -> Connection { + Connection::open_in_memory().expect("in-memory database should open") +} + +#[test] +fn migration_creates_the_five_remote_auth_tables() { + let conn = setup_test_db(); + + v20260727180000_remote_auth::migrate(&conn) + .expect("migration should create remote auth tables"); + + for table in [ + "remote_devices", + "remote_pairing_codes", + "remote_sessions", + "remote_ws_tickets", + "remote_audit_log", + ] { + assert!(helpers::table_exists(&conn, table), "{table} should exist"); + } +} + +#[test] +fn remote_device_and_pairing_columns_match_the_spec_shape() { + let conn = setup_test_db(); + v20260727180000_remote_auth::migrate(&conn) + .expect("migration should create remote auth tables"); + + for column in [ + "id", + "name", + "token_hash", + "token_prefix", + "scopes", + "created_at", + "last_seen_at", + "revoked_at", + ] { + assert!( + helpers::column_exists(&conn, "remote_devices", column), + "remote_devices should contain {column}" + ); + } + for column in [ + "id", + "code_hash", + "scopes", + "created_at", + "expires_at", + "consumed_at", + ] { + assert!( + helpers::column_exists(&conn, "remote_pairing_codes", column), + "remote_pairing_codes should contain {column}" + ); + } + for column in ["ticket_hash", "device_id", "expires_at", "consumed_at"] { + assert!( + helpers::column_exists(&conn, "remote_ws_tickets", column), + "remote_ws_tickets should contain {column}" + ); + } +} + +/// A-9: nothing in the schema invites storing a plaintext credential — the only credential +/// columns are hashes, and they are unique so a hash collision cannot silently pair twice. +#[test] +fn credential_columns_are_hash_only_and_unique() { + let conn = setup_test_db(); + v20260727180000_remote_auth::migrate(&conn) + .expect("migration should create remote auth tables"); + + conn.execute( + "INSERT INTO remote_devices (id, name, token_hash, token_prefix, scopes, created_at) + VALUES ('d1', 'laptop', 'hash-a', 'rxd_live_aaaa', '[\"ui:read\"]', '2026-07-27T00:00:00Z')", + [], + ) + .expect("first device inserts"); + assert!( + conn.execute( + "INSERT INTO remote_devices (id, name, token_hash, token_prefix, scopes, created_at) + VALUES ('d2', 'phone', 'hash-a', 'rxd_live_bbbb', '[\"ui:read\"]', '2026-07-27T00:00:00Z')", + [], + ) + .is_err(), + "a duplicate token hash must be refused" + ); + + conn.execute( + "INSERT INTO remote_pairing_codes (id, code_hash, scopes, created_at, expires_at) + VALUES ('c1', 'code-a', '[\"ui:read\"]', '2026-07-27T00:00:00Z', '2026-07-27T00:10:00Z')", + [], + ) + .expect("first pairing code inserts"); + assert!( + conn.execute( + "INSERT INTO remote_pairing_codes (id, code_hash, scopes, created_at, expires_at) + VALUES ('c2', 'code-a', '[\"ui:read\"]', '2026-07-27T00:00:00Z', '2026-07-27T00:10:00Z')", + [], + ) + .is_err(), + "a duplicate pairing-code hash must be refused" + ); + + assert!( + !helpers::column_exists(&conn, "remote_devices", "token"), + "no plaintext token column may exist" + ); + assert!( + !helpers::column_exists(&conn, "remote_pairing_codes", "code"), + "no plaintext pairing-code column may exist" + ); +} + +#[test] +fn migration_is_idempotent_and_seeds_no_devices() { + let conn = setup_test_db(); + + v20260727180000_remote_auth::migrate(&conn).expect("first migration should succeed"); + v20260727180000_remote_auth::migrate(&conn).expect("second migration should remain safe"); + + // A-2: a freshly migrated host has zero paired devices and grants nothing by default. + let device_count: i64 = conn + .query_row("SELECT COUNT(*) FROM remote_devices", [], |row| row.get(0)) + .expect("devices should be queryable"); + let code_count: i64 = conn + .query_row("SELECT COUNT(*) FROM remote_pairing_codes", [], |row| { + row.get(0) + }) + .expect("pairing codes should be queryable"); + assert_eq!(device_count, 0); + assert_eq!(code_count, 0); +} From 799c076e5f3025e0c1c4f9fe4be239155a2e36d3 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:44:12 +0300 Subject: [PATCH 034/416] feat: add remote access entities and repository traits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RemoteDevice/RemotePairingCode/RemoteSession/RemoteWsTicket plus newtype ids and a canonical RemoteScopeSet over the protocol crate's Scope. Agent control is derived from the live scope set rather than a parallel column, so introspection can never disagree with enforcement. The lookup and consume traits return typed tri-state outcomes rather than Option, so a repository error can never be observed by a caller as "no row" — that distinction is what lets the bearer middleware answer 500 instead of an anonymous 401. A malformed stored grant is likewise an error, never an empty scope set. --- src-tauri/Cargo.lock | 1 + src-tauri/crates/ralphx-domain/Cargo.toml | 1 + .../crates/ralphx-domain/src/entities/mod.rs | 6 + .../src/entities/remote_access.rs | 309 ++++++++++++++++++ .../src/entities/remote_access_tests.rs | 166 ++++++++++ .../ralphx-domain/src/repositories/mod.rs | 6 + .../repositories/remote_access_repository.rs | 170 ++++++++++ 7 files changed, 659 insertions(+) create mode 100644 src-tauri/crates/ralphx-domain/src/entities/remote_access.rs create mode 100644 src-tauri/crates/ralphx-domain/src/entities/remote_access_tests.rs create mode 100644 src-tauri/crates/ralphx-domain/src/repositories/remote_access_repository.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c46b053fc8..5dc656e3b5 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3672,6 +3672,7 @@ dependencies = [ "dashmap", "futures", "lazy_static", + "ralphx-remote-protocol", "rusqlite", "serde", "serde_json", diff --git a/src-tauri/crates/ralphx-domain/Cargo.toml b/src-tauri/crates/ralphx-domain/Cargo.toml index ad90f9ecd0..8be91ab255 100644 --- a/src-tauri/crates/ralphx-domain/Cargo.toml +++ b/src-tauri/crates/ralphx-domain/Cargo.toml @@ -8,6 +8,7 @@ async-trait = "0.1" chrono = { version = "0.4", features = ["serde"] } dashmap = "6" futures = "0.3" +ralphx-remote-protocol = { path = "../ralphx-remote-protocol" } rusqlite = { version = "0.32" } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src-tauri/crates/ralphx-domain/src/entities/mod.rs b/src-tauri/crates/ralphx-domain/src/entities/mod.rs index a3a9138fe7..696960a177 100644 --- a/src-tauri/crates/ralphx-domain/src/entities/mod.rs +++ b/src-tauri/crates/ralphx-domain/src/entities/mod.rs @@ -48,6 +48,7 @@ pub mod plan_branch; pub mod plan_selection_stats; pub mod persona; pub mod project; +pub mod remote_access; pub mod research; pub mod scripted_agent_workflow; #[cfg(test)] @@ -212,6 +213,11 @@ pub use plan_branch::{ParsePlanBranchStatusError, PlanBranch, PlanBranchId, Plan pub use plan_selection_stats::{PlanSelectionStats, SelectionSource}; pub use persona::{Persona, PersonaDirective, PersonaId, PersonaScopeFilter, PersonaStatus}; pub use project::{GitMode, MergeStrategy, MergeValidationMode, Project}; +pub use remote_access::{ + effective_pairing_scopes, validate_pairing_grant, RemoteAuditAction, RemoteAuditEntry, + RemoteDevice, RemoteDeviceId, RemotePairingCode, RemotePairingCodeId, RemoteScopeError, + RemoteScopeSet, RemoteSession, RemoteSessionId, RemoteWsTicket, +}; pub use research::{ CustomDepth, ParseResearchDepthPresetError, ParseResearchProcessStatusError, ResearchBrief, ResearchDepth, ResearchDepthPreset, ResearchOutput, ResearchPresets, ResearchProcess, diff --git a/src-tauri/crates/ralphx-domain/src/entities/remote_access.rs b/src-tauri/crates/ralphx-domain/src/entities/remote_access.rs new file mode 100644 index 0000000000..770c5b47ef --- /dev/null +++ b/src-tauri/crates/ralphx-domain/src/entities/remote_access.rs @@ -0,0 +1,309 @@ +//! Remote-access (`:3849`) device credentials, pairing codes, sessions, and audit entries. +//! +//! Deliberately disjoint from [`crate::entities::api_key`]: those are :3848 bot credentials +//! with a permission bitmask, project scoping, and a zero-keys bootstrap bypass. Remote +//! devices are the owner's own UI clients — coarse scopes, whole-host, no bootstrap +//! exception (remote-multi-env §4.1, §4.5). + +use ralphx_remote_protocol::Scope; +use serde::{Deserialize, Serialize}; + +/// A unique identifier for a paired remote device. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub struct RemoteDeviceId(pub String); + +/// A unique identifier for an outstanding pairing code row. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub struct RemotePairingCodeId(pub String); + +/// A unique identifier for a live remote session (one WS connection). +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub struct RemoteSessionId(pub String); + +macro_rules! remote_id { + ($name:ident) => { + impl $name { + /// Creates a new identifier with a random UUID v4. + pub fn new() -> Self { + Self(uuid::Uuid::new_v4().to_string()) + } + + /// Creates an identifier from an existing string (database deserialization). + pub fn from_string(value: impl Into) -> Self { + Self(value.into()) + } + + /// Returns the inner string value. + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl Default for $name { + fn default() -> Self { + Self::new() + } + } + + impl std::fmt::Display for $name { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } + } + }; +} + +remote_id!(RemoteDeviceId); +remote_id!(RemotePairingCodeId); +remote_id!(RemoteSessionId); + +/// An ordered, de-duplicated set of remote UI scopes. +/// +/// Persisted as the `scopes` JSON array on `remote_devices` / `remote_pairing_codes`. Order +/// is canonical (declaration order of [`Scope`]) so the stored JSON is stable and two equal +/// grants always compare equal. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct RemoteScopeSet(Vec); + +impl RemoteScopeSet { + /// The scopes a standard pairing grant carries: read + operate, never `ui:agent` (§4.4). + pub fn default_pairing_grant() -> Self { + Self::from_scopes([Scope::UiRead, Scope::UiOperate]) + } + + /// Builds a canonical set from any iterator, sorting and de-duplicating. + pub fn from_scopes(scopes: impl IntoIterator) -> Self { + let mut collected: Vec = scopes.into_iter().collect(); + collected.sort(); + collected.dedup(); + Self(collected) + } + + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + pub fn contains(&self, scope: Scope) -> bool { + self.0.contains(&scope) + } + + pub fn as_slice(&self) -> &[Scope] { + &self.0 + } + + pub fn to_vec(&self) -> Vec { + self.0.clone() + } + + /// True when every scope in `self` is also present in `other`. + pub fn is_subset_of(&self, other: &Self) -> bool { + self.0.iter().all(|scope| other.contains(*scope)) + } + + /// Returns a copy with `scope` added. + pub fn with(&self, scope: Scope) -> Self { + Self::from_scopes(self.0.iter().copied().chain(std::iter::once(scope))) + } + + /// Returns a copy with `scope` removed. + pub fn without(&self, scope: Scope) -> Self { + Self::from_scopes(self.0.iter().copied().filter(|entry| *entry != scope)) + } + + /// Serializes to the JSON array stored in the `scopes` column. + pub fn to_json(&self) -> Result { + serde_json::to_string(&self.0) + .map_err(|error| RemoteScopeError::Malformed(error.to_string())) + } + + /// Parses the JSON array stored in the `scopes` column. + /// + /// An unrecognized scope string is a hard error rather than a silent drop: a row written + /// by a newer build must never be re-read as a narrower (or wider) grant. + pub fn from_json(raw: &str) -> Result { + let parsed: Vec = serde_json::from_str(raw) + .map_err(|error| RemoteScopeError::Malformed(error.to_string()))?; + Ok(Self::from_scopes(parsed)) + } +} + +/// Typed failures around scope parsing and grant narrowing (rule 5 — no string matching). +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum RemoteScopeError { + #[error("stored remote scope set is malformed: {0}")] + Malformed(String), + #[error("requested remote scope {0:?} is not part of the pairing grant")] + NotGranted(Scope), + #[error("remote scope {0:?} is not grantable in v1")] + NotGrantable(Scope), +} + +/// Scopes a pairing code may ever carry. +/// +/// `ui:agent` is never minted into a pairing grant — it is a per-device host-side toggle +/// (§4.3, §5.4) — and `ui:elevated` is defined but ungrantable in v1 (§4.3). +pub fn validate_pairing_grant(grant: &RemoteScopeSet) -> Result<(), RemoteScopeError> { + for scope in grant.as_slice() { + match scope { + Scope::UiRead | Scope::UiOperate => {} + Scope::UiAgent | Scope::UiElevated => { + return Err(RemoteScopeError::NotGrantable(*scope)) + } + } + } + Ok(()) +} + +/// Resolves the scopes a pairing exchange mints for the new device. +/// +/// `requested` must be a **subset** of the code's grant (§4.2); asking for more is an error, +/// not a silent intersection, so a client can never believe it holds a scope it does not. +pub fn effective_pairing_scopes( + grant: &RemoteScopeSet, + requested: Option<&RemoteScopeSet>, +) -> Result { + let Some(requested) = requested else { + return Ok(grant.clone()); + }; + for scope in requested.as_slice() { + if !grant.contains(*scope) { + return Err(RemoteScopeError::NotGranted(*scope)); + } + } + Ok(requested.clone()) +} + +/// A paired remote device — the durable credential record behind a `rxd_live_` token. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteDevice { + pub id: RemoteDeviceId, + pub name: String, + /// SHA-256 hash of the raw `rxd_live_…` token. The raw token is never stored. + #[serde(skip)] + pub token_hash: String, + /// Display-only prefix, e.g. `rxd_live_a3f2`. + pub token_prefix: String, + pub scopes: RemoteScopeSet, + pub created_at: String, + pub last_seen_at: Option, + /// `None` = active. Set before kill-channels fire (§4.4 teardown order). + pub revoked_at: Option, +} + +impl RemoteDevice { + pub fn is_active(&self) -> bool { + self.revoked_at.is_none() + } + + /// Whether the host owner has granted this device remote agent control. + /// + /// Derived from the live scope set rather than a separate column so introspection can + /// never disagree with enforcement (§4.3 key point 7). + pub fn agent_control_granted(&self) -> bool { + self.scopes.contains(Scope::UiAgent) + } +} + +/// An outstanding (or spent) pairing code row. Only the hash is ever persisted. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemotePairingCode { + pub id: RemotePairingCodeId, + #[serde(skip)] + pub code_hash: String, + pub scopes: RemoteScopeSet, + pub created_at: String, + pub expires_at: String, + pub consumed_at: Option, +} + +/// Durable bookkeeping for one remote session. +/// +/// The in-memory live-session registry — not this row — is the enforcement handle for +/// revocation teardown (§4.3 note). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteSession { + pub id: RemoteSessionId, + pub device_id: RemoteDeviceId, + pub connected_at: String, + pub last_active_at: String, + pub remote_addr: String, + pub closed_at: Option, +} + +/// A single-use, device-bound WS upgrade ticket. Only the hash is persisted. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteWsTicket { + pub ticket_hash: String, + pub device_id: RemoteDeviceId, + pub expires_at: String, + pub consumed_at: Option, +} + +/// Every auditable remote-access decision (§5.5). +/// +/// An enum rather than free strings so call sites cannot drift (rule 5). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RemoteAuditAction { + PairingCodeCreated, + PairingCodeRevoked, + PairingSucceeded, + PairingRejected, + AuthAccepted, + AuthRejected, + AuthStoreError, + RateLimited, + WsTicketIssued, + WsTicketConsumed, + WsTicketRejected, + SessionOpened, + SessionClosed, + DeviceRevoked, + AgentControlGranted, + AgentControlRevoked, + ListenerDisabled, +} + +impl RemoteAuditAction { + /// Stable string persisted in `remote_audit_log.action`. + pub fn as_db_value(self) -> &'static str { + match self { + Self::PairingCodeCreated => "pairing_code_created", + Self::PairingCodeRevoked => "pairing_code_revoked", + Self::PairingSucceeded => "pairing_succeeded", + Self::PairingRejected => "pairing_rejected", + Self::AuthAccepted => "auth_accepted", + Self::AuthRejected => "auth_rejected", + Self::AuthStoreError => "auth_store_error", + Self::RateLimited => "rate_limited", + Self::WsTicketIssued => "ws_ticket_issued", + Self::WsTicketConsumed => "ws_ticket_consumed", + Self::WsTicketRejected => "ws_ticket_rejected", + Self::SessionOpened => "session_opened", + Self::SessionClosed => "session_closed", + Self::DeviceRevoked => "device_revoked", + Self::AgentControlGranted => "agent_control_granted", + Self::AgentControlRevoked => "agent_control_revoked", + Self::ListenerDisabled => "listener_disabled", + } + } +} + +/// One row of `remote_audit_log`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteAuditEntry { + pub id: i64, + pub device_id: Option, + pub action: String, + pub detail: Option, + pub created_at: String, +} + +#[cfg(test)] +#[path = "remote_access_tests.rs"] +mod tests; diff --git a/src-tauri/crates/ralphx-domain/src/entities/remote_access_tests.rs b/src-tauri/crates/ralphx-domain/src/entities/remote_access_tests.rs new file mode 100644 index 0000000000..6314557b06 --- /dev/null +++ b/src-tauri/crates/ralphx-domain/src/entities/remote_access_tests.rs @@ -0,0 +1,166 @@ +use super::*; + +fn set(scopes: &[Scope]) -> RemoteScopeSet { + RemoteScopeSet::from_scopes(scopes.iter().copied()) +} + +#[test] +fn the_default_pairing_grant_is_read_plus_operate_and_never_agent_control() { + let grant = RemoteScopeSet::default_pairing_grant(); + + assert!(grant.contains(Scope::UiRead)); + assert!(grant.contains(Scope::UiOperate)); + assert!(!grant.contains(Scope::UiAgent)); + assert!(!grant.contains(Scope::UiElevated)); + validate_pairing_grant(&grant).expect("the default grant must be mintable"); +} + +#[test] +fn a_pairing_grant_may_never_carry_agent_or_elevated_scope() { + assert_eq!( + validate_pairing_grant(&set(&[Scope::UiRead, Scope::UiAgent])), + Err(RemoteScopeError::NotGrantable(Scope::UiAgent)) + ); + assert_eq!( + validate_pairing_grant(&set(&[Scope::UiElevated])), + Err(RemoteScopeError::NotGrantable(Scope::UiElevated)) + ); +} + +#[test] +fn scope_sets_serialize_canonically_and_round_trip() { + let unordered = set(&[ + Scope::UiAgent, + Scope::UiRead, + Scope::UiRead, + Scope::UiOperate, + ]); + + let json = unordered.to_json().expect("scopes should serialize"); + + assert_eq!(json, r#"["ui:read","ui:operate","ui:agent"]"#); + assert_eq!( + RemoteScopeSet::from_json(&json).expect("scopes should parse"), + unordered + ); +} + +/// Fail closed on reads: a row a newer build wrote must not silently degrade to a narrower +/// (or wider) grant — the read is an error the caller has to handle. +#[test] +fn an_unrecognized_stored_scope_is_an_error_not_a_silent_drop() { + let error = RemoteScopeSet::from_json(r#"["ui:read","ui:teleport"]"#) + .expect_err("an unknown scope must not parse"); + + assert!(matches!(error, RemoteScopeError::Malformed(_))); +} + +#[test] +fn requested_scopes_must_be_a_subset_of_the_pairing_grant() { + let grant = RemoteScopeSet::default_pairing_grant(); + + assert_eq!( + effective_pairing_scopes(&grant, None).expect("absent request takes the whole grant"), + grant + ); + assert_eq!( + effective_pairing_scopes(&grant, Some(&set(&[Scope::UiRead]))) + .expect("a narrower request is honoured"), + set(&[Scope::UiRead]) + ); + assert_eq!( + effective_pairing_scopes(&grant, Some(&set(&[Scope::UiRead, Scope::UiAgent]))), + Err(RemoteScopeError::NotGranted(Scope::UiAgent)), + "asking for more than the grant must fail, never quietly intersect" + ); +} + +#[test] +fn agent_control_is_off_for_a_freshly_paired_device() { + let device = RemoteDevice { + id: RemoteDeviceId::new(), + name: "laptop".to_string(), + token_hash: "hash".to_string(), + token_prefix: "rxd_live_aaaa".to_string(), + scopes: RemoteScopeSet::default_pairing_grant(), + created_at: "2026-07-27T00:00:00Z".to_string(), + last_seen_at: None, + revoked_at: None, + }; + + assert!(device.is_active()); + assert!(!device.agent_control_granted()); + + let granted = RemoteDevice { + scopes: device.scopes.with(Scope::UiAgent), + ..device.clone() + }; + assert!(granted.agent_control_granted()); + + let narrowed = RemoteDevice { + scopes: granted.scopes.without(Scope::UiAgent), + ..granted + }; + assert!(!narrowed.agent_control_granted()); + assert_eq!( + narrowed.scopes, + RemoteScopeSet::default_pairing_grant(), + "narrowing agent control must not disturb the base grant" + ); +} + +#[test] +fn a_device_serialization_never_carries_the_token_hash() { + let device = RemoteDevice { + id: RemoteDeviceId::from_string("device-1"), + name: "laptop".to_string(), + token_hash: "sha256-of-the-token".to_string(), + token_prefix: "rxd_live_aaaa".to_string(), + scopes: RemoteScopeSet::default_pairing_grant(), + created_at: "2026-07-27T00:00:00Z".to_string(), + last_seen_at: None, + revoked_at: None, + }; + + let json = serde_json::to_string(&device).expect("device should serialize"); + + assert!(!json.contains("sha256-of-the-token")); + assert!(!json.contains("tokenHash")); + assert!(json.contains("tokenPrefix")); +} + +#[test] +fn audit_actions_have_stable_distinct_db_values() { + let actions = [ + RemoteAuditAction::PairingCodeCreated, + RemoteAuditAction::PairingCodeRevoked, + RemoteAuditAction::PairingSucceeded, + RemoteAuditAction::PairingRejected, + RemoteAuditAction::AuthAccepted, + RemoteAuditAction::AuthRejected, + RemoteAuditAction::AuthStoreError, + RemoteAuditAction::RateLimited, + RemoteAuditAction::WsTicketIssued, + RemoteAuditAction::WsTicketConsumed, + RemoteAuditAction::WsTicketRejected, + RemoteAuditAction::SessionOpened, + RemoteAuditAction::SessionClosed, + RemoteAuditAction::DeviceRevoked, + RemoteAuditAction::AgentControlGranted, + RemoteAuditAction::AgentControlRevoked, + RemoteAuditAction::ListenerDisabled, + ]; + + let values: std::collections::BTreeSet<&str> = + actions.iter().map(|action| action.as_db_value()).collect(); + + assert_eq!( + values.len(), + actions.len(), + "audit actions must be distinct" + ); + assert_eq!( + RemoteAuditAction::AuthStoreError.as_db_value(), + "auth_store_error" + ); +} diff --git a/src-tauri/crates/ralphx-domain/src/repositories/mod.rs b/src-tauri/crates/ralphx-domain/src/repositories/mod.rs index af1a050ea5..1e274b0e9d 100644 --- a/src-tauri/crates/ralphx-domain/src/repositories/mod.rs +++ b/src-tauri/crates/ralphx-domain/src/repositories/mod.rs @@ -20,6 +20,7 @@ pub mod agent_run_repository; pub mod agent_task_repository; pub mod agent_workflow_repository; pub mod api_key_repository; +pub mod remote_access_repository; pub mod app_state_repository; pub mod artifact_bucket_repository; pub mod artifact_flow_repository; @@ -95,6 +96,11 @@ pub use agent_run_repository::{AgentRunRepository, ORPHANED_AGENT_RUN_ON_APP_RES pub use agent_task_repository::{AgentTaskListOptions, AgentTaskRepository}; pub use agent_workflow_repository::AgentWorkflowRepository; pub use api_key_repository::{ApiKeyRepository, CreateKeyParams, RotateKeyParams}; +pub use remote_access_repository::{ + RemoteAuditLogRepository, RemoteDeviceLookup, RemoteDeviceRepository, RemotePairingCodeRepository, + RemotePairingOutcome, RemotePairingRedemption, RemoteSessionRepository, + RemoteWsTicketOutcome, RemoteWsTicketRepository, +}; pub use app_state_repository::AppStateRepository; pub use artifact_bucket_repository::ArtifactBucketRepository; pub use artifact_flow_repository::ArtifactFlowRepository; diff --git a/src-tauri/crates/ralphx-domain/src/repositories/remote_access_repository.rs b/src-tauri/crates/ralphx-domain/src/repositories/remote_access_repository.rs new file mode 100644 index 0000000000..f786435051 --- /dev/null +++ b/src-tauri/crates/ralphx-domain/src/repositories/remote_access_repository.rs @@ -0,0 +1,170 @@ +//! Repository traits for the :3849 remote-access auth tables (§4.3). +//! +//! Every implementation must go through `DbConnection::run` / `run_transaction` (rule 16). +//! The lookup and consume methods return **typed tri-state outcomes** rather than +//! `Option`, so a store failure can never be observed as "no row" — the distinction is what +//! makes the bearer middleware fail closed with 500 instead of an anonymous 401 (§4.4). + +use async_trait::async_trait; + +use crate::entities::{ + RemoteAuditAction, RemoteAuditEntry, RemoteDevice, RemoteDeviceId, RemotePairingCode, + RemotePairingCodeId, RemoteScopeSet, RemoteSession, RemoteSessionId, +}; +use crate::error::AppResult; +use ralphx_remote_protocol::Scope; + +/// Result of resolving a presented bearer token against `remote_devices`. +/// +/// `Err(AppError)` from the repository means the store failed; it is deliberately NOT a +/// variant here so callers cannot pattern-match a store outage into a rejection. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RemoteDeviceLookup { + /// A live, non-revoked device. + Active(RemoteDevice), + /// The token matched a device whose `revoked_at` is set. + Revoked(RemoteDevice), + /// No row carries this token hash. + Unknown, +} + +/// Everything needed to mint a device inside the pairing-code consume transaction. +#[derive(Debug, Clone)] +pub struct RemotePairingRedemption { + pub code_hash: String, + pub device_id: RemoteDeviceId, + pub device_name: String, + pub token_hash: String, + pub token_prefix: String, + /// `None` asks for the code's whole grant; `Some` must be a subset of it (§4.2). + pub requested_scopes: Option, + /// RFC3339 timestamp used for both the expiry comparison and the written rows. + pub now: String, +} + +/// Outcome of redeeming a pairing code. Exactly one concurrent redemption can be `Paired`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RemotePairingOutcome { + Paired(RemoteDevice), + /// No pairing-code row carries this hash. + Unknown, + /// `expires_at` is at or before `now`. + Expired, + /// `consumed_at` was already set — single-use enforced (P-7). + AlreadyConsumed, + /// The request asked for a scope outside the code's grant. + ScopeNotGranted(Scope), +} + +/// Outcome of consuming a WS ticket at upgrade time. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RemoteWsTicketOutcome { + /// Valid, unconsumed, unexpired — now consumed and bound to this device. + Consumed(RemoteDeviceId), + Unknown, + Expired, + AlreadyConsumed, +} + +/// Paired devices: the credential store behind every authenticated remote request. +#[async_trait] +pub trait RemoteDeviceRepository: Send + Sync { + /// Resolves a SHA-256 token hash. Errors are store failures, never "absent". + async fn lookup_by_token_hash(&self, token_hash: &str) -> AppResult; + + async fn get(&self, id: &RemoteDeviceId) -> AppResult>; + + /// All devices, newest first, including revoked ones (the host UI shows both). + async fn list(&self) -> AppResult>; + + /// Sets `revoked_at` if it is not already set. Returns the device as it now stands. + /// + /// Idempotent: re-revoking is not an error, so recovery paths can repeat it safely. + async fn revoke(&self, id: &RemoteDeviceId, now: &str) -> AppResult>; + + /// Replaces the device's scope set. Used by the agent-control toggle (§5.4). + /// + /// Refuses revoked devices so a toggle can never widen a dead credential. + async fn set_scopes( + &self, + id: &RemoteDeviceId, + scopes: &RemoteScopeSet, + ) -> AppResult>; + + /// Updates `last_seen_at` on every authenticated request (§4.3). + async fn touch_last_seen(&self, id: &RemoteDeviceId, now: &str) -> AppResult<()>; +} + +/// Single-use pairing codes. +#[async_trait] +pub trait RemotePairingCodeRepository: Send + Sync { + async fn create(&self, code: RemotePairingCode) -> AppResult; + + /// Validates and consumes a code, minting the device **in the same transaction**. + /// + /// `BEGIN IMMEDIATE` plus a guarded `consumed_at IS NULL` update means two concurrent + /// redemptions of one code can never both succeed (P-7). + async fn redeem(&self, redemption: RemotePairingRedemption) -> AppResult; + + /// Codes that are neither consumed nor expired as of `now`. + async fn list_outstanding(&self, now: &str) -> AppResult>; + + /// Marks a code consumed without pairing, so the host UI can cancel an outstanding code. + async fn cancel(&self, id: &RemotePairingCodeId, now: &str) -> AppResult; +} + +/// Durable session bookkeeping. The live registry, not this table, enforces teardown. +#[async_trait] +pub trait RemoteSessionRepository: Send + Sync { + async fn open(&self, session: RemoteSession) -> AppResult; + + async fn touch(&self, id: &RemoteSessionId, now: &str) -> AppResult<()>; + + async fn close(&self, id: &RemoteSessionId, now: &str) -> AppResult<()>; + + /// Closes every open session for a device; returns how many rows were closed. + async fn close_all_for_device(&self, device_id: &RemoteDeviceId, now: &str) + -> AppResult; + + /// Closes every open session on the host (listener disable). + async fn close_all(&self, now: &str) -> AppResult; + + async fn list_open(&self) -> AppResult>; +} + +/// Device-bound, single-use WS upgrade tickets. +#[async_trait] +pub trait RemoteWsTicketRepository: Send + Sync { + async fn issue( + &self, + ticket_hash: &str, + device_id: &RemoteDeviceId, + expires_at: &str, + ) -> AppResult<()>; + + /// Validates and consumes in one transaction, so a replay always loses. + async fn consume(&self, ticket_hash: &str, now: &str) -> AppResult; + + /// Invalidates every outstanding ticket for a device (revocation, agent-control off). + async fn consume_all_for_device( + &self, + device_id: &RemoteDeviceId, + now: &str, + ) -> AppResult; +} + +/// Append-only audit trail for every remote auth decision (§5.5). +#[async_trait] +pub trait RemoteAuditLogRepository: Send + Sync { + async fn record( + &self, + device_id: Option<&RemoteDeviceId>, + action: RemoteAuditAction, + detail: Option<&str>, + now: &str, + ) -> AppResult<()>; + + /// Most recent first. Named `list_recent` so it does not collide with + /// [`RemoteDeviceRepository::list`] on a type implementing both. + async fn list_recent(&self, limit: Option) -> AppResult>; +} From 4479a65da3c91c914941dc9f20d866e4f9287c55 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:44:20 +0300 Subject: [PATCH 035/416] feat: add the SQLite remote access repository MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the five remote-access traits over one DbConnection-backed store. Every method goes through db.run / db.run_transaction (rule 16) and the source itself is asserted free of direct connection locking. Pairing redemption validates the code, resolves the effective scopes, consumes the row under a guarded `consumed_at IS NULL` update, and inserts the device — all inside one BEGIN IMMEDIATE transaction, so two concurrent redemptions of one code can never both pair (P-7). WS tickets consume the same way. test: cover single-use pairing under concurrency, TTL expiry, hash-at-rest, the active/revoked/unknown lookup split, scope-subset refusal, ticket replay, session teardown, and a corrupt scope column failing the read. --- src-tauri/src/infrastructure/sqlite/mod.rs | 2 + .../sqlite/sqlite_remote_access_repo.rs | 676 ++++++++++++++++++ .../sqlite/sqlite_remote_access_repo_tests.rs | 533 ++++++++++++++ 3 files changed, 1211 insertions(+) create mode 100644 src-tauri/src/infrastructure/sqlite/sqlite_remote_access_repo.rs create mode 100644 src-tauri/src/infrastructure/sqlite/sqlite_remote_access_repo_tests.rs diff --git a/src-tauri/src/infrastructure/sqlite/mod.rs b/src-tauri/src/infrastructure/sqlite/mod.rs index ccef165d43..9061643c21 100644 --- a/src-tauri/src/infrastructure/sqlite/mod.rs +++ b/src-tauri/src/infrastructure/sqlite/mod.rs @@ -107,6 +107,7 @@ pub mod sqlite_plan_branch_repo; pub mod sqlite_plan_selection_stats_repo; pub mod sqlite_process_repo; pub mod sqlite_project_repo; +pub mod sqlite_remote_access_repo; pub mod sqlite_proposal_dependency_repo; pub mod sqlite_question_repo; pub mod sqlite_queued_message_repo; @@ -159,6 +160,7 @@ pub use sqlite_agent_run_repo::SqliteAgentRunRepository; pub use sqlite_agent_task_repo::SqliteAgentTaskRepository; pub use sqlite_agent_workflow_repo::SqliteAgentWorkflowRepository; pub use sqlite_api_key_repo::SqliteApiKeyRepository; +pub use sqlite_remote_access_repo::SqliteRemoteAccessRepository; pub use sqlite_app_state_repo::SqliteAppStateRepository; pub use sqlite_artifact_bucket_repo::SqliteArtifactBucketRepository; pub use sqlite_artifact_flow_repo::SqliteArtifactFlowRepository; diff --git a/src-tauri/src/infrastructure/sqlite/sqlite_remote_access_repo.rs b/src-tauri/src/infrastructure/sqlite/sqlite_remote_access_repo.rs new file mode 100644 index 0000000000..6470638e16 --- /dev/null +++ b/src-tauri/src/infrastructure/sqlite/sqlite_remote_access_repo.rs @@ -0,0 +1,676 @@ +//! SQLite implementation of the :3849 remote-access repositories (§4.3). +//! +//! Every method goes through [`DbConnection::run`] / [`DbConnection::run_transaction`] +//! (rule 16) — no `conn.lock().await` anywhere. Pairing-code redemption and ticket +//! consumption run inside `run_transaction` (`BEGIN IMMEDIATE`) so a concurrent second +//! redemption always loses the guarded `consumed_at IS NULL` update (P-7). + +use std::sync::Arc; + +use async_trait::async_trait; +use rusqlite::Connection; +use tokio::sync::Mutex; + +use super::DbConnection; +use crate::domain::entities::{ + effective_pairing_scopes, RemoteAuditAction, RemoteAuditEntry, RemoteDevice, RemoteDeviceId, + RemotePairingCode, RemotePairingCodeId, RemoteScopeSet, RemoteSession, RemoteSessionId, +}; +use crate::domain::repositories::{ + RemoteAuditLogRepository, RemoteDeviceLookup, RemoteDeviceRepository, + RemotePairingCodeRepository, RemotePairingOutcome, RemotePairingRedemption, + RemoteSessionRepository, RemoteWsTicketOutcome, RemoteWsTicketRepository, +}; +use crate::error::{AppError, AppResult}; + +const DEVICE_COLUMNS: &str = + "id, name, token_hash, token_prefix, scopes, created_at, last_seen_at, revoked_at"; +const PAIRING_CODE_COLUMNS: &str = "id, code_hash, scopes, created_at, expires_at, consumed_at"; +const SESSION_COLUMNS: &str = "id, device_id, connected_at, last_active_at, remote_addr, closed_at"; + +/// SQLite-backed store for remote devices, pairing codes, sessions, tickets, and audit rows. +pub struct SqliteRemoteAccessRepository { + db: DbConnection, +} + +impl SqliteRemoteAccessRepository { + pub fn from_db(db: DbConnection) -> Self { + Self { db } + } + + pub fn new(conn: Connection) -> Self { + Self { + db: DbConnection::new(conn), + } + } + + pub fn from_shared(conn: Arc>) -> Self { + Self { + db: DbConnection::from_shared(conn), + } + } +} + +/// Scope columns are parsed strictly: a malformed grant is a store error, never an empty set. +fn scopes_from_column(raw: String) -> AppResult { + RemoteScopeSet::from_json(&raw).map_err(|error| AppError::Database(error.to_string())) +} + +fn scopes_to_column(scopes: &RemoteScopeSet) -> AppResult { + scopes + .to_json() + .map_err(|error| AppError::Database(error.to_string())) +} + +/// Raw column tuple for a device row; scope parsing happens outside the rusqlite closure so a +/// malformed grant surfaces as `AppError::Database`, not a swallowed row-mapping failure. +type DeviceRow = ( + String, + String, + String, + String, + String, + String, + Option, + Option, +); + +fn device_row(row: &rusqlite::Row<'_>) -> Result { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + row.get(7)?, + )) +} + +fn device_from_row(raw: DeviceRow) -> AppResult { + Ok(RemoteDevice { + id: RemoteDeviceId::from_string(raw.0), + name: raw.1, + token_hash: raw.2, + token_prefix: raw.3, + scopes: scopes_from_column(raw.4)?, + created_at: raw.5, + last_seen_at: raw.6, + revoked_at: raw.7, + }) +} + +fn read_device(conn: &Connection, id: &str) -> AppResult> { + let raw = conn.query_row( + &format!("SELECT {DEVICE_COLUMNS} FROM remote_devices WHERE id = ?1"), + rusqlite::params![id], + device_row, + ); + match raw { + Ok(raw) => Ok(Some(device_from_row(raw)?)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(error) => Err(AppError::Database(error.to_string())), + } +} + +type PairingCodeRow = (String, String, String, String, String, Option); + +fn pairing_code_row(row: &rusqlite::Row<'_>) -> Result { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + )) +} + +fn pairing_code_from_row(raw: PairingCodeRow) -> AppResult { + Ok(RemotePairingCode { + id: RemotePairingCodeId::from_string(raw.0), + code_hash: raw.1, + scopes: scopes_from_column(raw.2)?, + created_at: raw.3, + expires_at: raw.4, + consumed_at: raw.5, + }) +} + +type SessionRow = (String, String, String, String, String, Option); + +fn session_row(row: &rusqlite::Row<'_>) -> Result { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + )) +} + +fn session_from_row(raw: SessionRow) -> RemoteSession { + RemoteSession { + id: RemoteSessionId::from_string(raw.0), + device_id: RemoteDeviceId::from_string(raw.1), + connected_at: raw.2, + last_active_at: raw.3, + remote_addr: raw.4, + closed_at: raw.5, + } +} + +#[async_trait] +impl RemoteDeviceRepository for SqliteRemoteAccessRepository { + async fn lookup_by_token_hash(&self, token_hash: &str) -> AppResult { + let token_hash = token_hash.to_string(); + self.db + .run(move |conn| { + let raw = conn.query_row( + &format!("SELECT {DEVICE_COLUMNS} FROM remote_devices WHERE token_hash = ?1"), + rusqlite::params![token_hash], + device_row, + ); + match raw { + Ok(raw) => { + let device = device_from_row(raw)?; + Ok(if device.is_active() { + RemoteDeviceLookup::Active(device) + } else { + RemoteDeviceLookup::Revoked(device) + }) + } + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(RemoteDeviceLookup::Unknown), + // Deliberately an error, not `Unknown`: a store outage must not be + // observable as "this token simply is not paired" (§4.4). + Err(error) => Err(AppError::Database(error.to_string())), + } + }) + .await + } + + async fn get(&self, id: &RemoteDeviceId) -> AppResult> { + let id = id.to_string(); + self.db.run(move |conn| read_device(conn, &id)).await + } + + async fn list(&self) -> AppResult> { + self.db + .run(move |conn| { + let mut statement = conn + .prepare(&format!( + "SELECT {DEVICE_COLUMNS} FROM remote_devices ORDER BY created_at DESC, id DESC" + )) + .map_err(|error| AppError::Database(error.to_string()))?; + let rows = statement + .query_map([], device_row) + .map_err(|error| AppError::Database(error.to_string()))? + .collect::, _>>() + .map_err(|error| AppError::Database(error.to_string()))?; + rows.into_iter().map(device_from_row).collect() + }) + .await + } + + async fn revoke(&self, id: &RemoteDeviceId, now: &str) -> AppResult> { + let id = id.to_string(); + let now = now.to_string(); + self.db + .run_transaction(move |conn| { + conn.execute( + "UPDATE remote_devices SET revoked_at = ?1 + WHERE id = ?2 AND revoked_at IS NULL", + rusqlite::params![now, id], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + read_device(conn, &id) + }) + .await + } + + async fn set_scopes( + &self, + id: &RemoteDeviceId, + scopes: &RemoteScopeSet, + ) -> AppResult> { + let id = id.to_string(); + let encoded = scopes_to_column(scopes)?; + self.db + .run_transaction(move |conn| { + // `revoked_at IS NULL` keeps a toggle from widening a dead credential. + conn.execute( + "UPDATE remote_devices SET scopes = ?1 WHERE id = ?2 AND revoked_at IS NULL", + rusqlite::params![encoded, id], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + read_device(conn, &id) + }) + .await + } + + async fn touch_last_seen(&self, id: &RemoteDeviceId, now: &str) -> AppResult<()> { + let id = id.to_string(); + let now = now.to_string(); + self.db + .run(move |conn| { + conn.execute( + "UPDATE remote_devices SET last_seen_at = ?1 WHERE id = ?2", + rusqlite::params![now, id], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + Ok(()) + }) + .await + } +} + +#[async_trait] +impl RemotePairingCodeRepository for SqliteRemoteAccessRepository { + async fn create(&self, code: RemotePairingCode) -> AppResult { + let encoded = scopes_to_column(&code.scopes)?; + let stored = code.clone(); + self.db + .run(move |conn| { + conn.execute( + "INSERT INTO remote_pairing_codes + (id, code_hash, scopes, created_at, expires_at, consumed_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + stored.id.as_str(), + stored.code_hash, + encoded, + stored.created_at, + stored.expires_at, + stored.consumed_at, + ], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + Ok(stored) + }) + .await + } + + async fn redeem(&self, redemption: RemotePairingRedemption) -> AppResult { + self.db + .run_transaction(move |conn| { + let raw = conn.query_row( + &format!( + "SELECT {PAIRING_CODE_COLUMNS} FROM remote_pairing_codes + WHERE code_hash = ?1" + ), + rusqlite::params![redemption.code_hash], + pairing_code_row, + ); + let code = match raw { + Ok(raw) => pairing_code_from_row(raw)?, + Err(rusqlite::Error::QueryReturnedNoRows) => { + return Ok(RemotePairingOutcome::Unknown) + } + Err(error) => return Err(AppError::Database(error.to_string())), + }; + if code.consumed_at.is_some() { + return Ok(RemotePairingOutcome::AlreadyConsumed); + } + // String comparison is valid here: every timestamp this crate writes is + // fixed-width RFC3339 UTC, so lexicographic order is chronological order. + if code.expires_at <= redemption.now { + return Ok(RemotePairingOutcome::Expired); + } + + let scopes = match effective_pairing_scopes( + &code.scopes, + redemption.requested_scopes.as_ref(), + ) { + Ok(scopes) => scopes, + Err(crate::domain::entities::RemoteScopeError::NotGranted(scope)) => { + return Ok(RemotePairingOutcome::ScopeNotGranted(scope)) + } + Err(error) => return Err(AppError::Validation(error.to_string())), + }; + + // The guard is what makes redemption single-use under concurrency: the + // loser of two `BEGIN IMMEDIATE` transactions updates zero rows (P-7). + let consumed = conn + .execute( + "UPDATE remote_pairing_codes SET consumed_at = ?1 + WHERE id = ?2 AND consumed_at IS NULL", + rusqlite::params![redemption.now, code.id.as_str()], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + if consumed != 1 { + return Ok(RemotePairingOutcome::AlreadyConsumed); + } + + let encoded = scopes_to_column(&scopes)?; + conn.execute( + "INSERT INTO remote_devices + (id, name, token_hash, token_prefix, scopes, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + redemption.device_id.as_str(), + redemption.device_name, + redemption.token_hash, + redemption.token_prefix, + encoded, + redemption.now, + ], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + + let device = + read_device(conn, redemption.device_id.as_str())?.ok_or_else(|| { + AppError::Database( + "paired device row disappeared inside its own transaction".to_string(), + ) + })?; + Ok(RemotePairingOutcome::Paired(device)) + }) + .await + } + + async fn list_outstanding(&self, now: &str) -> AppResult> { + let now = now.to_string(); + self.db + .run(move |conn| { + let mut statement = conn + .prepare(&format!( + "SELECT {PAIRING_CODE_COLUMNS} FROM remote_pairing_codes + WHERE consumed_at IS NULL AND expires_at > ?1 + ORDER BY created_at DESC, id DESC" + )) + .map_err(|error| AppError::Database(error.to_string()))?; + let rows = statement + .query_map(rusqlite::params![now], pairing_code_row) + .map_err(|error| AppError::Database(error.to_string()))? + .collect::, _>>() + .map_err(|error| AppError::Database(error.to_string()))?; + rows.into_iter().map(pairing_code_from_row).collect() + }) + .await + } + + async fn cancel(&self, id: &RemotePairingCodeId, now: &str) -> AppResult { + let id = id.to_string(); + let now = now.to_string(); + self.db + .run(move |conn| { + let affected = conn + .execute( + "UPDATE remote_pairing_codes SET consumed_at = ?1 + WHERE id = ?2 AND consumed_at IS NULL", + rusqlite::params![now, id], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + Ok(affected == 1) + }) + .await + } +} + +#[async_trait] +impl RemoteSessionRepository for SqliteRemoteAccessRepository { + async fn open(&self, session: RemoteSession) -> AppResult { + let stored = session.clone(); + self.db + .run(move |conn| { + conn.execute( + "INSERT INTO remote_sessions + (id, device_id, connected_at, last_active_at, remote_addr, closed_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + rusqlite::params![ + stored.id.as_str(), + stored.device_id.as_str(), + stored.connected_at, + stored.last_active_at, + stored.remote_addr, + stored.closed_at, + ], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + Ok(stored) + }) + .await + } + + async fn touch(&self, id: &RemoteSessionId, now: &str) -> AppResult<()> { + let id = id.to_string(); + let now = now.to_string(); + self.db + .run(move |conn| { + conn.execute( + "UPDATE remote_sessions SET last_active_at = ?1 WHERE id = ?2", + rusqlite::params![now, id], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + Ok(()) + }) + .await + } + + async fn close(&self, id: &RemoteSessionId, now: &str) -> AppResult<()> { + let id = id.to_string(); + let now = now.to_string(); + self.db + .run(move |conn| { + conn.execute( + "UPDATE remote_sessions SET closed_at = ?1 + WHERE id = ?2 AND closed_at IS NULL", + rusqlite::params![now, id], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + Ok(()) + }) + .await + } + + async fn close_all_for_device( + &self, + device_id: &RemoteDeviceId, + now: &str, + ) -> AppResult { + let device_id = device_id.to_string(); + let now = now.to_string(); + self.db + .run(move |conn| { + let affected = conn + .execute( + "UPDATE remote_sessions SET closed_at = ?1 + WHERE device_id = ?2 AND closed_at IS NULL", + rusqlite::params![now, device_id], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + Ok(affected) + }) + .await + } + + async fn close_all(&self, now: &str) -> AppResult { + let now = now.to_string(); + self.db + .run(move |conn| { + let affected = conn + .execute( + "UPDATE remote_sessions SET closed_at = ?1 WHERE closed_at IS NULL", + rusqlite::params![now], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + Ok(affected) + }) + .await + } + + async fn list_open(&self) -> AppResult> { + self.db + .run(move |conn| { + let mut statement = conn + .prepare(&format!( + "SELECT {SESSION_COLUMNS} FROM remote_sessions + WHERE closed_at IS NULL + ORDER BY connected_at DESC, id DESC" + )) + .map_err(|error| AppError::Database(error.to_string()))?; + let rows = statement + .query_map([], session_row) + .map_err(|error| AppError::Database(error.to_string()))? + .collect::, _>>() + .map_err(|error| AppError::Database(error.to_string()))?; + Ok(rows.into_iter().map(session_from_row).collect()) + }) + .await + } +} + +#[async_trait] +impl RemoteWsTicketRepository for SqliteRemoteAccessRepository { + async fn issue( + &self, + ticket_hash: &str, + device_id: &RemoteDeviceId, + expires_at: &str, + ) -> AppResult<()> { + let ticket_hash = ticket_hash.to_string(); + let device_id = device_id.to_string(); + let expires_at = expires_at.to_string(); + self.db + .run(move |conn| { + conn.execute( + "INSERT INTO remote_ws_tickets (ticket_hash, device_id, expires_at) + VALUES (?1, ?2, ?3)", + rusqlite::params![ticket_hash, device_id, expires_at], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + Ok(()) + }) + .await + } + + async fn consume(&self, ticket_hash: &str, now: &str) -> AppResult { + let ticket_hash = ticket_hash.to_string(); + let now = now.to_string(); + self.db + .run_transaction(move |conn| { + let raw = conn.query_row( + "SELECT device_id, expires_at, consumed_at FROM remote_ws_tickets + WHERE ticket_hash = ?1", + rusqlite::params![ticket_hash], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + )) + }, + ); + let (device_id, expires_at, consumed_at) = match raw { + Ok(row) => row, + Err(rusqlite::Error::QueryReturnedNoRows) => { + return Ok(RemoteWsTicketOutcome::Unknown) + } + Err(error) => return Err(AppError::Database(error.to_string())), + }; + if consumed_at.is_some() { + return Ok(RemoteWsTicketOutcome::AlreadyConsumed); + } + if expires_at <= now { + return Ok(RemoteWsTicketOutcome::Expired); + } + let consumed = conn + .execute( + "UPDATE remote_ws_tickets SET consumed_at = ?1 + WHERE ticket_hash = ?2 AND consumed_at IS NULL", + rusqlite::params![now, ticket_hash], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + if consumed != 1 { + return Ok(RemoteWsTicketOutcome::AlreadyConsumed); + } + Ok(RemoteWsTicketOutcome::Consumed( + RemoteDeviceId::from_string(device_id), + )) + }) + .await + } + + async fn consume_all_for_device( + &self, + device_id: &RemoteDeviceId, + now: &str, + ) -> AppResult { + let device_id = device_id.to_string(); + let now = now.to_string(); + self.db + .run(move |conn| { + let affected = conn + .execute( + "UPDATE remote_ws_tickets SET consumed_at = ?1 + WHERE device_id = ?2 AND consumed_at IS NULL", + rusqlite::params![now, device_id], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + Ok(affected) + }) + .await + } +} + +#[async_trait] +impl RemoteAuditLogRepository for SqliteRemoteAccessRepository { + async fn record( + &self, + device_id: Option<&RemoteDeviceId>, + action: RemoteAuditAction, + detail: Option<&str>, + now: &str, + ) -> AppResult<()> { + let device_id = device_id.map(|id| id.to_string()); + let detail = detail.map(|detail| detail.to_string()); + let now = now.to_string(); + self.db + .run(move |conn| { + conn.execute( + "INSERT INTO remote_audit_log (device_id, action, detail, created_at) + VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![device_id, action.as_db_value(), detail, now], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + Ok(()) + }) + .await + } + + async fn list_recent(&self, limit: Option) -> AppResult> { + let limit = limit.unwrap_or(100).clamp(1, 1000); + self.db + .run(move |conn| { + let mut statement = conn + .prepare( + "SELECT id, device_id, action, detail, created_at FROM remote_audit_log + ORDER BY id DESC LIMIT ?1", + ) + .map_err(|error| AppError::Database(error.to_string()))?; + let rows = statement + .query_map(rusqlite::params![limit], |row| { + Ok(RemoteAuditEntry { + id: row.get(0)?, + device_id: row + .get::<_, Option>(1)? + .map(RemoteDeviceId::from_string), + action: row.get(2)?, + detail: row.get(3)?, + created_at: row.get(4)?, + }) + }) + .map_err(|error| AppError::Database(error.to_string()))? + .collect::, _>>() + .map_err(|error| AppError::Database(error.to_string()))?; + Ok(rows) + }) + .await + } +} + +#[cfg(test)] +#[path = "sqlite_remote_access_repo_tests.rs"] +mod tests; diff --git a/src-tauri/src/infrastructure/sqlite/sqlite_remote_access_repo_tests.rs b/src-tauri/src/infrastructure/sqlite/sqlite_remote_access_repo_tests.rs new file mode 100644 index 0000000000..f8561bcb13 --- /dev/null +++ b/src-tauri/src/infrastructure/sqlite/sqlite_remote_access_repo_tests.rs @@ -0,0 +1,533 @@ +use super::*; +use crate::domain::entities::{RemoteScopeSet, RemoteWsTicket}; +use crate::domain::services::key_crypto::{generate_prefixed_key, hash_key}; +use crate::testing::SqliteTestDb; +use ralphx_remote_protocol::Scope; + +const NOW: &str = "2026-07-27T12:00:00+00:00"; +const LATER: &str = "2026-07-27T12:10:00+00:00"; +const MUCH_LATER: &str = "2026-07-27T13:00:00+00:00"; + +fn repo(db: &SqliteTestDb) -> SqliteRemoteAccessRepository { + SqliteRemoteAccessRepository::from_db(DbConnection::from_shared(db.shared_conn())) +} + +fn pairing_code(raw: &str, scopes: RemoteScopeSet, expires_at: &str) -> RemotePairingCode { + RemotePairingCode { + id: RemotePairingCodeId::new(), + code_hash: hash_key(raw), + scopes, + created_at: NOW.to_string(), + expires_at: expires_at.to_string(), + consumed_at: None, + } +} + +fn redemption(raw_code: &str, now: &str) -> RemotePairingRedemption { + let token = generate_prefixed_key("rxd_live_"); + RemotePairingRedemption { + code_hash: hash_key(raw_code), + device_id: RemoteDeviceId::new(), + device_name: "laptop".to_string(), + token_hash: hash_key(&token), + token_prefix: token.chars().take(13).collect(), + requested_scopes: None, + now: now.to_string(), + } +} + +/// C-1: every repository method here goes through `DbConnection::run` / +/// `run_transaction`. A direct `conn.lock().await` would silently reintroduce blocking +/// access, so the source itself is the assertion. +#[test] +fn the_repository_never_locks_the_connection_directly() { + let source = include_str!("sqlite_remote_access_repo.rs"); + + assert!(!source.contains("lock().await")); + assert!(!source.contains("blocking_lock()")); + assert!(source.contains(".run(move |conn|")); + assert!(source.contains(".run_transaction(move |conn|")); +} + +#[tokio::test] +async fn redeeming_a_code_mints_a_device_and_consumes_the_code_in_one_transaction() { + let db = SqliteTestDb::new("remote-access-redeem"); + let repo = repo(&db); + let raw = generate_prefixed_key("rxp_"); + repo.create(pairing_code( + &raw, + RemoteScopeSet::default_pairing_grant(), + LATER, + )) + .await + .expect("code should insert"); + + let outcome = repo + .redeem(redemption(&raw, NOW)) + .await + .expect("redemption should complete"); + + let RemotePairingOutcome::Paired(device) = outcome else { + panic!("expected a paired device, got {outcome:?}"); + }; + assert_eq!(device.scopes, RemoteScopeSet::default_pairing_grant()); + assert!(!device.agent_control_granted()); + assert!(device.is_active()); + assert!(repo + .list_outstanding(NOW) + .await + .expect("outstanding codes should read") + .is_empty()); +} + +/// A-9: only hashes reach the tables — a raw code or token never appears at rest. +#[tokio::test] +async fn codes_and_tokens_are_stored_hashed() { + let db = SqliteTestDb::new("remote-access-hash-at-rest"); + let repo = repo(&db); + let raw = generate_prefixed_key("rxp_"); + repo.create(pairing_code( + &raw, + RemoteScopeSet::default_pairing_grant(), + LATER, + )) + .await + .expect("code should insert"); + let token = generate_prefixed_key("rxd_live_"); + let mut redemption = redemption(&raw, NOW); + redemption.token_hash = hash_key(&token); + + repo.redeem(redemption).await.expect("redemption completes"); + + db.with_connection(|conn| { + let stored_code: String = conn + .query_row("SELECT code_hash FROM remote_pairing_codes", [], |row| { + row.get(0) + }) + .expect("code hash should read"); + let stored_token: String = conn + .query_row("SELECT token_hash FROM remote_devices", [], |row| { + row.get(0) + }) + .expect("token hash should read"); + assert_ne!(stored_code, raw); + assert_eq!(stored_code, hash_key(&raw)); + assert_ne!(stored_token, token); + assert_eq!(stored_token, hash_key(&token)); + }); +} + +/// P-7: two concurrent redemptions of one code — exactly one may pair. +#[tokio::test] +async fn concurrent_redemptions_of_one_code_pair_exactly_once() { + let db = SqliteTestDb::new("remote-access-single-use"); + let seeder = repo(&db); + let raw = generate_prefixed_key("rxp_"); + seeder + .create(pairing_code( + &raw, + RemoteScopeSet::default_pairing_grant(), + LATER, + )) + .await + .expect("code should insert"); + let first = SqliteRemoteAccessRepository::new(db.new_connection()); + let second = SqliteRemoteAccessRepository::new(db.new_connection()); + + let (left, right) = tokio::join!( + first.redeem(redemption(&raw, NOW)), + second.redeem(redemption(&raw, NOW)), + ); + + let paired = [&left, &right] + .iter() + .filter(|result| matches!(result, Ok(RemotePairingOutcome::Paired(_)))) + .count(); + assert_eq!( + paired, 1, + "exactly one redemption may pair: {left:?} / {right:?}" + ); + db.with_connection(|conn| { + let devices: i64 = conn + .query_row("SELECT COUNT(*) FROM remote_devices", [], |row| row.get(0)) + .expect("device count should read"); + let consumed: i64 = conn + .query_row( + "SELECT COUNT(*) FROM remote_pairing_codes WHERE consumed_at IS NOT NULL", + [], + |row| row.get(0), + ) + .expect("consumed count should read"); + assert_eq!(devices, 1); + assert_eq!(consumed, 1); + }); +} + +#[tokio::test] +async fn a_replayed_code_is_rejected_as_already_consumed() { + let db = SqliteTestDb::new("remote-access-replay"); + let repo = repo(&db); + let raw = generate_prefixed_key("rxp_"); + repo.create(pairing_code( + &raw, + RemoteScopeSet::default_pairing_grant(), + LATER, + )) + .await + .expect("code should insert"); + repo.redeem(redemption(&raw, NOW)) + .await + .expect("first redemption completes"); + + let replay = repo + .redeem(redemption(&raw, NOW)) + .await + .expect("replay completes"); + + assert_eq!(replay, RemotePairingOutcome::AlreadyConsumed); +} + +#[tokio::test] +async fn an_expired_or_unknown_code_never_pairs() { + let db = SqliteTestDb::new("remote-access-ttl"); + let repo = repo(&db); + let raw = generate_prefixed_key("rxp_"); + repo.create(pairing_code( + &raw, + RemoteScopeSet::default_pairing_grant(), + LATER, + )) + .await + .expect("code should insert"); + + let expired = repo + .redeem(redemption(&raw, MUCH_LATER)) + .await + .expect("expired redemption completes"); + let unknown = repo + .redeem(redemption("rxp_never-minted", NOW)) + .await + .expect("unknown redemption completes"); + + assert_eq!(expired, RemotePairingOutcome::Expired); + assert_eq!(unknown, RemotePairingOutcome::Unknown); + db.with_connection(|conn| { + let devices: i64 = conn + .query_row("SELECT COUNT(*) FROM remote_devices", [], |row| row.get(0)) + .expect("device count should read"); + assert_eq!(devices, 0, "a failed redemption must not mint a device"); + }); +} + +#[tokio::test] +async fn requesting_a_scope_outside_the_grant_is_refused_without_consuming_the_code() { + let db = SqliteTestDb::new("remote-access-scope-subset"); + let repo = repo(&db); + let raw = generate_prefixed_key("rxp_"); + repo.create(pairing_code( + &raw, + RemoteScopeSet::from_scopes([Scope::UiRead]), + LATER, + )) + .await + .expect("code should insert"); + let mut redemption = redemption(&raw, NOW); + redemption.requested_scopes = Some(RemoteScopeSet::from_scopes([ + Scope::UiRead, + Scope::UiOperate, + ])); + + let outcome = repo.redeem(redemption).await.expect("redemption completes"); + + assert_eq!( + outcome, + RemotePairingOutcome::ScopeNotGranted(Scope::UiOperate) + ); + assert_eq!( + repo.list_outstanding(NOW) + .await + .expect("outstanding codes should read") + .len(), + 1, + "a refused scope request must leave the code redeemable" + ); +} + +#[tokio::test] +async fn token_lookup_distinguishes_active_revoked_and_unknown() { + let db = SqliteTestDb::new("remote-access-lookup"); + let repo = repo(&db); + let raw = generate_prefixed_key("rxp_"); + repo.create(pairing_code( + &raw, + RemoteScopeSet::default_pairing_grant(), + LATER, + )) + .await + .expect("code should insert"); + let token = generate_prefixed_key("rxd_live_"); + let mut redemption = redemption(&raw, NOW); + redemption.token_hash = hash_key(&token); + let RemotePairingOutcome::Paired(device) = + repo.redeem(redemption).await.expect("redemption completes") + else { + panic!("device should pair"); + }; + + let active = repo + .lookup_by_token_hash(&hash_key(&token)) + .await + .expect("lookup completes"); + let unknown = repo + .lookup_by_token_hash(&hash_key("rxd_live_not-a-real-token")) + .await + .expect("lookup completes"); + repo.revoke(&device.id, LATER) + .await + .expect("revoke completes"); + let revoked = repo + .lookup_by_token_hash(&hash_key(&token)) + .await + .expect("lookup completes"); + + assert!(matches!(active, RemoteDeviceLookup::Active(_))); + assert_eq!(unknown, RemoteDeviceLookup::Unknown); + let RemoteDeviceLookup::Revoked(revoked) = revoked else { + panic!("a revoked device must resolve to Revoked, not Unknown"); + }; + assert_eq!(revoked.revoked_at.as_deref(), Some(LATER)); +} + +#[tokio::test] +async fn revocation_is_idempotent_and_keeps_the_first_timestamp() { + let db = SqliteTestDb::new("remote-access-revoke-idempotent"); + let repo = repo(&db); + let device = paired_device(&repo).await; + + repo.revoke(&device.id, LATER) + .await + .expect("first revoke completes"); + let second = repo + .revoke(&device.id, MUCH_LATER) + .await + .expect("second revoke completes") + .expect("device should still exist"); + + assert_eq!(second.revoked_at.as_deref(), Some(LATER)); +} + +#[tokio::test] +async fn agent_control_scopes_can_be_granted_and_narrowed_but_never_on_a_revoked_device() { + let db = SqliteTestDb::new("remote-access-agent-control"); + let repo = repo(&db); + let device = paired_device(&repo).await; + + let granted = repo + .set_scopes(&device.id, &device.scopes.with(Scope::UiAgent)) + .await + .expect("grant completes") + .expect("device exists"); + let narrowed = repo + .set_scopes(&device.id, &granted.scopes.without(Scope::UiAgent)) + .await + .expect("narrow completes") + .expect("device exists"); + repo.revoke(&device.id, LATER) + .await + .expect("revoke completes"); + let after_revoke = repo + .set_scopes(&device.id, &narrowed.scopes.with(Scope::UiAgent)) + .await + .expect("post-revoke set completes") + .expect("device exists"); + + assert!(granted.agent_control_granted()); + assert!(!narrowed.agent_control_granted()); + assert_eq!(narrowed.scopes, RemoteScopeSet::default_pairing_grant()); + assert!( + !after_revoke.agent_control_granted(), + "a revoked device must not be re-widened" + ); +} + +#[tokio::test] +async fn ws_tickets_are_single_use_device_bound_and_expiring() { + let db = SqliteTestDb::new("remote-access-ws-tickets"); + let repo = repo(&db); + let device = paired_device(&repo).await; + let raw = generate_prefixed_key("rxt_"); + let expired_raw = generate_prefixed_key("rxt_"); + repo.issue(&hash_key(&raw), &device.id, LATER) + .await + .expect("ticket should issue"); + repo.issue(&hash_key(&expired_raw), &device.id, LATER) + .await + .expect("second ticket should issue"); + + let first = repo + .consume(&hash_key(&raw), NOW) + .await + .expect("consume completes"); + let replay = repo + .consume(&hash_key(&raw), NOW) + .await + .expect("replay completes"); + let expired = repo + .consume(&hash_key(&expired_raw), MUCH_LATER) + .await + .expect("expired consume completes"); + let unknown = repo + .consume(&hash_key("rxt_never-issued"), NOW) + .await + .expect("unknown consume completes"); + + assert_eq!(first, RemoteWsTicketOutcome::Consumed(device.id.clone())); + assert_eq!(replay, RemoteWsTicketOutcome::AlreadyConsumed); + assert_eq!(expired, RemoteWsTicketOutcome::Expired); + assert_eq!(unknown, RemoteWsTicketOutcome::Unknown); +} + +#[tokio::test] +async fn revoking_a_device_can_invalidate_its_outstanding_tickets() { + let db = SqliteTestDb::new("remote-access-ticket-sweep"); + let repo = repo(&db); + let device = paired_device(&repo).await; + let raw = generate_prefixed_key("rxt_"); + repo.issue(&hash_key(&raw), &device.id, LATER) + .await + .expect("ticket should issue"); + + let swept = repo + .consume_all_for_device(&device.id, LATER) + .await + .expect("sweep completes"); + let after = repo + .consume(&hash_key(&raw), NOW) + .await + .expect("consume completes"); + + assert_eq!(swept, 1); + assert_eq!(after, RemoteWsTicketOutcome::AlreadyConsumed); +} + +#[tokio::test] +async fn sessions_close_per_device_and_globally() { + let db = SqliteTestDb::new("remote-access-sessions"); + let repo = repo(&db); + let device = paired_device(&repo).await; + let other = paired_device(&repo).await; + for owner in [&device, &other] { + repo.open(RemoteSession { + id: RemoteSessionId::new(), + device_id: owner.id.clone(), + connected_at: NOW.to_string(), + last_active_at: NOW.to_string(), + remote_addr: "127.0.0.1:51000".to_string(), + closed_at: None, + }) + .await + .expect("session opens"); + } + + let closed_for_device = repo + .close_all_for_device(&device.id, LATER) + .await + .expect("device close completes"); + let open_after = repo.list_open().await.expect("open sessions read"); + let closed_globally = repo + .close_all(MUCH_LATER) + .await + .expect("global close completes"); + + assert_eq!(closed_for_device, 1); + assert_eq!(open_after.len(), 1); + assert_eq!(open_after[0].device_id, other.id); + assert_eq!(closed_globally, 1); + assert!(repo + .list_open() + .await + .expect("open sessions read") + .is_empty()); +} + +#[tokio::test] +async fn audit_rows_are_appended_newest_first() { + let db = SqliteTestDb::new("remote-access-audit"); + let repo = repo(&db); + let device = paired_device(&repo).await; + + repo.record( + Some(&device.id), + RemoteAuditAction::AuthAccepted, + Some("GET /remote/v1/session"), + NOW, + ) + .await + .expect("audit row writes"); + repo.record(None, RemoteAuditAction::PairingRejected, None, LATER) + .await + .expect("anonymous audit row writes"); + + let entries = repo.list_recent(Some(10)).await.expect("audit log reads"); + + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].action, "pairing_rejected"); + assert_eq!(entries[0].device_id, None); + assert_eq!(entries[1].action, "auth_accepted"); + assert_eq!(entries[1].device_id.as_ref(), Some(&device.id)); + assert_eq!(entries[1].detail.as_deref(), Some("GET /remote/v1/session")); +} + +/// A malformed scope column must surface as a store error rather than an empty grant. +#[tokio::test] +async fn a_corrupt_scope_column_fails_the_read_instead_of_narrowing_the_grant() { + let db = SqliteTestDb::new("remote-access-corrupt-scopes"); + let repo = repo(&db); + let device = paired_device(&repo).await; + db.with_connection(|conn| { + conn.execute( + "UPDATE remote_devices SET scopes = '[\"ui:teleport\"]' WHERE id = ?1", + rusqlite::params![device.id.as_str()], + ) + .expect("corrupt scopes should write"); + }); + + let error = repo + .get(&device.id) + .await + .expect_err("a malformed grant must not read as an empty grant"); + + assert!(matches!(error, AppError::Database(_))); +} + +#[test] +fn the_ws_ticket_entity_carries_only_a_hash() { + let ticket = RemoteWsTicket { + ticket_hash: hash_key("rxt_example"), + device_id: RemoteDeviceId::from_string("device-1"), + expires_at: LATER.to_string(), + consumed_at: None, + }; + + assert_eq!(ticket.ticket_hash.len(), 64); + assert!(!ticket.ticket_hash.starts_with("rxt_")); +} + +async fn paired_device(repo: &SqliteRemoteAccessRepository) -> RemoteDevice { + let raw = generate_prefixed_key("rxp_"); + repo.create(pairing_code( + &raw, + RemoteScopeSet::default_pairing_grant(), + LATER, + )) + .await + .expect("code should insert"); + match repo + .redeem(redemption(&raw, NOW)) + .await + .expect("redemption completes") + { + RemotePairingOutcome::Paired(device) => device, + other => panic!("expected a paired device, got {other:?}"), + } +} From 399e6b7f46c527521bfc83ce40f81c32b00471c4 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:46:04 +0300 Subject: [PATCH 036/416] feat: add remote environments migration --- .../infrastructure/sqlite/migrations/mod.rs | 10 +- .../v20260727191500_remote_environments.rs | 31 ++++++ ...0260727191500_remote_environments_tests.rs | 94 +++++++++++++++++++ 3 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 src-tauri/src/infrastructure/sqlite/migrations/v20260727191500_remote_environments.rs create mode 100644 src-tauri/src/infrastructure/sqlite/migrations/v20260727191500_remote_environments_tests.rs diff --git a/src-tauri/src/infrastructure/sqlite/migrations/mod.rs b/src-tauri/src/infrastructure/sqlite/migrations/mod.rs index 095b0204c1..99f296bb33 100644 --- a/src-tauri/src/infrastructure/sqlite/migrations/mod.rs +++ b/src-tauri/src/infrastructure/sqlite/migrations/mod.rs @@ -554,6 +554,9 @@ mod v20260724222347_agent_task_assignment_planned_run_identity_tests; mod v20260727161131_remote_host_settings; #[cfg(test)] mod v20260727161131_remote_host_settings_tests; +mod v20260727191500_remote_environments; +#[cfg(test)] +mod v20260727191500_remote_environments_tests; #[cfg(test)] pub(super) fn migrate_scripted_agent_workflows_for_test(conn: &Connection) -> AppResult<()> { v20260715194617_scripted_agent_workflows::migrate(conn) @@ -648,7 +651,7 @@ mod v8_task_git_fields_tests; mod v9_project_git_fields_tests; /// Current schema version - bump this when adding a new migration -pub const SCHEMA_VERSION: i64 = 20260727161131; +pub const SCHEMA_VERSION: i64 = 20260727191500; /// Migration function signature type MigrationFn = fn(&Connection) -> AppResult<()>; @@ -1784,6 +1787,11 @@ const MIGRATIONS: &[Migration] = &[ name: "remote_host_settings", migrate: v20260727161131_remote_host_settings::migrate, }, + Migration { + version: 20260727191500, + name: "remote_environments", + migrate: v20260727191500_remote_environments::migrate, + }, ]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src-tauri/src/infrastructure/sqlite/migrations/v20260727191500_remote_environments.rs b/src-tauri/src/infrastructure/sqlite/migrations/v20260727191500_remote_environments.rs new file mode 100644 index 0000000000..80f284f7f4 --- /dev/null +++ b/src-tauri/src/infrastructure/sqlite/migrations/v20260727191500_remote_environments.rs @@ -0,0 +1,31 @@ +// Migration v20260727191500: remote environments registry (client mode, §6.1) +// +// One row per paired remote host. `environment_id` is the host-reported identity and is +// UNIQUE so pairing the same host via MagicDNS and via its 100.x address merges into one +// environment instead of inserting a second row. `status` drives the partial-failure +// reconciler (`active | pending_add | pending_delete`). + +use rusqlite::Connection; + +use crate::error::{AppError, AppResult}; + +pub fn migrate(conn: &Connection) -> AppResult<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS remote_environments ( + id TEXT PRIMARY KEY, + environment_id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + base_url TEXT NOT NULL, + candidate_urls TEXT NOT NULL, + token_secret_ref TEXT NOT NULL, + scopes TEXT NOT NULL, + protocol_version INTEGER NOT NULL, + status TEXT NOT NULL + CHECK (status IN ('active', 'pending_add', 'pending_delete')), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now')), + last_connected_at TEXT + );", + ) + .map_err(|error| AppError::Database(error.to_string()))?; + Ok(()) +} diff --git a/src-tauri/src/infrastructure/sqlite/migrations/v20260727191500_remote_environments_tests.rs b/src-tauri/src/infrastructure/sqlite/migrations/v20260727191500_remote_environments_tests.rs new file mode 100644 index 0000000000..c19d34e155 --- /dev/null +++ b/src-tauri/src/infrastructure/sqlite/migrations/v20260727191500_remote_environments_tests.rs @@ -0,0 +1,94 @@ +//! Tests for migration v20260727191500: remote environments registry + +use rusqlite::Connection; + +use super::{helpers, v20260727191500_remote_environments}; + +fn setup_test_db() -> Connection { + Connection::open_in_memory().expect("in-memory database should open") +} + +fn insert_environment(conn: &Connection, id: &str, environment_id: &str, status: &str) -> Result { + conn.execute( + "INSERT INTO remote_environments ( + id, environment_id, name, base_url, candidate_urls, + token_secret_ref, scopes, protocol_version, status + ) VALUES (?1, ?2, 'Mac Studio', 'https://mac-studio.tailnet.ts.net', '[]', + ?3, '[\"ui:read\"]', 1, ?4)", + rusqlite::params![id, environment_id, format!("remote-env:{id}:token"), status], + ) +} + +#[test] +fn migration_creates_the_remote_environments_schema() { + let conn = setup_test_db(); + + v20260727191500_remote_environments::migrate(&conn) + .expect("migration should create remote_environments"); + + assert!(helpers::table_exists(&conn, "remote_environments")); + for column in [ + "id", + "environment_id", + "name", + "base_url", + "candidate_urls", + "token_secret_ref", + "scopes", + "protocol_version", + "status", + "created_at", + "last_connected_at", + ] { + assert!( + helpers::column_exists(&conn, "remote_environments", column), + "remote_environments should contain {column}" + ); + } +} + +#[test] +fn migration_enforces_unique_host_identity() { + let conn = setup_test_db(); + v20260727191500_remote_environments::migrate(&conn) + .expect("migration should create remote_environments"); + + insert_environment(&conn, "row-a", "env-1", "active") + .expect("first row for a host identity should insert"); + assert!( + insert_environment(&conn, "row-b", "env-1", "active").is_err(), + "a second row for the same environment_id must violate UNIQUE" + ); +} + +#[test] +fn migration_rejects_unknown_status_values() { + let conn = setup_test_db(); + v20260727191500_remote_environments::migrate(&conn) + .expect("migration should create remote_environments"); + + for status in ["active", "pending_add", "pending_delete"] { + insert_environment(&conn, &format!("row-{status}"), &format!("env-{status}"), status) + .unwrap_or_else(|error| panic!("{status} should be accepted: {error}")); + } + assert!( + insert_environment(&conn, "row-bad", "env-bad", "half_paired").is_err(), + "statuses outside the reconciler set must be rejected" + ); +} + +#[test] +fn migration_is_idempotent() { + let conn = setup_test_db(); + + v20260727191500_remote_environments::migrate(&conn).expect("first migration should succeed"); + v20260727191500_remote_environments::migrate(&conn) + .expect("second migration should remain safe"); + + let row_count: i64 = conn + .query_row("SELECT COUNT(*) FROM remote_environments", [], |row| { + row.get(0) + }) + .expect("table should be queryable"); + assert_eq!(row_count, 0, "migration must not seed environments"); +} From acc9e0cd890d7ba8973844d774ddd8596249d19e Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:51:48 +0300 Subject: [PATCH 037/416] feat: add the remote environment entity and registry repositories --- src-tauri/src/domain/entities/mod.rs | 5 + .../src/domain/entities/remote_environment.rs | 141 ++++++++ src-tauri/src/domain/repositories/mod.rs | 2 + .../remote_environment_repository.rs | 69 ++++ .../memory/memory_remote_environment_repo.rs | 141 ++++++++ src-tauri/src/infrastructure/memory/mod.rs | 2 + src-tauri/src/infrastructure/sqlite/mod.rs | 2 + .../sqlite/sqlite_remote_environment_repo.rs | 315 ++++++++++++++++++ .../sqlite_remote_environment_repo_tests.rs | 232 +++++++++++++ 9 files changed, 909 insertions(+) create mode 100644 src-tauri/src/domain/entities/remote_environment.rs create mode 100644 src-tauri/src/domain/repositories/remote_environment_repository.rs create mode 100644 src-tauri/src/infrastructure/memory/memory_remote_environment_repo.rs create mode 100644 src-tauri/src/infrastructure/sqlite/sqlite_remote_environment_repo.rs create mode 100644 src-tauri/src/infrastructure/sqlite/sqlite_remote_environment_repo_tests.rs diff --git a/src-tauri/src/domain/entities/mod.rs b/src-tauri/src/domain/entities/mod.rs index 7895ea67e8..979167961a 100644 --- a/src-tauri/src/domain/entities/mod.rs +++ b/src-tauri/src/domain/entities/mod.rs @@ -1,4 +1,5 @@ pub mod memory_archive_job; +pub mod remote_environment; pub mod ui_feature_flag_overrides; #[cfg(test)] @@ -15,4 +16,8 @@ pub use ralphx_domain::entities::{ task_metadata, task_qa, task_step, team, types, workflow, }; pub use memory_archive_job::{MemoryArchiveJobStatus, MemoryArchiveJobType}; +pub use remote_environment::{ + remote_environment_token_secret_ref, RemoteEnvironment, RemoteEnvironmentId, + RemoteEnvironmentStatus, +}; pub use ui_feature_flag_overrides::UiFeatureFlagOverrides; diff --git a/src-tauri/src/domain/entities/remote_environment.rs b/src-tauri/src/domain/entities/remote_environment.rs new file mode 100644 index 0000000000..4ab67af453 --- /dev/null +++ b/src-tauri/src/domain/entities/remote_environment.rs @@ -0,0 +1,141 @@ +// Remote environment registry entity (client mode, §6.1). +// +// One entity per paired remote host. `environment_id` is the HOST-reported identity +// (from its descriptor); `id` is the client-local row identity that also anchors the +// Keychain secret reference, so re-pairing the same host through a different URL keeps +// the same client-local id and the same Keychain entry. + +use ralphx_remote_protocol::Scope; +use serde::{Deserialize, Serialize}; + +/// Client-local identity of a paired remote environment row. +/// +/// Newtype so remote-environment ids cannot be confused with host `environment_id` +/// strings or other entity ids. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct RemoteEnvironmentId(pub String); + +impl RemoteEnvironmentId { + pub fn new() -> Self { + Self(uuid::Uuid::new_v4().to_string()) + } + + pub fn from_string(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl Default for RemoteEnvironmentId { + fn default() -> Self { + Self::new() + } +} + +impl std::fmt::Display for RemoteEnvironmentId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +/// Reconciler-facing lifecycle status of a remote environment row (§6.1). +/// +/// `PendingAdd` and `PendingDelete` are the staged add/remove states the startup +/// reconciler resolves; only `Active` environments are usable by the proxy surface. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RemoteEnvironmentStatus { + Active, + PendingAdd, + PendingDelete, +} + +impl RemoteEnvironmentStatus { + pub fn as_str(self) -> &'static str { + match self { + Self::Active => "active", + Self::PendingAdd => "pending_add", + Self::PendingDelete => "pending_delete", + } + } + + pub fn parse(value: &str) -> Option { + match value { + "active" => Some(Self::Active), + "pending_add" => Some(Self::PendingAdd), + "pending_delete" => Some(Self::PendingDelete), + _ => None, + } + } +} + +/// Builds the Keychain key for a remote environment's device token. +/// +/// The key is anchored on the CLIENT-LOCAL row id, which survives upsert-dedup +/// re-pairs, so refreshing a token overwrites the same Keychain entry instead of +/// orphaning the previous one. +pub fn remote_environment_token_secret_ref(id: &RemoteEnvironmentId) -> String { + format!("remote-env:{}:token", id.as_str()) +} + +/// A paired remote environment as stored in the client registry. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteEnvironment { + /// Client-local row identity (uuid). + pub id: RemoteEnvironmentId, + /// Host-reported identity from the environment descriptor. UNIQUE per registry. + pub environment_id: String, + /// User-facing display name. + pub name: String, + /// Preferred endpoint, e.g. `https://mac-studio.tailnet.ts.net`. + pub base_url: String, + /// Alternate endpoints for the same host (MagicDNS vs 100.x dedup, §6.1). + pub candidate_urls: Vec, + /// Keychain key holding the device token — a reference, never the secret itself. + pub token_secret_ref: String, + /// Scopes the host granted at pairing time. + pub scopes: Vec, + /// Protocol version the host reported at pairing time. + pub protocol_version: u32, + /// Staged add/remove lifecycle status. + pub status: RemoteEnvironmentStatus, + pub created_at: String, + pub last_connected_at: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn status_round_trips_through_db_strings() { + for status in [ + RemoteEnvironmentStatus::Active, + RemoteEnvironmentStatus::PendingAdd, + RemoteEnvironmentStatus::PendingDelete, + ] { + assert_eq!( + RemoteEnvironmentStatus::parse(status.as_str()), + Some(status) + ); + } + assert_eq!(RemoteEnvironmentStatus::parse("half_paired"), None); + } + + #[test] + fn token_secret_ref_is_anchored_on_the_client_local_id() { + let id = RemoteEnvironmentId::from_string("row-1"); + assert_eq!(remote_environment_token_secret_ref(&id), "remote-env:row-1:token"); + } + + #[test] + fn status_serializes_snake_case_for_the_frontend() { + let json = serde_json::to_string(&RemoteEnvironmentStatus::PendingAdd) + .expect("status should serialize"); + assert_eq!(json, "\"pending_add\""); + } +} diff --git a/src-tauri/src/domain/repositories/mod.rs b/src-tauri/src/domain/repositories/mod.rs index 152bc3c4f3..25b377e3bb 100644 --- a/src-tauri/src/domain/repositories/mod.rs +++ b/src-tauri/src/domain/repositories/mod.rs @@ -5,6 +5,7 @@ pub mod orphan_worktree_cleanup_marker_repository; pub mod permission_repository; pub mod question_repository; pub mod queued_message_repository; +pub mod remote_environment_repository; pub mod ui_feature_flag_overrides_repository; pub use orphan_worktree_cleanup_marker_repository::{ @@ -30,4 +31,5 @@ pub use ralphx_domain::repositories::{ task_dependency_repository, task_proposal_repository, task_qa_repository, task_repository, task_step_repository, workflow_repository, }; +pub use remote_environment_repository::{RemoteEnvironmentRepository, UpsertPairedEnvironment}; pub use ui_feature_flag_overrides_repository::UiFeatureFlagOverridesRepository; diff --git a/src-tauri/src/domain/repositories/remote_environment_repository.rs b/src-tauri/src/domain/repositories/remote_environment_repository.rs new file mode 100644 index 0000000000..4b1822ed78 --- /dev/null +++ b/src-tauri/src/domain/repositories/remote_environment_repository.rs @@ -0,0 +1,69 @@ +// Repository seam for the client-side remote environment registry (§6.1). + +use async_trait::async_trait; + +use crate::domain::entities::remote_environment::{ + RemoteEnvironment, RemoteEnvironmentId, RemoteEnvironmentStatus, +}; +use crate::error::AppResult; + +/// Inputs for the transactional pairing upsert. +/// +/// The repository owns row identity: on first pairing it mints the client-local id and +/// the Keychain `token_secret_ref`; on re-pairing an already-known `environment_id` it +/// keeps both and merges `url` into `candidate_urls` instead of inserting a second row. +#[derive(Debug, Clone)] +pub struct UpsertPairedEnvironment { + /// Host-reported identity from the descriptor/pair response. + pub environment_id: String, + /// User-facing display name. + pub name: String, + /// The endpoint the pairing exchange just used. + pub url: String, + /// Scopes granted by the host, serialized as the protocol scope strings. + pub scopes: Vec, + /// Protocol version the host reported. + pub protocol_version: u32, +} + +/// Client registry of paired remote environments. +/// +/// All write methods are plain row writes; the staged add/remove ORDERING +/// (row → Keychain → activate, revoke → Keychain → row) is owned by the +/// application service, not the repository. +#[async_trait] +pub trait RemoteEnvironmentRepository: Send + Sync { + /// Transactional upsert on `environment_id` (§6.1 dedup). + /// + /// Inserts a new row as `pending_add`, or — when the host identity already + /// exists — merges `url` into `candidate_urls`, refreshes name/scopes/protocol + /// version, and resets the row to `pending_add` for the staged re-pair. + /// Exactly one row per host identity in both branches. + async fn upsert_paired(&self, params: UpsertPairedEnvironment) + -> AppResult; + + async fn get(&self, id: &RemoteEnvironmentId) -> AppResult>; + + async fn get_by_environment_id( + &self, + environment_id: &str, + ) -> AppResult>; + + async fn list(&self) -> AppResult>; + + /// Sets the lifecycle status. Errors with `AppError::NotFound` when the row is gone. + async fn set_status( + &self, + id: &RemoteEnvironmentId, + status: RemoteEnvironmentStatus, + ) -> AppResult<()>; + + /// Deletes the row. Deleting an absent row is a no-op (idempotent removal). + async fn delete(&self, id: &RemoteEnvironmentId) -> AppResult<()>; + + async fn touch_last_connected( + &self, + id: &RemoteEnvironmentId, + timestamp: &str, + ) -> AppResult<()>; +} diff --git a/src-tauri/src/infrastructure/memory/memory_remote_environment_repo.rs b/src-tauri/src/infrastructure/memory/memory_remote_environment_repo.rs new file mode 100644 index 0000000000..d99621215b --- /dev/null +++ b/src-tauri/src/infrastructure/memory/memory_remote_environment_repo.rs @@ -0,0 +1,141 @@ +// Memory-based RemoteEnvironmentRepository implementation for testing. +// Mirrors the SQLite semantics: environment_id dedup merge, pending_add inserts, +// NotFound on status writes to missing rows, idempotent delete. + +use std::collections::HashMap; +use std::sync::Arc; +use tokio::sync::RwLock; + +use async_trait::async_trait; +use chrono::Utc; + +use crate::domain::entities::remote_environment::{ + remote_environment_token_secret_ref, RemoteEnvironment, RemoteEnvironmentId, + RemoteEnvironmentStatus, +}; +use crate::domain::repositories::{RemoteEnvironmentRepository, UpsertPairedEnvironment}; +use crate::error::{AppError, AppResult}; + +pub struct MemoryRemoteEnvironmentRepository { + environments: Arc>>, +} + +impl Default for MemoryRemoteEnvironmentRepository { + fn default() -> Self { + Self::new() + } +} + +impl MemoryRemoteEnvironmentRepository { + pub fn new() -> Self { + Self { + environments: Arc::new(RwLock::new(HashMap::new())), + } + } +} + +#[async_trait] +impl RemoteEnvironmentRepository for MemoryRemoteEnvironmentRepository { + async fn upsert_paired( + &self, + params: UpsertPairedEnvironment, + ) -> AppResult { + let mut environments = self.environments.write().await; + let existing = environments + .values() + .find(|env| env.environment_id == params.environment_id) + .cloned(); + let env = match existing { + Some(mut env) => { + if params.url != env.base_url && !env.candidate_urls.contains(¶ms.url) { + env.candidate_urls.push(params.url.clone()); + } + env.name = params.name; + env.scopes = params.scopes; + env.protocol_version = params.protocol_version; + env.status = RemoteEnvironmentStatus::PendingAdd; + env + } + None => { + let id = RemoteEnvironmentId::new(); + let token_secret_ref = remote_environment_token_secret_ref(&id); + RemoteEnvironment { + id, + environment_id: params.environment_id, + name: params.name, + base_url: params.url, + candidate_urls: Vec::new(), + token_secret_ref, + scopes: params.scopes, + protocol_version: params.protocol_version, + status: RemoteEnvironmentStatus::PendingAdd, + created_at: Utc::now().format("%Y-%m-%dT%H:%M:%S+00:00").to_string(), + last_connected_at: None, + } + } + }; + environments.insert(env.id.as_str().to_string(), env.clone()); + Ok(env) + } + + async fn get(&self, id: &RemoteEnvironmentId) -> AppResult> { + let environments = self.environments.read().await; + Ok(environments.get(id.as_str()).cloned()) + } + + async fn get_by_environment_id( + &self, + environment_id: &str, + ) -> AppResult> { + let environments = self.environments.read().await; + Ok(environments + .values() + .find(|env| env.environment_id == environment_id) + .cloned()) + } + + async fn list(&self) -> AppResult> { + let environments = self.environments.read().await; + let mut all: Vec = environments.values().cloned().collect(); + all.sort_by(|a, b| { + (a.created_at.as_str(), a.id.as_str()).cmp(&(b.created_at.as_str(), b.id.as_str())) + }); + Ok(all) + } + + async fn set_status( + &self, + id: &RemoteEnvironmentId, + status: RemoteEnvironmentStatus, + ) -> AppResult<()> { + let mut environments = self.environments.write().await; + match environments.get_mut(id.as_str()) { + Some(env) => { + env.status = status; + Ok(()) + } + None => Err(AppError::NotFound(format!( + "remote environment not found: {}", + id.as_str() + ))), + } + } + + async fn delete(&self, id: &RemoteEnvironmentId) -> AppResult<()> { + let mut environments = self.environments.write().await; + environments.remove(id.as_str()); + Ok(()) + } + + async fn touch_last_connected( + &self, + id: &RemoteEnvironmentId, + timestamp: &str, + ) -> AppResult<()> { + let mut environments = self.environments.write().await; + if let Some(env) = environments.get_mut(id.as_str()) { + env.last_connected_at = Some(timestamp.to_string()); + } + Ok(()) + } +} diff --git a/src-tauri/src/infrastructure/memory/mod.rs b/src-tauri/src/infrastructure/memory/mod.rs index ffca53f3bf..543fbc620e 100644 --- a/src-tauri/src/infrastructure/memory/mod.rs +++ b/src-tauri/src/infrastructure/memory/mod.rs @@ -66,6 +66,7 @@ pub mod memory_project_repo; pub mod memory_proposal_dependency_repo; pub mod memory_question_repo; pub mod memory_queued_message_repo; +pub mod memory_remote_environment_repo; pub mod memory_review_issue_repo; pub mod memory_review_repo; pub mod memory_review_settings_repo; @@ -147,6 +148,7 @@ pub use memory_project_repo::MemoryProjectRepository; pub use memory_proposal_dependency_repo::MemoryProposalDependencyRepository; pub use memory_question_repo::MemoryQuestionRepository; pub use memory_queued_message_repo::MemoryQueuedMessageRepository; +pub use memory_remote_environment_repo::MemoryRemoteEnvironmentRepository; pub use memory_review_issue_repo::MemoryReviewIssueRepository; pub use memory_review_repo::MemoryReviewRepository; pub use memory_review_settings_repo::MemoryReviewSettingsRepository; diff --git a/src-tauri/src/infrastructure/sqlite/mod.rs b/src-tauri/src/infrastructure/sqlite/mod.rs index ccef165d43..520e068428 100644 --- a/src-tauri/src/infrastructure/sqlite/mod.rs +++ b/src-tauri/src/infrastructure/sqlite/mod.rs @@ -110,6 +110,7 @@ pub mod sqlite_project_repo; pub mod sqlite_proposal_dependency_repo; pub mod sqlite_question_repo; pub mod sqlite_queued_message_repo; +pub mod sqlite_remote_environment_repo; pub mod sqlite_review_issue_repo; pub mod sqlite_review_repo; pub mod sqlite_review_settings_repo; @@ -204,6 +205,7 @@ pub use sqlite_project_repo::SqliteProjectRepository; pub use sqlite_proposal_dependency_repo::SqliteProposalDependencyRepository; pub use sqlite_question_repo::SqliteQuestionRepository; pub use sqlite_queued_message_repo::SqliteQueuedMessageRepository; +pub use sqlite_remote_environment_repo::SqliteRemoteEnvironmentRepository; pub use sqlite_review_issue_repo::{ReviewIssueRepository, SqliteReviewIssueRepository}; pub use sqlite_review_repo::SqliteReviewRepository; pub use sqlite_review_settings_repo::SqliteReviewSettingsRepository; diff --git a/src-tauri/src/infrastructure/sqlite/sqlite_remote_environment_repo.rs b/src-tauri/src/infrastructure/sqlite/sqlite_remote_environment_repo.rs new file mode 100644 index 0000000000..0b3d94ab5c --- /dev/null +++ b/src-tauri/src/infrastructure/sqlite/sqlite_remote_environment_repo.rs @@ -0,0 +1,315 @@ +// SQLite-based RemoteEnvironmentRepository implementation (client registry, §6.1). +// +// All methods go through DbConnection::run / run_transaction (rule 16 / C-1); the +// pairing upsert is transactional so the environment_id dedup merge can never race +// itself into two rows for one host. + +use std::sync::Arc; +use tokio::sync::Mutex; + +use async_trait::async_trait; +use rusqlite::Connection; + +use super::DbConnection; +use crate::domain::entities::remote_environment::{ + remote_environment_token_secret_ref, RemoteEnvironment, RemoteEnvironmentId, + RemoteEnvironmentStatus, +}; +use crate::domain::repositories::{RemoteEnvironmentRepository, UpsertPairedEnvironment}; +use crate::error::{AppError, AppResult}; + +const SELECT_COLUMNS: &str = "id, environment_id, name, base_url, candidate_urls, \ + token_secret_ref, scopes, protocol_version, status, created_at, last_connected_at"; + +pub struct SqliteRemoteEnvironmentRepository { + db: DbConnection, +} + +impl SqliteRemoteEnvironmentRepository { + pub fn new(conn: Connection) -> Self { + Self { + db: DbConnection::new(conn), + } + } + + pub fn from_shared(conn: Arc>) -> Self { + Self { + db: DbConnection::from_shared(conn), + } + } +} + +fn parse_json_strings(field: &str, raw: &str) -> AppResult> { + serde_json::from_str::>(raw) + .map_err(|error| AppError::Database(format!("invalid {field} JSON: {error}"))) +} + +fn parse_scopes(raw: &str) -> AppResult> { + serde_json::from_str::>(raw) + .map_err(|error| AppError::Database(format!("invalid remote environment scopes: {error}"))) +} + +fn serialize_json(field: &str, value: &T) -> AppResult { + serde_json::to_string(value) + .map_err(|error| AppError::Database(format!("failed to serialize {field}: {error}"))) +} + +type RemoteEnvironmentRow = ( + String, + String, + String, + String, + String, + String, + String, + i64, + String, + String, + Option, +); + +fn row_to_tuple(row: &rusqlite::Row<'_>) -> Result { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + row.get(7)?, + row.get(8)?, + row.get(9)?, + row.get(10)?, + )) +} + +fn tuple_to_environment(tuple: RemoteEnvironmentRow) -> AppResult { + let ( + id, + environment_id, + name, + base_url, + candidate_urls, + token_secret_ref, + scopes, + protocol_version, + status, + created_at, + last_connected_at, + ) = tuple; + let status = RemoteEnvironmentStatus::parse(&status).ok_or_else(|| { + AppError::Database(format!("invalid remote environment status: {status}")) + })?; + let protocol_version = u32::try_from(protocol_version).map_err(|_| { + AppError::Database(format!( + "invalid remote environment protocol version: {protocol_version}" + )) + })?; + Ok(RemoteEnvironment { + id: RemoteEnvironmentId::from_string(id), + environment_id, + name, + base_url, + candidate_urls: parse_json_strings("candidate_urls", &candidate_urls)?, + token_secret_ref, + scopes: parse_scopes(&scopes)?, + protocol_version, + status, + created_at, + last_connected_at, + }) +} + +fn read_by_environment_id( + conn: &Connection, + environment_id: &str, +) -> AppResult> { + let result = conn.query_row( + &format!("SELECT {SELECT_COLUMNS} FROM remote_environments WHERE environment_id = ?1"), + [environment_id], + row_to_tuple, + ); + match result { + Ok(tuple) => Ok(Some(tuple_to_environment(tuple)?)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(error) => Err(AppError::Database(error.to_string())), + } +} + +fn read_by_id(conn: &Connection, id: &str) -> AppResult> { + let result = conn.query_row( + &format!("SELECT {SELECT_COLUMNS} FROM remote_environments WHERE id = ?1"), + [id], + row_to_tuple, + ); + match result { + Ok(tuple) => Ok(Some(tuple_to_environment(tuple)?)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(error) => Err(AppError::Database(error.to_string())), + } +} + +#[async_trait] +impl RemoteEnvironmentRepository for SqliteRemoteEnvironmentRepository { + async fn upsert_paired( + &self, + params: UpsertPairedEnvironment, + ) -> AppResult { + self.db + .run_transaction(move |conn| { + let scopes_json = serialize_json("scopes", ¶ms.scopes)?; + match read_by_environment_id(conn, ¶ms.environment_id)? { + Some(existing) => { + // Dedup merge (§6.1): same host through a second URL keeps the + // one row (and its Keychain ref) and records the new endpoint. + let mut candidate_urls = existing.candidate_urls.clone(); + if params.url != existing.base_url + && !candidate_urls.contains(¶ms.url) + { + candidate_urls.push(params.url.clone()); + } + let candidate_urls_json = + serialize_json("candidate_urls", &candidate_urls)?; + conn.execute( + "UPDATE remote_environments + SET name = ?2, candidate_urls = ?3, scopes = ?4, + protocol_version = ?5, status = ?6 + WHERE id = ?1", + rusqlite::params![ + existing.id.as_str(), + params.name, + candidate_urls_json, + scopes_json, + i64::from(params.protocol_version), + RemoteEnvironmentStatus::PendingAdd.as_str(), + ], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + read_by_id(conn, existing.id.as_str())?.ok_or_else(|| { + AppError::Database( + "remote environment row vanished during upsert".to_string(), + ) + }) + } + None => { + let id = RemoteEnvironmentId::new(); + let token_secret_ref = remote_environment_token_secret_ref(&id); + conn.execute( + "INSERT INTO remote_environments ( + id, environment_id, name, base_url, candidate_urls, + token_secret_ref, scopes, protocol_version, status + ) VALUES (?1, ?2, ?3, ?4, '[]', ?5, ?6, ?7, ?8)", + rusqlite::params![ + id.as_str(), + params.environment_id, + params.name, + params.url, + token_secret_ref, + scopes_json, + i64::from(params.protocol_version), + RemoteEnvironmentStatus::PendingAdd.as_str(), + ], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + read_by_id(conn, id.as_str())?.ok_or_else(|| { + AppError::Database( + "remote environment row vanished during insert".to_string(), + ) + }) + } + } + }) + .await + } + + async fn get(&self, id: &RemoteEnvironmentId) -> AppResult> { + let id = id.as_str().to_string(); + self.db.run(move |conn| read_by_id(conn, &id)).await + } + + async fn get_by_environment_id( + &self, + environment_id: &str, + ) -> AppResult> { + let environment_id = environment_id.to_string(); + self.db + .run(move |conn| read_by_environment_id(conn, &environment_id)) + .await + } + + async fn list(&self) -> AppResult> { + self.db + .run(move |conn| { + let mut stmt = conn.prepare(&format!( + "SELECT {SELECT_COLUMNS} FROM remote_environments ORDER BY created_at ASC, id ASC" + ))?; + let tuples = stmt + .query_map([], row_to_tuple)? + .collect::, _>>()?; + tuples.into_iter().map(tuple_to_environment).collect() + }) + .await + } + + async fn set_status( + &self, + id: &RemoteEnvironmentId, + status: RemoteEnvironmentStatus, + ) -> AppResult<()> { + let id = id.as_str().to_string(); + self.db + .run(move |conn| { + let updated = conn + .execute( + "UPDATE remote_environments SET status = ?2 WHERE id = ?1", + rusqlite::params![id, status.as_str()], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + if updated == 0 { + return Err(AppError::NotFound(format!( + "remote environment not found: {id}" + ))); + } + Ok(()) + }) + .await + } + + async fn delete(&self, id: &RemoteEnvironmentId) -> AppResult<()> { + let id = id.as_str().to_string(); + self.db + .run(move |conn| { + conn.execute( + "DELETE FROM remote_environments WHERE id = ?1", + rusqlite::params![id], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + Ok(()) + }) + .await + } + + async fn touch_last_connected( + &self, + id: &RemoteEnvironmentId, + timestamp: &str, + ) -> AppResult<()> { + let id = id.as_str().to_string(); + let timestamp = timestamp.to_string(); + self.db + .run(move |conn| { + conn.execute( + "UPDATE remote_environments SET last_connected_at = ?2 WHERE id = ?1", + rusqlite::params![id, timestamp], + ) + .map_err(|error| AppError::Database(error.to_string()))?; + Ok(()) + }) + .await + } +} + +#[cfg(test)] +#[path = "sqlite_remote_environment_repo_tests.rs"] +mod tests; diff --git a/src-tauri/src/infrastructure/sqlite/sqlite_remote_environment_repo_tests.rs b/src-tauri/src/infrastructure/sqlite/sqlite_remote_environment_repo_tests.rs new file mode 100644 index 0000000000..1c9e59827f --- /dev/null +++ b/src-tauri/src/infrastructure/sqlite/sqlite_remote_environment_repo_tests.rs @@ -0,0 +1,232 @@ +// Tests for SqliteRemoteEnvironmentRepository (C-1: everything through DbConnection). +// Runs against in-memory SQLite with full migrations. + +use ralphx_remote_protocol::Scope; + +use crate::domain::entities::remote_environment::{RemoteEnvironmentId, RemoteEnvironmentStatus}; +use crate::domain::repositories::{RemoteEnvironmentRepository, UpsertPairedEnvironment}; +use crate::infrastructure::sqlite::sqlite_remote_environment_repo::SqliteRemoteEnvironmentRepository; +use crate::testing::SqliteTestDb; + +fn setup_repo() -> (SqliteTestDb, SqliteRemoteEnvironmentRepository) { + let db = SqliteTestDb::new("sqlite-remote-environment-repo"); + let repo = SqliteRemoteEnvironmentRepository::from_shared(db.shared_conn()); + (db, repo) +} + +fn pairing(environment_id: &str, url: &str) -> UpsertPairedEnvironment { + UpsertPairedEnvironment { + environment_id: environment_id.to_string(), + name: "Mac Studio".to_string(), + url: url.to_string(), + scopes: vec![Scope::UiRead, Scope::UiOperate], + protocol_version: 1, + } +} + +#[tokio::test] +async fn upsert_inserts_a_pending_add_row_with_client_local_identity() { + let (_db, repo) = setup_repo(); + + let env = repo + .upsert_paired(pairing("env-1", "https://mac-studio.tailnet.ts.net")) + .await + .expect("insert should succeed"); + + assert_eq!(env.environment_id, "env-1"); + assert_eq!(env.base_url, "https://mac-studio.tailnet.ts.net"); + assert_eq!(env.candidate_urls, Vec::::new()); + assert_eq!(env.status, RemoteEnvironmentStatus::PendingAdd); + assert_eq!( + env.token_secret_ref, + format!("remote-env:{}:token", env.id.as_str()) + ); + assert_eq!(env.scopes, vec![Scope::UiRead, Scope::UiOperate]); + assert!(env.last_connected_at.is_none()); +} + +#[tokio::test] +async fn upsert_dedups_on_environment_id_and_merges_candidate_urls() { + let (_db, repo) = setup_repo(); + + // Pair via MagicDNS, activate, then pair the SAME host via its 100.x address. + let first = repo + .upsert_paired(pairing("env-1", "https://mac-studio.tailnet.ts.net")) + .await + .expect("first pairing should insert"); + repo.set_status(&first.id, RemoteEnvironmentStatus::Active) + .await + .expect("activation should succeed"); + + let merged = repo + .upsert_paired(pairing("env-1", "http://100.101.102.103:3849")) + .await + .expect("second pairing should merge"); + + // Exactly one row, one environment. + let all = repo.list().await.expect("list should succeed"); + assert_eq!(all.len(), 1, "same host identity must never yield two rows"); + + // The client-local identity and Keychain ref survive the merge, so the token + // refresh overwrites the same secret instead of orphaning the previous one. + assert_eq!(merged.id, first.id); + assert_eq!(merged.token_secret_ref, first.token_secret_ref); + assert_eq!(merged.base_url, "https://mac-studio.tailnet.ts.net"); + assert_eq!( + merged.candidate_urls, + vec!["http://100.101.102.103:3849".to_string()] + ); + // The re-pair goes back through the staged add machine. + assert_eq!(merged.status, RemoteEnvironmentStatus::PendingAdd); +} + +#[tokio::test] +async fn upsert_does_not_duplicate_known_urls() { + let (_db, repo) = setup_repo(); + + repo.upsert_paired(pairing("env-1", "https://mac-studio.tailnet.ts.net")) + .await + .expect("first pairing should insert"); + repo.upsert_paired(pairing("env-1", "http://100.101.102.103:3849")) + .await + .expect("second pairing should merge"); + let env = repo + .upsert_paired(pairing("env-1", "http://100.101.102.103:3849")) + .await + .expect("re-pairing a known URL should be a no-op merge"); + + assert_eq!( + env.candidate_urls, + vec!["http://100.101.102.103:3849".to_string()], + "an already-known endpoint must not be recorded twice" + ); + + let via_base = repo + .upsert_paired(pairing("env-1", "https://mac-studio.tailnet.ts.net")) + .await + .expect("re-pairing via the base URL should merge"); + assert_eq!( + via_base.candidate_urls, + vec!["http://100.101.102.103:3849".to_string()], + "the base URL must not be duplicated into candidate_urls" + ); +} + +#[tokio::test] +async fn unique_environment_id_is_enforced_at_the_schema_level() { + let (db, repo) = setup_repo(); + + let env = repo + .upsert_paired(pairing("env-1", "https://mac-studio.tailnet.ts.net")) + .await + .expect("insert should succeed"); + + // Bypass the repo to prove the schema itself refuses a second row. + let conn = db.new_connection(); + let result = conn.execute( + "INSERT INTO remote_environments ( + id, environment_id, name, base_url, candidate_urls, + token_secret_ref, scopes, protocol_version, status + ) VALUES ('other-id', ?1, 'Clone', 'http://100.1.2.3:3849', '[]', + 'remote-env:other-id:token', '[]', 1, 'pending_add')", + [env.environment_id.as_str()], + ); + assert!( + result.is_err(), + "schema must reject a duplicate environment_id" + ); +} + +#[tokio::test] +async fn status_round_trips_through_every_reconciler_state() { + let (_db, repo) = setup_repo(); + + let env = repo + .upsert_paired(pairing("env-1", "https://mac-studio.tailnet.ts.net")) + .await + .expect("insert should succeed"); + + for status in [ + RemoteEnvironmentStatus::Active, + RemoteEnvironmentStatus::PendingDelete, + RemoteEnvironmentStatus::PendingAdd, + ] { + repo.set_status(&env.id, status) + .await + .expect("status write should succeed"); + let read = repo + .get(&env.id) + .await + .expect("get should succeed") + .expect("row should exist"); + assert_eq!(read.status, status); + } +} + +#[tokio::test] +async fn set_status_fails_closed_on_a_missing_row() { + let (_db, repo) = setup_repo(); + + let missing = RemoteEnvironmentId::from_string("missing"); + let error = repo + .set_status(&missing, RemoteEnvironmentStatus::Active) + .await + .expect_err("activating a deleted row must not report success"); + assert!( + matches!(error, crate::error::AppError::NotFound(_)), + "expected NotFound, got {error:?}" + ); +} + +#[tokio::test] +async fn get_by_environment_id_and_delete_round_trip() { + let (_db, repo) = setup_repo(); + + let env = repo + .upsert_paired(pairing("env-1", "https://mac-studio.tailnet.ts.net")) + .await + .expect("insert should succeed"); + + let by_identity = repo + .get_by_environment_id("env-1") + .await + .expect("lookup should succeed") + .expect("row should exist"); + assert_eq!(by_identity.id, env.id); + + repo.delete(&env.id).await.expect("delete should succeed"); + assert!(repo + .get(&env.id) + .await + .expect("get should succeed") + .is_none()); + // Idempotent: deleting again is a no-op, not an error. + repo.delete(&env.id) + .await + .expect("second delete should be a no-op"); +} + +#[tokio::test] +async fn touch_last_connected_updates_only_the_timestamp() { + let (_db, repo) = setup_repo(); + + let env = repo + .upsert_paired(pairing("env-1", "https://mac-studio.tailnet.ts.net")) + .await + .expect("insert should succeed"); + + repo.touch_last_connected(&env.id, "2026-07-27T19:15:00+00:00") + .await + .expect("touch should succeed"); + + let read = repo + .get(&env.id) + .await + .expect("get should succeed") + .expect("row should exist"); + assert_eq!( + read.last_connected_at.as_deref(), + Some("2026-07-27T19:15:00+00:00") + ); + assert_eq!(read.status, RemoteEnvironmentStatus::PendingAdd); +} From df7832305d059583fe5183938c971e3b91385b4a Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:57:27 +0300 Subject: [PATCH 038/416] feat: add the client-to-host pairing wire seam --- src-tauri/src/infrastructure/mod.rs | 4 + .../src/infrastructure/remote_host_client.rs | 474 ++++++++++++++++++ 2 files changed, 478 insertions(+) create mode 100644 src-tauri/src/infrastructure/remote_host_client.rs diff --git a/src-tauri/src/infrastructure/mod.rs b/src-tauri/src/infrastructure/mod.rs index 0fead78639..8e7c44724c 100644 --- a/src-tauri/src/infrastructure/mod.rs +++ b/src-tauri/src/infrastructure/mod.rs @@ -12,6 +12,7 @@ pub(crate) mod jira_agile_client; pub mod linear_client; pub mod login_shell_env; pub mod memory; +pub mod remote_host_client; pub mod services; pub mod secret_store; pub mod sqlite; @@ -30,6 +31,9 @@ pub use atlassian_client::HyperAtlassianApiClient; pub use clickup_client::HyperClickUpApiClient; pub use granola_client::HyperGranolaApiClient; pub use linear_client::HyperLinearApiClient; +pub use remote_host_client::{ + HyperRemoteHostClient, RemoteHostClient, RemoteHostClientError, UnavailableRemoteHostClient, +}; pub use services::GhCliGithubService; pub use sqlite::{get_default_db_path, open_connection, open_memory_connection, run_migrations}; pub use supervisor::{EventBus, EventSubscriber}; diff --git a/src-tauri/src/infrastructure/remote_host_client.rs b/src-tauri/src/infrastructure/remote_host_client.rs new file mode 100644 index 0000000000..04551fc688 --- /dev/null +++ b/src-tauri/src/infrastructure/remote_host_client.rs @@ -0,0 +1,474 @@ +// RemoteHostClient — trait abstraction for the client→host pairing/auth wire (§4.2). +// +// Trait-based (like WebhookHttpClient) so the pairing/reconciler logic can run against +// a scripted mock host in unit tests. Production uses hyper 1.x (no reqwest); it must +// speak BOTH https (Serve / MagicDNS certs) and plain http (direct 100.x endpoints). +// +// The device token flows through this seam only between the host response and the +// Keychain write — it is never returned to the webview (P-18). + +use std::sync::Mutex; + +use async_trait::async_trait; +use http_body_util::{BodyExt, Full}; +use hyper::{Method, Request, StatusCode}; +use hyper_rustls::HttpsConnector; +use hyper_util::client::legacy::connect::HttpConnector; +use hyper_util::client::legacy::Client; +use hyper_util::rt::TokioExecutor; +use ralphx_remote_protocol::{EnvironmentDescriptor, Scope}; +use serde::{Deserialize, Serialize}; +use tokio::time::Duration; +use tokio_util::bytes::Bytes; + +/// Pre-auth descriptor route (mirrors `remote_server::DESCRIPTOR_PATH`). +pub const REMOTE_DESCRIPTOR_PATH: &str = "/.well-known/ralphx/environment"; +/// Pairing exchange route (§4.2). +pub const REMOTE_PAIR_PATH: &str = "/remote/v1/auth/pair"; +/// Bearer-authenticated session introspection route; a 200 proves the token is live. +pub const REMOTE_SESSION_PATH: &str = "/remote/v1/session"; +/// Self-revocation route used by the staged remove machine (best-effort). +pub const REMOTE_REVOKE_PATH: &str = "/remote/v1/auth/revoke"; + +/// Wire request for `POST /remote/v1/auth/pair` (§4.2, C-11: camelCase). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PairWireRequest { + pub pairing_code: String, + pub device_name: String, + pub requested_scopes: Vec, +} + +/// Wire response for `POST /remote/v1/auth/pair` (§4.2, C-11: camelCase). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PairWireResponse { + /// The long-term device token (`rxd_live_…`). Keychain-bound; never reaches JS. + pub device_token: String, + pub device_id: String, + pub scopes: Vec, + pub environment_id: String, +} + +/// Typed failures from the client→host wire. +#[derive(Debug, Clone, thiserror::Error)] +pub enum RemoteHostClientError { + #[error("host unreachable: {0}")] + Unreachable(String), + /// The host answered with a non-success status (bad pairing code, revoked token, …). + #[error("host rejected the request ({status}): {message}")] + Rejected { status: u16, message: String }, + #[error("host returned an invalid response: {0}")] + InvalidResponse(String), +} + +/// Client side of the host pairing/auth surface. +#[async_trait] +pub trait RemoteHostClient: Send + Sync { + /// `GET /.well-known/ralphx/environment` — pre-auth identity + version negotiation. + async fn fetch_descriptor( + &self, + base_url: &str, + ) -> Result; + + /// `POST /remote/v1/auth/pair` — exchanges a single-use pairing code for a device token. + async fn pair( + &self, + base_url: &str, + request: &PairWireRequest, + ) -> Result; + + /// Proves whether `token` is still a live bearer on the host. + /// + /// `Ok(true)` = live, `Ok(false)` = the host explicitly refused it (401/403); + /// transport failures stay errors so callers can fail closed instead of + /// mistaking an unreachable host for a revoked token. + async fn validate_token( + &self, + base_url: &str, + token: &str, + ) -> Result; + + /// Best-effort self-revocation of `token` on the host (staged remove, P-27). + async fn revoke_token(&self, base_url: &str, token: &str) + -> Result<(), RemoteHostClientError>; +} + +// ============================================================================ +// Production implementation using hyper 1.x +// ============================================================================ + +pub struct HyperRemoteHostClient { + client: Client, Full>, + request_timeout: Duration, +} + +fn install_rustls_crypto_provider() { + static INSTALL_PROVIDER: std::sync::Once = std::sync::Once::new(); + INSTALL_PROVIDER.call_once(|| { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + }); +} + +impl HyperRemoteHostClient { + pub fn new() -> Result { + install_rustls_crypto_provider(); + let mut connector = HttpConnector::new(); + connector.set_connect_timeout(Some(Duration::from_secs(10))); + connector.enforce_http(false); + let https = hyper_rustls::HttpsConnectorBuilder::new() + .with_native_roots() + .map_err(|error| format!("native root certificates unavailable: {error}"))? + .https_or_http() + .enable_http1() + .wrap_connector(connector); + Ok(Self { + client: Client::builder(TokioExecutor::new()).build(https), + request_timeout: Duration::from_secs(15), + }) + } + + async fn request_json( + &self, + method: Method, + url: &str, + bearer: Option<&str>, + body: Option>, + ) -> Result<(StatusCode, Vec), RemoteHostClientError> { + let uri: hyper::Uri = url + .parse() + .map_err(|error| RemoteHostClientError::Unreachable(format!("invalid URL: {error}")))?; + let mut builder = Request::builder() + .method(method) + .uri(uri) + .header("Content-Type", "application/json"); + if let Some(bearer) = bearer { + builder = builder.header("Authorization", format!("Bearer {bearer}")); + } + let request = builder + .body(Full::new(Bytes::from(body.unwrap_or_default()))) + .map_err(|error| { + RemoteHostClientError::Unreachable(format!("build request: {error}")) + })?; + let response = tokio::time::timeout(self.request_timeout, self.client.request(request)) + .await + .map_err(|_| { + RemoteHostClientError::Unreachable(format!( + "request timed out after {}s", + self.request_timeout.as_secs() + )) + })? + .map_err(|error| RemoteHostClientError::Unreachable(error.to_string()))?; + let status = response.status(); + let body = response + .into_body() + .collect() + .await + .map_err(|error| RemoteHostClientError::Unreachable(error.to_string()))? + .to_bytes() + .to_vec(); + Ok((status, body)) + } +} + +fn join_url(base_url: &str, path: &str) -> String { + format!("{}{}", base_url.trim_end_matches('/'), path) +} + +fn rejected(status: StatusCode, body: &[u8]) -> RemoteHostClientError { + RemoteHostClientError::Rejected { + status: status.as_u16(), + message: String::from_utf8_lossy(body).chars().take(300).collect(), + } +} + +#[async_trait] +impl RemoteHostClient for HyperRemoteHostClient { + async fn fetch_descriptor( + &self, + base_url: &str, + ) -> Result { + let url = join_url(base_url, REMOTE_DESCRIPTOR_PATH); + let (status, body) = self.request_json(Method::GET, &url, None, None).await?; + if !status.is_success() { + return Err(rejected(status, &body)); + } + serde_json::from_slice(&body) + .map_err(|error| RemoteHostClientError::InvalidResponse(error.to_string())) + } + + async fn pair( + &self, + base_url: &str, + request: &PairWireRequest, + ) -> Result { + let url = join_url(base_url, REMOTE_PAIR_PATH); + let body = serde_json::to_vec(request) + .map_err(|error| RemoteHostClientError::InvalidResponse(error.to_string()))?; + let (status, body) = self + .request_json(Method::POST, &url, None, Some(body)) + .await?; + if !status.is_success() { + return Err(rejected(status, &body)); + } + serde_json::from_slice(&body) + .map_err(|error| RemoteHostClientError::InvalidResponse(error.to_string())) + } + + async fn validate_token( + &self, + base_url: &str, + token: &str, + ) -> Result { + let url = join_url(base_url, REMOTE_SESSION_PATH); + let (status, body) = self + .request_json(Method::GET, &url, Some(token), None) + .await?; + if status.is_success() { + return Ok(true); + } + if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN { + return Ok(false); + } + Err(rejected(status, &body)) + } + + async fn revoke_token( + &self, + base_url: &str, + token: &str, + ) -> Result<(), RemoteHostClientError> { + let url = join_url(base_url, REMOTE_REVOKE_PATH); + let (status, body) = self + .request_json(Method::POST, &url, Some(token), None) + .await?; + // A host that no longer knows the token has nothing left to revoke. + if status.is_success() + || status == StatusCode::UNAUTHORIZED + || status == StatusCode::FORBIDDEN + || status == StatusCode::NOT_FOUND + { + return Ok(()); + } + Err(rejected(status, &body)) + } +} + +/// Fallback client used when TLS roots are unavailable at AppState construction +/// (mirrors `UnavailableAtlassianApiClient`): every call fails typed-unreachable. +pub struct UnavailableRemoteHostClient { + reason: String, +} + +impl UnavailableRemoteHostClient { + pub fn new(reason: impl Into) -> Self { + Self { + reason: reason.into(), + } + } +} + +#[async_trait] +impl RemoteHostClient for UnavailableRemoteHostClient { + async fn fetch_descriptor( + &self, + _base_url: &str, + ) -> Result { + Err(RemoteHostClientError::Unreachable(self.reason.clone())) + } + + async fn pair( + &self, + _base_url: &str, + _request: &PairWireRequest, + ) -> Result { + Err(RemoteHostClientError::Unreachable(self.reason.clone())) + } + + async fn validate_token( + &self, + _base_url: &str, + _token: &str, + ) -> Result { + Err(RemoteHostClientError::Unreachable(self.reason.clone())) + } + + async fn revoke_token( + &self, + _base_url: &str, + _token: &str, + ) -> Result<(), RemoteHostClientError> { + Err(RemoteHostClientError::Unreachable(self.reason.clone())) + } +} + +// ============================================================================ +// Test mock — the "mock host" the pairing/reconciler tests run against +// ============================================================================ + +/// Scripted response for one mock-host call. +pub type MockHostResult = Result; + +/// Recorded call log entry for assertion in tests. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RecordedHostCall { + Descriptor { base_url: String }, + Pair { base_url: String, request: PairWireRequest }, + Validate { base_url: String, token: String }, + Revoke { base_url: String, token: String }, +} + +/// Mock host for pairing/reconciler tests: scripted responses + a recorded call log. +pub struct MockRemoteHostClient { + pub descriptor: Mutex>, + pub pair_response: Mutex>, + pub validate_response: Mutex>, + pub revoke_response: Mutex>, + pub calls: Mutex>, +} + +impl MockRemoteHostClient { + pub fn new(descriptor: EnvironmentDescriptor, pair_response: PairWireResponse) -> Self { + Self { + descriptor: Mutex::new(Ok(descriptor)), + pair_response: Mutex::new(Ok(pair_response)), + validate_response: Mutex::new(Ok(true)), + revoke_response: Mutex::new(Ok(())), + calls: Mutex::new(Vec::new()), + } + } + + /// A mock host whose every surface fails with `Unreachable`. + pub fn unreachable() -> Self { + let error = RemoteHostClientError::Unreachable("mock host offline".to_string()); + Self { + descriptor: Mutex::new(Err(error.clone())), + pair_response: Mutex::new(Err(error.clone())), + validate_response: Mutex::new(Err(error.clone())), + revoke_response: Mutex::new(Err(error)), + calls: Mutex::new(Vec::new()), + } + } + + pub fn recorded_calls(&self) -> Vec { + self.calls.lock().expect("mock call log poisoned").clone() + } + + fn record(&self, call: RecordedHostCall) { + self.calls.lock().expect("mock call log poisoned").push(call); + } +} + +#[async_trait] +impl RemoteHostClient for MockRemoteHostClient { + async fn fetch_descriptor( + &self, + base_url: &str, + ) -> Result { + self.record(RecordedHostCall::Descriptor { + base_url: base_url.to_string(), + }); + self.descriptor + .lock() + .expect("mock descriptor poisoned") + .clone() + } + + async fn pair( + &self, + base_url: &str, + request: &PairWireRequest, + ) -> Result { + self.record(RecordedHostCall::Pair { + base_url: base_url.to_string(), + request: request.clone(), + }); + self.pair_response + .lock() + .expect("mock pair response poisoned") + .clone() + } + + async fn validate_token( + &self, + base_url: &str, + token: &str, + ) -> Result { + self.record(RecordedHostCall::Validate { + base_url: base_url.to_string(), + token: token.to_string(), + }); + self.validate_response + .lock() + .expect("mock validate response poisoned") + .clone() + } + + async fn revoke_token( + &self, + base_url: &str, + token: &str, + ) -> Result<(), RemoteHostClientError> { + self.record(RecordedHostCall::Revoke { + base_url: base_url.to_string(), + token: token.to_string(), + }); + self.revoke_response + .lock() + .expect("mock revoke response poisoned") + .clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // C-11: parity tests use REAL serialization shapes — explicit camelCase keys, + // protocol scope strings — not mock-convenient shapes. + #[test] + fn pair_request_serializes_the_documented_wire_shape() { + let request = PairWireRequest { + pairing_code: "rxp_0123456789abcdefghijklmnopqrstuv".to_string(), + device_name: "RalphX Desktop".to_string(), + requested_scopes: vec![Scope::UiRead, Scope::UiOperate], + }; + let json = serde_json::to_value(&request).expect("request should serialize"); + assert_eq!( + json, + serde_json::json!({ + "pairingCode": "rxp_0123456789abcdefghijklmnopqrstuv", + "deviceName": "RalphX Desktop", + "requestedScopes": ["ui:read", "ui:operate"], + }) + ); + } + + #[test] + fn pair_response_parses_the_documented_wire_shape() { + let raw = r#"{ + "deviceToken": "rxd_live_secret", + "deviceId": "device-1", + "scopes": ["ui:read", "ui:operate"], + "environmentId": "env-1" + }"#; + let response: PairWireResponse = + serde_json::from_str(raw).expect("response should parse"); + assert_eq!(response.device_token, "rxd_live_secret"); + assert_eq!(response.device_id, "device-1"); + assert_eq!(response.scopes, vec![Scope::UiRead, Scope::UiOperate]); + assert_eq!(response.environment_id, "env-1"); + } + + #[test] + fn join_url_tolerates_trailing_slashes() { + assert_eq!( + join_url("https://mac-studio.tailnet.ts.net/", REMOTE_PAIR_PATH), + "https://mac-studio.tailnet.ts.net/remote/v1/auth/pair" + ); + assert_eq!( + join_url("http://100.101.102.103:3849", REMOTE_DESCRIPTOR_PATH), + "http://100.101.102.103:3849/.well-known/ralphx/environment" + ); + } +} From 356dc19dda0057394592c23ccb55bdbee335eccd Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:57:31 +0300 Subject: [PATCH 039/416] feat: resolve the tailscale CLI through the shared production resolver Adds find_tailscale_cli_path(), mirroring find_codex_cli_path's Option shape (not the bare-name-fallback shape used by git/gh) so an absent binary is representable rather than silently depending on ambient PATH. --- src-tauri/src/infrastructure/tool_paths.rs | 13 ++++- .../src/infrastructure/tool_paths_tests.rs | 51 ++++++++++++++++++- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/infrastructure/tool_paths.rs b/src-tauri/src/infrastructure/tool_paths.rs index 2370b899cc..32d9dbaa86 100644 --- a/src-tauri/src/infrastructure/tool_paths.rs +++ b/src-tauri/src/infrastructure/tool_paths.rs @@ -221,6 +221,17 @@ pub(crate) fn find_codex_cli_path() -> Option { find_codex_cli_candidates().into_iter().next() } +pub(crate) fn find_tailscale_cli_path() -> Option { + find_launchable_cli_path( + "tailscale", + &[ + "/Applications/Tailscale.app/Contents/MacOS/tailscale", + "/opt/homebrew/bin/tailscale", + "/usr/local/bin/tailscale", + ], + ) +} + pub(crate) fn find_codex_cli_candidates() -> Vec { let extra_candidates = javascript_tool_env_candidates("codex"); let user_candidates = home_local_tool_candidates("codex"); @@ -453,7 +464,7 @@ fn find_cli_path_with_candidate_groups_and_shell( } #[cfg(test)] -fn find_cli_path_with_candidate_groups_for_test( +pub(crate) fn find_cli_path_with_candidate_groups_for_test( tool_name: &'static str, fixed_candidates: &[&'static str], extra_candidates: &[PathBuf], diff --git a/src-tauri/src/infrastructure/tool_paths_tests.rs b/src-tauri/src/infrastructure/tool_paths_tests.rs index 35cdb6ab63..f16fa9bb54 100644 --- a/src-tauri/src/infrastructure/tool_paths_tests.rs +++ b/src-tauri/src/infrastructure/tool_paths_tests.rs @@ -1,6 +1,6 @@ use super::tool_paths::{ - find_claude_cli_path, find_codex_cli_path, launchable_cli_path_from_shell_output, - TEST_ENV_MUTEX, + find_claude_cli_path, find_cli_path_with_candidate_groups_for_test, find_codex_cli_path, + find_launchable_cli_path_without_shell, launchable_cli_path_from_shell_output, TEST_ENV_MUTEX, }; use std::ffi::OsStr; use std::path::Path; @@ -80,6 +80,53 @@ fn find_codex_cli_path_uses_home_local_bin_when_path_is_stripped() { assert_eq!(find_codex_cli_path(), Some(local_bin.join("codex"))); } +#[test] +fn tailscale_fixed_candidate_is_found_without_shell_fallback() { + let _lock = TEST_ENV_MUTEX.lock().expect("env mutex"); + let temp_dir = tempfile::tempdir().expect("temp dir"); + let app_binary = temp_dir + .path() + .join("Applications/Tailscale.app/Contents/MacOS/tailscale"); + std::fs::create_dir_all(app_binary.parent().expect("app binary parent")) + .expect("create app binary parent"); + write_fake_tool(&app_binary); + let fixed_candidate: &'static str = + Box::leak(app_binary.to_string_lossy().into_owned().into_boxed_str()); + + let _home = EnvGuard::set_os("HOME", temp_dir.path()); + let _path = EnvGuard::set_os("PATH", ""); + let _nvm_bin = EnvGuard::unset("NVM_BIN"); + let _volta_home = EnvGuard::unset("VOLTA_HOME"); + + assert_eq!( + find_launchable_cli_path_without_shell("tailscale", &[fixed_candidate]), + Some(app_binary) + ); +} + +#[test] +fn tailscale_resolution_returns_none_when_all_candidates_are_absent() { + let _lock = TEST_ENV_MUTEX.lock().expect("env mutex"); + let temp_dir = tempfile::tempdir().expect("temp dir"); + let missing = temp_dir.path().join("missing/tailscale"); + let fixed_candidate: &'static str = + Box::leak(missing.to_string_lossy().into_owned().into_boxed_str()); + let _home = EnvGuard::set_os("HOME", temp_dir.path()); + let _path = EnvGuard::set_os("PATH", ""); + let _nvm_bin = EnvGuard::unset("NVM_BIN"); + let _volta_home = EnvGuard::unset("VOLTA_HOME"); + + let resolved = find_cli_path_with_candidate_groups_for_test( + "tailscale", + &[fixed_candidate], + &[], + &[], + |_| None, + ); + + assert!(resolved.is_empty()); +} + #[test] fn find_claude_cli_path_uses_interactive_zshrc_when_path_is_stripped() { if !Path::new("/bin/zsh").exists() { From b8e57d1dd90b9c707f7ede0454229fc65059516b Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:57:34 +0300 Subject: [PATCH 040/416] docs: add tailscale to the production CLI resolution inventory --- .claude/rules/production-cli-resolution.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.claude/rules/production-cli-resolution.md b/.claude/rules/production-cli-resolution.md index 859d4b7048..fa358d12fe 100644 --- a/.claude/rules/production-cli-resolution.md +++ b/.claude/rules/production-cli-resolution.md @@ -16,6 +16,7 @@ |---|---| | `claude` | Claude harness chat/execution and reserved user-scope `ralphx` MCP registration cleanup | | `codex` | Codex harness chat/execution and capability probes | +| `tailscale` | remote host tailnet discovery and Serve exposure | | `gh` | GitHub auth, PR polling, PR/release operations | | `git` | repository state, diffs, worktrees, merge/cleanup | | `node` | bundled RalphX MCP servers | From 27896e5c994c62c6836803b08ef47ef8468f4038 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:57:40 +0300 Subject: [PATCH 041/416] feat: add the tailscale status, serve, and reachability integration module Implements infrastructure/tailscale.rs: status --json parsing (CGNAT-filtered self-addresses + MagicDNS name), Serve acquire/release behind a command-recorder-testable seam, and an HTTPS reachability probe against the host's own descriptor route. All subprocess calls resolve exclusively through find_tailscale_cli_path and use tokio::process::Command with piped stdout/stderr and a timeout, never .output(). A missing binary or an unreachable/logged-out tailnet degrades to an empty address list rather than an error; only a spawn failure or undecodable status output is treated as Unavailable. Serve acquire/release are implemented and unit-tested via the recorder seam but intentionally not yet wired into the listener's start/stop lifecycle in remote_server/mod.rs, since that requires exposure-mode bookkeeping on the private ActiveRemoteListener struct that a parallel change is still evolving. --- src-tauri/src/infrastructure/mod.rs | 3 + src-tauri/src/infrastructure/tailscale.rs | 282 ++++++++++++++++++ .../src/infrastructure/tailscale_tests.rs | 147 +++++++++ 3 files changed, 432 insertions(+) create mode 100644 src-tauri/src/infrastructure/tailscale.rs create mode 100644 src-tauri/src/infrastructure/tailscale_tests.rs diff --git a/src-tauri/src/infrastructure/mod.rs b/src-tauri/src/infrastructure/mod.rs index 0fead78639..7f1518fe90 100644 --- a/src-tauri/src/infrastructure/mod.rs +++ b/src-tauri/src/infrastructure/mod.rs @@ -17,6 +17,7 @@ pub mod secret_store; pub mod sqlite; pub(crate) mod subprocess_env_policy; pub mod supervisor; +pub(crate) mod tailscale; pub mod tool_paths; pub mod external_mcp_supervisor; pub mod webhook_http_client; @@ -59,6 +60,8 @@ mod login_shell_env_tests; #[cfg(test)] mod subprocess_env_policy_tests; #[cfg(test)] +mod tailscale_tests; +#[cfg(test)] mod tool_paths_tests; #[cfg(test)] mod webhook_http_client_tests; diff --git a/src-tauri/src/infrastructure/tailscale.rs b/src-tauri/src/infrastructure/tailscale.rs new file mode 100644 index 0000000000..3c421ced35 --- /dev/null +++ b/src-tauri/src/infrastructure/tailscale.rs @@ -0,0 +1,282 @@ +//! Tailscale CLI integration for remote-host discovery and Serve exposure. + +use std::net::IpAddr; +use std::process::Stdio; +use std::time::Duration; + +use async_trait::async_trait; +use http_body_util::Full; +use hyper::{Method, Request}; +use hyper_rustls::HttpsConnector; +use hyper_util::client::legacy::connect::HttpConnector; +use hyper_util::client::legacy::Client; +use hyper_util::rt::TokioExecutor; +use serde::Deserialize; +use tokio::io::{AsyncBufReadExt, BufReader}; +use tokio_util::bytes::Bytes; + +use crate::infrastructure::tool_paths::find_tailscale_cli_path; +use crate::remote_server::settings::{ + is_tailnet_cgnat_ipv4, TailnetProviderError, TailnetSelfAddressProvider, +}; +use crate::remote_server::DESCRIPTOR_PATH; + +const COMMAND_TIMEOUT: Duration = Duration::from_secs(10); +const HTTPS_PORT: &str = "--https=443"; + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub(crate) enum TailscaleServeError { + #[error("tailscale CLI is unavailable")] + CliUnavailable, + #[error("tailscale Serve command could not be launched: {0}")] + Launch(String), + #[error("tailscale Serve command timed out")] + Timeout, + #[error("tailscale Serve output could not be read: {0}")] + Output(String), + #[error("tailscale Serve command failed with status {0}")] + Exit(String), +} + +#[async_trait] +pub(crate) trait TailscaleCommandRunner: Send + Sync { + async fn run_status(&self) -> Result; + async fn run_serve_acquire(&self, port: u16) -> Result<(), TailscaleServeError>; + async fn run_serve_release(&self) -> Result<(), TailscaleServeError>; +} + +pub(crate) struct RealTailscaleCommandRunner; + +#[async_trait] +impl TailscaleCommandRunner for RealTailscaleCommandRunner { + async fn run_status(&self) -> Result { + let path = find_tailscale_cli_path().ok_or_else(|| { + TailnetProviderError::Unavailable("tailscale CLI disappeared after resolution".into()) + })?; + let output = run_command(path, status_args()) + .await + .map_err(TailscaleProcessError::into_provider_error)?; + Ok(output.stdout) + } + + async fn run_serve_acquire(&self, port: u16) -> Result<(), TailscaleServeError> { + run_serve_command(serve_acquire_args(port)).await + } + + async fn run_serve_release(&self) -> Result<(), TailscaleServeError> { + run_serve_command(serve_release_args()).await + } +} + +pub(crate) struct TailscaleSelfAddressProvider; + +#[async_trait] +impl TailnetSelfAddressProvider for TailscaleSelfAddressProvider { + async fn self_addresses(&self) -> Result, TailnetProviderError> { + if find_tailscale_cli_path().is_none() { + return Ok(Vec::new()); + } + let stdout = RealTailscaleCommandRunner.run_status().await?; + Ok(parse_status(&stdout)?.self_addresses()) + } +} + +/// Acquires the process-independent Tailscale Serve mapping for a loopback listener. +#[allow(dead_code)] +pub(crate) async fn acquire_serve(port: u16) -> Result<(), TailscaleServeError> { + RealTailscaleCommandRunner.run_serve_acquire(port).await +} + +/// Releases the Tailscale Serve mapping. +#[allow(dead_code)] +pub(crate) async fn release_serve() -> Result<(), TailscaleServeError> { + RealTailscaleCommandRunner.run_serve_release().await +} + +#[derive(Debug, Deserialize)] +pub(crate) struct TailscaleStatus { + #[serde(rename = "Version")] + _version: String, + #[serde(rename = "BackendState")] + _backend_state: String, + #[serde(rename = "Self", default)] + self_status: Option, +} + +#[derive(Debug, Deserialize)] +struct TailscaleSelfStatus { + #[serde(rename = "DNSName", default)] + dns_name: String, + #[serde(rename = "TailscaleIPs", default)] + tailscale_ips: Vec, +} + +impl TailscaleStatus { + pub(crate) fn self_addresses(&self) -> Vec { + self.self_status + .as_ref() + .into_iter() + .flat_map(|status| status.tailscale_ips.iter().copied()) + .filter(|address| matches!(address, IpAddr::V4(ip) if is_tailnet_cgnat_ipv4(*ip))) + .collect() + } + + pub(crate) fn magicdns_name(&self) -> Option<&str> { + self.self_status + .as_ref() + .map(|status| status.dns_name.trim_end_matches('.')) + .filter(|name| !name.is_empty()) + } +} + +pub(crate) fn parse_status(stdout: &str) -> Result { + serde_json::from_str(stdout).map_err(|error| { + TailnetProviderError::Unavailable(format!("invalid tailscale status JSON: {error}")) + }) +} + +/// Probes the descriptor route through Tailscale's MagicDNS hostname. +pub(crate) async fn probe_magicdns_reachability(magicdns_name: &str) -> bool { + let hostname = magicdns_name.trim_end_matches('.'); + if hostname.is_empty() { + return false; + } + let Ok(uri) = format!("https://{hostname}{DESCRIPTOR_PATH}").parse::() else { + return false; + }; + install_rustls_crypto_provider(); + let Ok(https) = hyper_rustls::HttpsConnectorBuilder::new() + .with_native_roots() + .map(|builder| builder.https_only().enable_http1().build()) + else { + return false; + }; + let client: Client, Full> = + Client::builder(TokioExecutor::new()).build(https); + let Ok(request) = Request::builder() + .method(Method::GET) + .uri(uri) + .body(Full::new(Bytes::new())) + else { + return false; + }; + + matches!( + tokio::time::timeout(COMMAND_TIMEOUT, client.request(request)).await, + Ok(Ok(response)) if response.status().is_success() + ) +} + +fn install_rustls_crypto_provider() { + static INSTALL_PROVIDER: std::sync::Once = std::sync::Once::new(); + INSTALL_PROVIDER.call_once(|| { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + }); +} + +fn status_args() -> Vec { + vec!["status".into(), "--json".into()] +} + +pub(crate) fn serve_acquire_args(port: u16) -> Vec { + vec![ + "serve".into(), + "--bg".into(), + HTTPS_PORT.into(), + format!("http://127.0.0.1:{port}"), + ] +} + +pub(crate) fn serve_release_args() -> Vec { + vec!["serve".into(), HTTPS_PORT.into(), "off".into()] +} + +async fn run_serve_command(args: Vec) -> Result<(), TailscaleServeError> { + let path = find_tailscale_cli_path().ok_or(TailscaleServeError::CliUnavailable)?; + let output = run_command(path, args).await.map_err(|error| match error { + TailscaleProcessError::Launch(message) => TailscaleServeError::Launch(message), + TailscaleProcessError::Timeout => TailscaleServeError::Timeout, + TailscaleProcessError::Output(message) => TailscaleServeError::Output(message), + })?; + if output.success { + Ok(()) + } else { + Err(TailscaleServeError::Exit(output.status)) + } +} + +struct CommandOutput { + stdout: String, + success: bool, + status: String, +} + +enum TailscaleProcessError { + Launch(String), + Timeout, + Output(String), +} + +impl TailscaleProcessError { + fn into_provider_error(self) -> TailnetProviderError { + let message = match self { + Self::Launch(message) => format!("tailscale status could not be launched: {message}"), + Self::Timeout => "tailscale status timed out".to_string(), + Self::Output(message) => { + format!("tailscale status output could not be read: {message}") + } + }; + TailnetProviderError::Unavailable(message) + } +} + +async fn run_command( + path: std::path::PathBuf, + args: Vec, +) -> Result { + let mut child = tokio::process::Command::new(path) + .args(&args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .map_err(|error| TailscaleProcessError::Launch(error.to_string()))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| TailscaleProcessError::Output("stdout pipe was not captured".to_string()))?; + let stderr = child + .stderr + .take() + .ok_or_else(|| TailscaleProcessError::Output("stderr pipe was not captured".to_string()))?; + + let collect = async { + let (stdout, stderr, status) = + tokio::join!(read_stream(stdout), read_stream(stderr), child.wait()); + let stdout = stdout.map_err(TailscaleProcessError::Output)?; + stderr.map_err(TailscaleProcessError::Output)?; + let status = status.map_err(|error| TailscaleProcessError::Output(error.to_string()))?; + Ok(CommandOutput { + stdout, + success: status.success(), + status: status.to_string(), + }) + }; + + tokio::time::timeout(COMMAND_TIMEOUT, collect) + .await + .map_err(|_| TailscaleProcessError::Timeout)? +} + +async fn read_stream(stream: impl tokio::io::AsyncRead + Unpin) -> Result { + let mut reader = BufReader::new(stream).lines(); + let mut lines = Vec::new(); + while let Some(line) = reader + .next_line() + .await + .map_err(|error| error.to_string())? + { + lines.push(line); + } + Ok(lines.join("\n")) +} diff --git a/src-tauri/src/infrastructure/tailscale_tests.rs b/src-tauri/src/infrastructure/tailscale_tests.rs new file mode 100644 index 0000000000..2be55f4a4d --- /dev/null +++ b/src-tauri/src/infrastructure/tailscale_tests.rs @@ -0,0 +1,147 @@ +use std::net::{IpAddr, Ipv4Addr}; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; + +use super::tailscale::{ + parse_status, serve_acquire_args, serve_release_args, TailscaleCommandRunner, + TailscaleServeError, +}; +use crate::remote_server::settings::{is_tailnet_cgnat_ipv4, TailnetProviderError}; + +const RUNNING_STATUS: &str = r#"{ + "Version": "1.66.1", + "BackendState": "Running", + "Self": { + "ID": "n1234567890CNTRL", + "PublicKey": "nodekey:abc123", + "HostName": "mac-studio", + "DNSName": "mac-studio.tail1234.ts.net.", + "OS": "macOS", + "TailscaleIPs": [ + "100.101.102.103", + "fd7a:115c:a1e0:0:0:0:0:1234" + ], + "Online": true + }, + "MagicDNSSuffix": "tail1234.ts.net", + "CurrentTailnet": { + "Name": "example.ts.net", + "MagicDNSSuffix": "tail1234.ts.net", + "MagicDNSEnabled": true + } +}"#; + +const LOGGED_OUT_STATUS: &str = r#"{ + "Version": "1.66.1", + "BackendState": "NeedsLogin", + "MagicDNSSuffix": "", + "CurrentTailnet": null +}"#; + +#[derive(Clone, Default)] +struct RecordingTailscaleCommandRunner { + calls: Arc>>>, +} + +#[async_trait] +impl TailscaleCommandRunner for RecordingTailscaleCommandRunner { + async fn run_status(&self) -> Result { + Ok(RUNNING_STATUS.to_string()) + } + + async fn run_serve_acquire(&self, port: u16) -> Result<(), TailscaleServeError> { + self.calls + .lock() + .expect("command recorder mutex") + .push(serve_acquire_args(port)); + Ok(()) + } + + async fn run_serve_release(&self) -> Result<(), TailscaleServeError> { + self.calls + .lock() + .expect("command recorder mutex") + .push(serve_release_args()); + Ok(()) + } +} + +#[test] +fn running_status_parses_magicdns_and_filters_self_addresses() { + let status = parse_status(RUNNING_STATUS).expect("running status parses"); + + assert_eq!(status.magicdns_name(), Some("mac-studio.tail1234.ts.net")); + assert_eq!( + status.self_addresses(), + vec![IpAddr::V4(Ipv4Addr::new(100, 101, 102, 103))] + ); +} + +#[test] +fn logged_out_status_is_valid_and_has_no_self_addresses() { + let status = parse_status(LOGGED_OUT_STATUS).expect("logged-out status parses"); + + assert_eq!(status.magicdns_name(), None); + assert!(status.self_addresses().is_empty()); +} + +#[test] +fn malformed_or_unexpected_status_is_unavailable() { + assert!(matches!( + parse_status("not json at all"), + Err(TailnetProviderError::Unavailable(_)) + )); + assert!(matches!( + parse_status(r#"{"Self":null}"#), + Err(TailnetProviderError::Unavailable(_)) + )); +} + +#[tokio::test] +async fn recorder_captures_exact_serve_acquire_and_release_argv() { + let runner = RecordingTailscaleCommandRunner::default(); + runner + .run_serve_acquire(3849) + .await + .expect("record acquire"); + runner.run_serve_release().await.expect("record release"); + + assert_eq!( + *runner.calls.lock().expect("command recorder mutex"), + vec![ + vec![ + "serve".to_string(), + "--bg".to_string(), + "--https=443".to_string(), + "http://127.0.0.1:3849".to_string(), + ], + vec![ + "serve".to_string(), + "--https=443".to_string(), + "off".to_string(), + ], + ] + ); +} + +#[test] +fn cgnat_validation_covers_both_boundaries_and_nearby_non_tailnet_ranges() { + for address in [ + Ipv4Addr::new(100, 64, 0, 0), + Ipv4Addr::new(100, 127, 255, 255), + ] { + assert!(is_tailnet_cgnat_ipv4(address), "{address} should be CGNAT"); + } + for address in [ + Ipv4Addr::new(100, 63, 255, 255), + Ipv4Addr::new(100, 128, 0, 0), + Ipv4Addr::new(192, 168, 1, 20), + Ipv4Addr::new(127, 0, 0, 1), + ] { + assert!( + !is_tailnet_cgnat_ipv4(address), + "{address} should not be CGNAT" + ); + } +} From f5478fa22f14f54a6594ad0da62228d09db8e4c3 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 20:57:46 +0300 Subject: [PATCH 042/416] feat: wire the real tailscale provider and advertised endpoints into the remote listener MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swaps UnconfiguredTailnetProvider for TailscaleSelfAddressProvider at its 3 production call sites (auto-start, enable command, exposure-mode command), supplying PR 1.1's bind-validation seam with real tailnet self-addresses. UnconfiguredTailnetProvider itself is left in place for tests. Adds a pure advertised-endpoints assembly to remote_server/endpoints.rs (loopback+serve, tailnet-direct) describing reachability URLs from already-known settings/status facts. Access is descriptive only here — it grants nothing and is not yet wired into any route or the wire protocol; PR 1.7's host settings UI is the intended consumer. --- .../src/commands/remote_host_commands.rs | 7 +-- src-tauri/src/remote_server/endpoints.rs | 49 +++++++++++++++++++ .../src/remote_server/endpoints_tests.rs | 45 +++++++++++++++++ src-tauri/src/remote_server/mod.rs | 6 ++- 4 files changed, 102 insertions(+), 5 deletions(-) create mode 100644 src-tauri/src/remote_server/endpoints_tests.rs diff --git a/src-tauri/src/commands/remote_host_commands.rs b/src-tauri/src/commands/remote_host_commands.rs index d764d689ce..013bbb1a68 100644 --- a/src-tauri/src/commands/remote_host_commands.rs +++ b/src-tauri/src/commands/remote_host_commands.rs @@ -5,8 +5,9 @@ use serde::{Deserialize, Serialize}; use tauri::State; +use crate::infrastructure::tailscale::TailscaleSelfAddressProvider; use crate::remote_server::settings::{ - RemoteExposureMode, RemoteHostSettings, RemoteHostSettingsStore, UnconfiguredTailnetProvider, + RemoteExposureMode, RemoteHostSettings, RemoteHostSettingsStore, }; use crate::remote_server::{ apply_exposure_mode, remote_listener_handle, start_listener, stop_listener, @@ -58,7 +59,7 @@ pub async fn start_remote_listener( ) -> Result { let store = settings_store(&state); let handle = remote_listener_handle(&app); - start_listener(&handle, &store, &UnconfiguredTailnetProvider) + start_listener(&handle, &store, &TailscaleSelfAddressProvider) .await .map_err(|error| error.to_string())?; let settings = store.get_or_create().await.map_err(|e| e.to_string())?; @@ -92,7 +93,7 @@ pub async fn set_remote_exposure_mode( let settings = apply_exposure_mode( &handle, &store, - &UnconfiguredTailnetProvider, + &TailscaleSelfAddressProvider, input.exposure_mode, ) .await diff --git a/src-tauri/src/remote_server/endpoints.rs b/src-tauri/src/remote_server/endpoints.rs index b30ff77495..bfd9e4cb16 100644 --- a/src-tauri/src/remote_server/endpoints.rs +++ b/src-tauri/src/remote_server/endpoints.rs @@ -4,11 +4,14 @@ //! stranger can read, so it publishes identity and version negotiation data only (§3.1, §4.6). use std::sync::Arc; +use std::{net::Ipv4Addr, string::ToString}; use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; use ralphx_remote_protocol::{EnvironmentDescriptor, PROTOCOL_VERSION}; use serde::Serialize; +use crate::remote_server::settings::RemoteExposureMode; + /// Oldest client protocol this host will negotiate with. /// /// Host acceptance policy, not protocol shape — it lives here rather than in the protocol @@ -39,6 +42,52 @@ pub(crate) struct RemoteHealthBody { pub status: &'static str, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum AdvertisedEndpointKind { + LoopbackServe, + TailnetDirect, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct AdvertisedEndpoint { + pub kind: AdvertisedEndpointKind, + pub url: String, + pub available: bool, +} + +/// Describes remote URLs from already-observed reachability facts without granting access. +pub(crate) fn advertised_endpoints( + exposure_mode: RemoteExposureMode, + port: u16, + magicdns_name: Option<&str>, + serve_reachable: bool, + tailnet_self_ip: Option, +) -> Vec { + match exposure_mode { + RemoteExposureMode::Serve => magicdns_name + .map(str::trim) + .map(|name| name.trim_end_matches('.')) + .filter(|name| !name.is_empty()) + .map(|name| AdvertisedEndpoint { + kind: AdvertisedEndpointKind::LoopbackServe, + url: format!("https://{name}"), + available: serve_reachable, + }) + .into_iter() + .collect(), + RemoteExposureMode::TailnetDirect => tailnet_self_ip + .map(|address| AdvertisedEndpoint { + kind: AdvertisedEndpointKind::TailnetDirect, + url: format!("https://{address}:{port}"), + available: true, + }) + .into_iter() + .collect(), + } +} + /// Builds the five-field descriptor published at `/.well-known/ralphx/environment`. pub(crate) fn environment_descriptor(environment_id: &str) -> EnvironmentDescriptor { EnvironmentDescriptor { diff --git a/src-tauri/src/remote_server/endpoints_tests.rs b/src-tauri/src/remote_server/endpoints_tests.rs new file mode 100644 index 0000000000..8afb74b2bd --- /dev/null +++ b/src-tauri/src/remote_server/endpoints_tests.rs @@ -0,0 +1,45 @@ +use std::net::Ipv4Addr; + +use super::endpoints::{advertised_endpoints, AdvertisedEndpoint, AdvertisedEndpointKind}; +use super::settings::RemoteExposureMode; + +#[test] +fn serve_mode_advertises_resolved_magicdns_reachability() { + assert_eq!( + advertised_endpoints( + RemoteExposureMode::Serve, + 3849, + Some("mac-studio.tail1234.ts.net."), + true, + None, + ), + vec![AdvertisedEndpoint { + kind: AdvertisedEndpointKind::LoopbackServe, + url: "https://mac-studio.tail1234.ts.net".to_string(), + available: true, + }] + ); +} + +#[test] +fn serve_mode_without_magicdns_degrades_to_no_endpoint() { + assert!(advertised_endpoints(RemoteExposureMode::Serve, 3849, None, false, None).is_empty()); +} + +#[test] +fn tailnet_direct_mode_advertises_the_self_ip_and_listener_port() { + assert_eq!( + advertised_endpoints( + RemoteExposureMode::TailnetDirect, + 3849, + None, + false, + Some(Ipv4Addr::new(100, 101, 102, 103)), + ), + vec![AdvertisedEndpoint { + kind: AdvertisedEndpointKind::TailnetDirect, + url: "https://100.101.102.103:3849".to_string(), + available: true, + }] + ); +} diff --git a/src-tauri/src/remote_server/mod.rs b/src-tauri/src/remote_server/mod.rs index 253c51bc35..dc7dfb9cd5 100644 --- a/src-tauri/src/remote_server/mod.rs +++ b/src-tauri/src/remote_server/mod.rs @@ -8,6 +8,8 @@ pub mod capture; pub mod endpoints; #[cfg(test)] +mod endpoints_tests; +#[cfg(test)] mod listener_tests; pub mod settings; #[cfg(test)] @@ -37,13 +39,13 @@ use tokio_util::sync::CancellationToken; use tower_http::cors::{AllowOrigin, CorsLayer}; use crate::error::AppError; +use crate::infrastructure::tailscale::TailscaleSelfAddressProvider; use crate::remote_server::endpoints::{ environment_descriptor_handler, health_handler, RemoteRouterState, }; use crate::remote_server::settings::{ effective_remote_port, resolve_bind_address, RemoteBindError, RemoteExposureMode, RemoteHostSettings, RemoteHostSettingsStore, TailnetSelfAddressProvider, - UnconfiguredTailnetProvider, }; pub(crate) const DESCRIPTOR_PATH: &str = "/.well-known/ralphx/environment"; @@ -399,7 +401,7 @@ pub(crate) async fn auto_start_remote_listener_from_handle(app_handle: &tauri::A let store = RemoteHostSettingsStore::from_db(state.db.clone()); let handle = remote_listener_handle(app_handle); - match auto_start_if_enabled(&handle, &store, &UnconfiguredTailnetProvider).await { + match auto_start_if_enabled(&handle, &store, &TailscaleSelfAddressProvider).await { Ok(Some(address)) => { tracing::info!(%address, "Remote listener auto-started from persisted settings"); } From d9fa3425f13f03d6d254c33e21ef29f568dbbd6e Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:01:04 +0300 Subject: [PATCH 043/416] fix: allow interim dead code pending PR 1.7 consumers --- src-tauri/src/infrastructure/tailscale.rs | 8 ++++++++ src-tauri/src/remote_server/endpoints.rs | 6 ++++++ src-tauri/src/remote_server/settings.rs | 5 +++-- 3 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/infrastructure/tailscale.rs b/src-tauri/src/infrastructure/tailscale.rs index 3c421ced35..631e89e054 100644 --- a/src-tauri/src/infrastructure/tailscale.rs +++ b/src-tauri/src/infrastructure/tailscale.rs @@ -105,6 +105,8 @@ pub(crate) struct TailscaleStatus { #[derive(Debug, Deserialize)] struct TailscaleSelfStatus { + // Read by magicdns_name(), consumed by PR 1.7's advertised-endpoints surface. + #[allow(dead_code)] #[serde(rename = "DNSName", default)] dns_name: String, #[serde(rename = "TailscaleIPs", default)] @@ -121,6 +123,8 @@ impl TailscaleStatus { .collect() } + // Consumed by PR 1.7's advertised-endpoints surface. + #[allow(dead_code)] pub(crate) fn magicdns_name(&self) -> Option<&str> { self.self_status .as_ref() @@ -136,6 +140,8 @@ pub(crate) fn parse_status(stdout: &str) -> Result bool { let hostname = magicdns_name.trim_end_matches('.'); if hostname.is_empty() { @@ -167,6 +173,8 @@ pub(crate) async fn probe_magicdns_reachability(magicdns_name: &str) -> bool { ) } +// Live once probe_magicdns_reachability gains its PR 1.7 caller. +#[allow(dead_code)] fn install_rustls_crypto_provider() { static INSTALL_PROVIDER: std::sync::Once = std::sync::Once::new(); INSTALL_PROVIDER.call_once(|| { diff --git a/src-tauri/src/remote_server/endpoints.rs b/src-tauri/src/remote_server/endpoints.rs index bfd9e4cb16..ab086b77ce 100644 --- a/src-tauri/src/remote_server/endpoints.rs +++ b/src-tauri/src/remote_server/endpoints.rs @@ -42,6 +42,8 @@ pub(crate) struct RemoteHealthBody { pub status: &'static str, } +// Consumed by PR 1.7's Remote Access pane (endpoint list). +#[allow(dead_code)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) enum AdvertisedEndpointKind { @@ -49,6 +51,8 @@ pub(crate) enum AdvertisedEndpointKind { TailnetDirect, } +// Consumed by PR 1.7's Remote Access pane (endpoint list). +#[allow(dead_code)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct AdvertisedEndpoint { @@ -58,6 +62,8 @@ pub(crate) struct AdvertisedEndpoint { } /// Describes remote URLs from already-observed reachability facts without granting access. +// Consumed by PR 1.7's Remote Access pane (endpoint list). +#[allow(dead_code)] pub(crate) fn advertised_endpoints( exposure_mode: RemoteExposureMode, port: u16, diff --git a/src-tauri/src/remote_server/settings.rs b/src-tauri/src/remote_server/settings.rs index 8e2f7add15..452170c83b 100644 --- a/src-tauri/src/remote_server/settings.rs +++ b/src-tauri/src/remote_server/settings.rs @@ -175,8 +175,9 @@ pub(crate) trait TailnetSelfAddressProvider: Send + Sync { /// Stub provider reporting that this host has no tailnet addresses. /// -/// Consequence: `RemoteExposureMode::TailnetDirect` is refused until PR 1.6 ships the real -/// provider. Serve mode (loopback) is unaffected. +/// Test double since PR 1.6 wired the real `TailscaleSelfAddressProvider` into +/// production call sites; retained for listener/settings tests. +#[allow(dead_code)] pub(crate) struct UnconfiguredTailnetProvider; #[async_trait::async_trait] From 330356a19457a69ba1f69635334fe7a6fb7a5f9c Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:02:44 +0300 Subject: [PATCH 044/416] feat: add the remote environment service with staged add and remove --- src-tauri/src/application/mod.rs | 5 + .../application/remote_environment_service.rs | 629 ++++++++++++++ .../remote_environment_service_tests.rs | 803 ++++++++++++++++++ 3 files changed, 1437 insertions(+) create mode 100644 src-tauri/src/application/remote_environment_service.rs create mode 100644 src-tauri/src/application/remote_environment_service_tests.rs diff --git a/src-tauri/src/application/mod.rs b/src-tauri/src/application/mod.rs index d82fc2155e..bca7e1cef1 100644 --- a/src-tauri/src/application/mod.rs +++ b/src-tauri/src/application/mod.rs @@ -190,6 +190,7 @@ pub mod question_state; pub mod ready_task_scheduler; pub mod reconciliation; pub mod recovery_queue; +pub mod remote_environment_service; pub mod resume_validator; pub mod review_issue_service; pub mod review_service; @@ -362,6 +363,10 @@ pub use question_state::{PendingQuestionInfo, QuestionAnswer, QuestionOption, Qu pub use ready_task_scheduler::spawn_ready_task_scheduler_if_needed; pub use reconciliation::ReconciliationRunner; pub use recovery_queue::{ProcessSummary, RecoveryItem, RecoveryPriority, RecoveryQueue}; +pub use remote_environment_service::{ + RemoteEnvironmentError, RemoteEnvironmentReconcileReport, RemoteEnvironmentService, + LOCAL_ENVIRONMENT_ID, +}; pub use resume_validator::{ResumeValidationResult, ResumeValidator}; pub use review_issue_service::{CreateIssueInput, ReviewIssueService}; pub use review_service::ReviewService; diff --git a/src-tauri/src/application/remote_environment_service.rs b/src-tauri/src/application/remote_environment_service.rs new file mode 100644 index 0000000000..a5f48f62f8 --- /dev/null +++ b/src-tauri/src/application/remote_environment_service.rs @@ -0,0 +1,629 @@ +// RemoteEnvironmentService — client-side registry, pairing, staged add/remove state +// machines, startup reconciler, and the active-environment authority for the Rust +// proxy surface (§4.2, §6.1, §6.4). +// +// Invariants owned here (not by the repository, not by the webview): +// - Add order: row as `pending_add` FIRST → Keychain secret → flip `active` (P-27). +// - Remove order: mark `pending_delete` → best-effort host revoke → Keychain delete +// → row delete (P-27). Any other ordering can orphan a valid bearer. +// - `remote_invoke`/`remote_fetch` authorize against the Rust-side active-environment +// mirror, never a trusted JS argument; background environments accept health ops +// only (P-26). +// - The device token flows host-response → Keychain and Keychain → host header only; +// no method returns it (P-18). +// +// Honest containment (N3-M3, documented residual — do NOT claim more): the binding +// prevents CONCURRENT fan-out and bearer EXTRACTION. A compromised renderer can still +// call `set_active_environment` and drive paired environments one at a time, +// sequentially. v1 accepts that residual; nothing here asserts it prevented. + +use std::sync::Arc; + +use ralphx_remote_protocol::{ErrorCode, Scope, PROTOCOL_VERSION}; +use tokio::sync::RwLock; + +use crate::domain::entities::remote_environment::{ + RemoteEnvironment, RemoteEnvironmentId, RemoteEnvironmentStatus, +}; +use crate::domain::repositories::{RemoteEnvironmentRepository, UpsertPairedEnvironment}; +use crate::domain::services::{SecretStore, SecretStoreError}; +use crate::error::AppError; +use crate::infrastructure::remote_host_client::{ + PairWireRequest, RemoteHostClient, RemoteHostClientError, REMOTE_DESCRIPTOR_PATH, +}; + +/// The always-present local environment identity (§6.4). It has no supervisor, no +/// registry row, and never accepts remote proxy calls. +pub const LOCAL_ENVIRONMENT_ID: &str = "local"; + +/// Health-op fetch path for the host health probe. +pub const REMOTE_HEALTH_PATH: &str = "/health"; + +/// Scopes requested at pairing time. Default pairing intentionally does NOT request +/// `ui:agent` (§3.3); agent control is a per-device host-side grant. +const DEFAULT_REQUESTED_SCOPES: &[Scope] = &[Scope::UiRead, Scope::UiOperate]; + +/// Typed failures of the remote environment surface (rule 5: no string matching). +#[derive(Debug, thiserror::Error)] +pub enum RemoteEnvironmentError { + /// Transport is not wired yet — the outbound HTTP invoke path and WS land in + /// PR 2.2/2.3. Authorization already ran when this is returned. + #[error("remote transport is not connected")] + NotConnected, + #[error("environment {requested} is not the active environment ({active})")] + NotActiveEnvironment { requested: String, active: String }, + #[error("no paired remote environment with id {0}")] + UnknownEnvironment(String), + #[error("environment {0} is not active (status: {1})")] + EnvironmentNotUsable(String, &'static str), + #[error("the local environment does not accept remote proxy calls")] + LocalEnvironment, + #[error("invalid pairing URL: {0}")] + InvalidUrl(String), + #[error( + "host requires client protocol >= {host_min_client}, this client speaks {client}" + )] + VersionSkew { host_min_client: u32, client: u32 }, + #[error("host identity mismatch: descriptor reported {descriptor}, pair response {response}")] + IdentityMismatch { + descriptor: String, + response: String, + }, + #[error("pairing rejected by host: {0}")] + PairRejected(String), + #[error("host unreachable: {0}")] + Unreachable(String), + #[error("secret store: {0}")] + Secret(#[from] SecretStoreError), + #[error(transparent)] + Db(#[from] AppError), +} + +impl RemoteEnvironmentError { + /// Stable machine-readable code carried across the IPC boundary + /// (`"{code}: {message}"`). Protocol-crate codes are reused where one exists. + pub fn code(&self) -> &'static str { + match self { + Self::NotConnected => "NOT_CONNECTED", + Self::NotActiveEnvironment { .. } | Self::LocalEnvironment => { + remote_error_code_str(ErrorCode::RemoteForbidden) + } + Self::UnknownEnvironment(_) | Self::EnvironmentNotUsable(..) => { + remote_error_code_str(ErrorCode::RemoteCommandUnavailable) + } + Self::InvalidUrl(_) => "INVALID_PAIRING_URL", + Self::VersionSkew { .. } => remote_error_code_str(ErrorCode::RemoteVersionMismatch), + Self::IdentityMismatch { .. } => "HOST_IDENTITY_MISMATCH", + Self::PairRejected(_) => "PAIRING_REJECTED", + Self::Unreachable(_) => remote_error_code_str(ErrorCode::RemoteUnreachable), + Self::Secret(_) => "SECRET_STORE_UNAVAILABLE", + Self::Db(_) => "DATABASE_ERROR", + } + } + + /// IPC-boundary rendering: `"{code}: {message}"`. + pub fn to_command_error(&self) -> String { + format!("{}: {}", self.code(), self) + } +} + +/// Serialized form of a protocol error code (single authority: the protocol crate). +fn remote_error_code_str(code: ErrorCode) -> &'static str { + match code { + ErrorCode::RemoteCommandUnavailable => "REMOTE_COMMAND_UNAVAILABLE", + ErrorCode::RemoteForbidden => "REMOTE_FORBIDDEN", + ErrorCode::RemoteUnauthorized => "REMOTE_UNAUTHORIZED", + ErrorCode::RemoteUnreachable => "REMOTE_UNREACHABLE", + ErrorCode::RemoteVersionMismatch => "REMOTE_VERSION_MISMATCH", + ErrorCode::RemoteTimeoutUnknown => "REMOTE_TIMEOUT_UNKNOWN", + ErrorCode::RemoteRequestInProgress => "REMOTE_REQUEST_IN_PROGRESS", + ErrorCode::RemoteRequestIdReused => "REMOTE_REQUEST_ID_REUSED", + } +} + +/// What the startup reconciler did, for logs and tests (row ids). +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct RemoteEnvironmentReconcileReport { + /// `pending_add` rows whose secret validated against the host → flipped `active`. + pub activated: Vec, + /// `pending_add` rows with no Keychain secret → deleted (crash before the + /// Keychain write left a husk). + pub deleted_husks: Vec, + /// `pending_delete` rows completed: revoke retried, secret deleted, row deleted. + pub completed_removals: Vec, + /// `pending_add` rows whose token the host explicitly refused → surfaced for + /// re-pair (row kept so the UI can offer it). + pub needs_repair: Vec, + /// Rows skipped fail-closed (Keychain unavailable, host unreachable, write + /// failure) — retried on the next startup. + pub deferred: Vec, +} + +pub struct RemoteEnvironmentService { + repo: Arc, + secret_store: Arc, + host_client: Arc, + /// Rust-side mirror of the frontend `environmentStore` identity (§6.4). + /// The ONLY writer is `set_active_environment`; proxy authorization reads it. + active_environment_id: RwLock, +} + +impl RemoteEnvironmentService { + pub fn new( + repo: Arc, + secret_store: Arc, + host_client: Arc, + ) -> Self { + Self { + repo, + secret_store, + host_client, + active_environment_id: RwLock::new(LOCAL_ENVIRONMENT_ID.to_string()), + } + } + + // ------------------------------------------------------------------ + // Pairing (staged add machine, §6.1 / P-27) + // ------------------------------------------------------------------ + + /// Pairs this client with a host: descriptor → pair exchange → row as + /// `pending_add` → Keychain write → flip `active`. + /// + /// The ordering is load-bearing. A crash after the row write leaves a + /// reconcilable `pending_add` husk; a crash after the Keychain write leaves a + /// `pending_add` row the reconciler re-validates and activates. There is no + /// ordering in which a valid bearer exists without a row referencing it. + pub async fn pair( + &self, + url: &str, + code: &str, + name: &str, + ) -> Result { + let url = validate_pairing_url(url)?; + + // 1. Descriptor: learn the host identity + protocol, abort on skew (§4.2). + let descriptor = self + .host_client + .fetch_descriptor(&url) + .await + .map_err(descriptor_error)?; + if descriptor.min_client_protocol > PROTOCOL_VERSION { + return Err(RemoteEnvironmentError::VersionSkew { + host_min_client: descriptor.min_client_protocol, + client: PROTOCOL_VERSION, + }); + } + + // 2. Pair exchange (single-use code consumption is host-side). + let response = self + .host_client + .pair( + &url, + &PairWireRequest { + pairing_code: code.to_string(), + device_name: client_device_name(), + requested_scopes: DEFAULT_REQUESTED_SCOPES.to_vec(), + }, + ) + .await + .map_err(pair_error)?; + if response.environment_id != descriptor.environment_id { + return Err(RemoteEnvironmentError::IdentityMismatch { + descriptor: descriptor.environment_id, + response: response.environment_id, + }); + } + + // 3. Row FIRST, as pending_add (dedup-merges on environment_id, §6.1). + let env = self + .repo + .upsert_paired(UpsertPairedEnvironment { + environment_id: response.environment_id, + name: name.to_string(), + url, + scopes: response.scopes, + protocol_version: descriptor.protocol_version, + }) + .await?; + + // 4. Keychain write. On failure the pending_add row stays behind and the + // startup reconciler deletes the husk — never a secret without a row. + self.secret_store + .put_secret(&env.token_secret_ref, &response.device_token) + .await?; + + // 5. Flip to active. On failure the reconciler re-validates and activates. + self.repo + .set_status(&env.id, RemoteEnvironmentStatus::Active) + .await?; + + Ok(RemoteEnvironment { + status: RemoteEnvironmentStatus::Active, + ..env + }) + } + + // ------------------------------------------------------------------ + // Removal (staged remove machine, §6.1 / P-27) + // ------------------------------------------------------------------ + + /// Removes a paired environment: mark `pending_delete` → best-effort host + /// revoke → Keychain delete → row delete. + /// + /// Keychain failures abort BEFORE the row delete so the row keeps referencing + /// the secret and the startup reconciler can finish the removal — deleting the + /// row first would orphan a valid bearer. + pub async fn remove(&self, id: &str) -> Result<(), RemoteEnvironmentError> { + let env_id = RemoteEnvironmentId::from_string(id); + let Some(env) = self.repo.get(&env_id).await? else { + // Idempotent: removing an unknown environment is a no-op, but never + // leave the mirror pointing at a gone id. + self.reset_active_if(id).await; + return Ok(()); + }; + + self.repo + .set_status(&env.id, RemoteEnvironmentStatus::PendingDelete) + .await?; + // Proxy authority dies before any network effect. + self.reset_active_if(id).await; + + match self.secret_store.get_secret(&env.token_secret_ref).await { + Ok(Some(token)) => { + // Best-effort revoke; an unreachable host must not block removal + // (the reconciler retries on the next startup only if the later + // Keychain delete fails). + if let Err(error) = self.host_client.revoke_token(&env.base_url, &token).await { + tracing::warn!( + environment = env.id.as_str(), + %error, + "Best-effort remote token revoke failed during removal" + ); + } + } + Ok(None) => {} + // Fail closed: cannot prove the secret state — keep the pending_delete + // row so the reconciler retries. + Err(error) => return Err(error.into()), + } + + self.secret_store.delete_secret(&env.token_secret_ref).await?; + self.repo.delete(&env.id).await?; + Ok(()) + } + + // ------------------------------------------------------------------ + // Startup reconciler (P-27) + // ------------------------------------------------------------------ + + /// Resolves staged add/remove states left behind by crashes. Runs in the same + /// app-setup recovery phase as other startup recovery. + pub async fn reconcile_on_startup(&self) -> RemoteEnvironmentReconcileReport { + let mut report = RemoteEnvironmentReconcileReport::default(); + let environments = match self.repo.list().await { + Ok(environments) => environments, + Err(error) => { + // Fail closed: an unreadable registry reconciles nothing. + tracing::warn!(%error, "Remote environment reconciler could not read the registry"); + return report; + } + }; + + for env in environments { + match env.status { + RemoteEnvironmentStatus::Active => {} + RemoteEnvironmentStatus::PendingAdd => { + self.reconcile_pending_add(&env, &mut report).await; + } + RemoteEnvironmentStatus::PendingDelete => { + self.reconcile_pending_delete(&env, &mut report).await; + } + } + } + report + } + + async fn reconcile_pending_add( + &self, + env: &RemoteEnvironment, + report: &mut RemoteEnvironmentReconcileReport, + ) { + let row_id = env.id.as_str().to_string(); + let secret = match self.secret_store.get_secret(&env.token_secret_ref).await { + Ok(secret) => secret, + Err(error) => { + // Fail closed: an unreadable Keychain is NOT "no secret". Deleting + // the row here could orphan a live bearer. + tracing::warn!(environment = %row_id, %error, "Keychain unreadable; deferring pending_add reconcile"); + report.deferred.push(row_id); + return; + } + }; + let Some(token) = secret else { + // Crash before the Keychain write: the row is a husk with no bearer. + match self.repo.delete(&env.id).await { + Ok(()) => report.deleted_husks.push(row_id), + Err(error) => { + tracing::warn!(environment = %row_id, %error, "Failed to delete pending_add husk"); + report.deferred.push(row_id); + } + } + return; + }; + match self.host_client.validate_token(&env.base_url, &token).await { + Ok(true) => match self + .repo + .set_status(&env.id, RemoteEnvironmentStatus::Active) + .await + { + Ok(()) => report.activated.push(row_id), + Err(error) => { + tracing::warn!(environment = %row_id, %error, "Failed to activate reconciled environment"); + report.deferred.push(row_id); + } + }, + Ok(false) => { + // The host provably refuses this bearer — keep the row so the UI + // can surface a re-pair; the dead token is not an orphan hazard. + report.needs_repair.push(row_id); + } + Err(error) => { + // Fail closed: unreachable is not proof in either direction. + tracing::debug!(environment = %row_id, %error, "Host unreachable; deferring pending_add validation"); + report.deferred.push(row_id); + } + } + } + + async fn reconcile_pending_delete( + &self, + env: &RemoteEnvironment, + report: &mut RemoteEnvironmentReconcileReport, + ) { + let row_id = env.id.as_str().to_string(); + match self.secret_store.get_secret(&env.token_secret_ref).await { + Ok(Some(token)) => { + // Retry the best-effort revoke, then delete secret before row. + if let Err(error) = self.host_client.revoke_token(&env.base_url, &token).await { + tracing::debug!(environment = %row_id, %error, "Reconciler revoke retry failed (best-effort)"); + } + if let Err(error) = self.secret_store.delete_secret(&env.token_secret_ref).await + { + // Keychain delete failed: the row must survive to keep the + // secret referenced for the next retry. + tracing::warn!(environment = %row_id, %error, "Keychain delete failed; deferring removal"); + report.deferred.push(row_id); + return; + } + match self.repo.delete(&env.id).await { + Ok(()) => report.completed_removals.push(row_id), + Err(error) => { + tracing::warn!(environment = %row_id, %error, "Row delete failed after secret delete"); + report.deferred.push(row_id); + } + } + } + Ok(None) => match self.repo.delete(&env.id).await { + Ok(()) => report.completed_removals.push(row_id), + Err(error) => { + tracing::warn!(environment = %row_id, %error, "Row delete failed for secretless pending_delete"); + report.deferred.push(row_id); + } + }, + Err(error) => { + tracing::warn!(environment = %row_id, %error, "Keychain unreadable; deferring pending_delete reconcile"); + report.deferred.push(row_id); + } + } + } + + // ------------------------------------------------------------------ + // Registry reads + // ------------------------------------------------------------------ + + pub async fn list(&self) -> Result, RemoteEnvironmentError> { + Ok(self.repo.list().await?) + } + + // ------------------------------------------------------------------ + // Active-environment mirror (§6.4) + proxy authorization (P-26) + // ------------------------------------------------------------------ + + pub async fn active_environment_id(&self) -> String { + self.active_environment_id.read().await.clone() + } + + /// Switches the authoritative active environment. `"local"` is always valid; + /// a remote id must reference an `active` registry row. + pub async fn set_active_environment(&self, id: &str) -> Result<(), RemoteEnvironmentError> { + if id != LOCAL_ENVIRONMENT_ID { + let env = self + .repo + .get(&RemoteEnvironmentId::from_string(id)) + .await? + .ok_or_else(|| RemoteEnvironmentError::UnknownEnvironment(id.to_string()))?; + if env.status != RemoteEnvironmentStatus::Active { + return Err(RemoteEnvironmentError::EnvironmentNotUsable( + id.to_string(), + env.status.as_str(), + )); + } + } + *self.active_environment_id.write().await = id.to_string(); + Ok(()) + } + + async fn reset_active_if(&self, id: &str) { + let mut active = self.active_environment_id.write().await; + if *active == id { + *active = LOCAL_ENVIRONMENT_ID.to_string(); + } + } + + /// Authorizes a proxy call for `id` (P-26). Non-health ops require `id` to + /// equal the Rust-side active environment; health ops only require a + /// registered environment. `"local"` never routes through the remote proxy. + async fn authorize_proxy_target( + &self, + id: &str, + health_op: bool, + ) -> Result { + if id == LOCAL_ENVIRONMENT_ID { + return Err(RemoteEnvironmentError::LocalEnvironment); + } + let env = self + .repo + .get(&RemoteEnvironmentId::from_string(id)) + .await? + .ok_or_else(|| RemoteEnvironmentError::UnknownEnvironment(id.to_string()))?; + if !health_op { + let active = self.active_environment_id.read().await; + if *active != id { + return Err(RemoteEnvironmentError::NotActiveEnvironment { + requested: id.to_string(), + active: active.clone(), + }); + } + } + Ok(env) + } + + // ------------------------------------------------------------------ + // Proxy command surface (stubs; transport lands in PR 2.2/2.3) + // ------------------------------------------------------------------ + + /// Opens the outbound WS for `id`. The socket body lands in PR 2.3; the stub + /// still enforces that only a registered, usable environment can be connected. + pub async fn connect(&self, id: &str) -> Result<(), RemoteEnvironmentError> { + if id == LOCAL_ENVIRONMENT_ID { + return Err(RemoteEnvironmentError::LocalEnvironment); + } + let env = self + .repo + .get(&RemoteEnvironmentId::from_string(id)) + .await? + .ok_or_else(|| RemoteEnvironmentError::UnknownEnvironment(id.to_string()))?; + if env.status != RemoteEnvironmentStatus::Active { + return Err(RemoteEnvironmentError::EnvironmentNotUsable( + id.to_string(), + env.status.as_str(), + )); + } + Err(RemoteEnvironmentError::NotConnected) + } + + /// Closes the outbound WS for `id`. Disconnecting an unconnected environment + /// is idempotent success; the socket teardown body lands in PR 2.3. + pub async fn disconnect(&self, id: &str) -> Result<(), RemoteEnvironmentError> { + if id == LOCAL_ENVIRONMENT_ID { + return Err(RemoteEnvironmentError::LocalEnvironment); + } + self.repo + .get(&RemoteEnvironmentId::from_string(id)) + .await? + .ok_or_else(|| RemoteEnvironmentError::UnknownEnvironment(id.to_string()))?; + Ok(()) + } + + /// Forwards one command invoke to the active environment (HTTP path lands in + /// PR 2.2). Active-env-bound: a non-active id is rejected BEFORE any + /// transport work, so the binding is proven independently of the stub. + pub async fn invoke( + &self, + id: &str, + _request_id: &str, + _cmd: &str, + _args: serde_json::Value, + ) -> Result { + self.authorize_proxy_target(id, false).await?; + Err(RemoteEnvironmentError::NotConnected) + } + + /// Fetches a host resource. Health paths (descriptor probe, health probe) are + /// permitted for background environments; anything else is active-env-bound. + pub async fn fetch( + &self, + id: &str, + path: &str, + ) -> Result { + let health_op = path == REMOTE_DESCRIPTOR_PATH || path == REMOTE_HEALTH_PATH; + let env = self.authorize_proxy_target(id, health_op).await?; + if path == REMOTE_DESCRIPTOR_PATH { + let descriptor = self + .host_client + .fetch_descriptor(&env.base_url) + .await + .map_err(descriptor_error)?; + return serde_json::to_value(&descriptor).map_err(|error| { + RemoteEnvironmentError::Unreachable(format!( + "descriptor serialization failed: {error}" + )) + }); + } + // Authenticated fetch paths need the bearer-holding transport (PR 2.2). + Err(RemoteEnvironmentError::NotConnected) + } +} + +/// Shape-validates a pairing URL: http(s), a host, and nothing else is required. +/// Pairing inputs never reach filesystem or process sinks (C-5) — this guards the +/// network sink only. +fn validate_pairing_url(url: &str) -> Result { + let trimmed = url.trim(); + let parsed: hyper::Uri = trimmed + .parse() + .map_err(|error| RemoteEnvironmentError::InvalidUrl(format!("{error}")))?; + match parsed.scheme_str() { + Some("http") | Some("https") => {} + other => { + return Err(RemoteEnvironmentError::InvalidUrl(format!( + "unsupported scheme: {}", + other.unwrap_or("none") + ))) + } + } + if parsed.host().is_none() { + return Err(RemoteEnvironmentError::InvalidUrl( + "missing host".to_string(), + )); + } + Ok(trimmed.trim_end_matches('/').to_string()) +} + +fn client_device_name() -> String { + format!("RalphX Desktop {}", env!("CARGO_PKG_VERSION")) +} + +fn descriptor_error(error: RemoteHostClientError) -> RemoteEnvironmentError { + match error { + RemoteHostClientError::Unreachable(message) => { + RemoteEnvironmentError::Unreachable(message) + } + RemoteHostClientError::Rejected { status, message } => { + RemoteEnvironmentError::Unreachable(format!( + "descriptor request refused ({status}): {message}" + )) + } + RemoteHostClientError::InvalidResponse(message) => { + RemoteEnvironmentError::Unreachable(format!("invalid descriptor: {message}")) + } + } +} + +fn pair_error(error: RemoteHostClientError) -> RemoteEnvironmentError { + match error { + RemoteHostClientError::Unreachable(message) => { + RemoteEnvironmentError::Unreachable(message) + } + RemoteHostClientError::Rejected { status, message } => { + RemoteEnvironmentError::PairRejected(format!("({status}) {message}")) + } + RemoteHostClientError::InvalidResponse(message) => { + RemoteEnvironmentError::PairRejected(format!("invalid pair response: {message}")) + } + } +} + +#[cfg(test)] +#[path = "remote_environment_service_tests.rs"] +mod tests; diff --git a/src-tauri/src/application/remote_environment_service_tests.rs b/src-tauri/src/application/remote_environment_service_tests.rs new file mode 100644 index 0000000000..a54796d4ba --- /dev/null +++ b/src-tauri/src/application/remote_environment_service_tests.rs @@ -0,0 +1,803 @@ +// RemoteEnvironmentService tests: pairing against a mock host, the staged +// add/remove machines, the P-27 partial-failure reconciler matrix, the P-26 +// active-environment binding, and the P-18 no-token-to-JS proof. + +use std::sync::{Arc, Mutex as StdMutex}; + +use async_trait::async_trait; +use ralphx_remote_protocol::{EnvironmentDescriptor, Scope, PROTOCOL_VERSION}; + +use super::*; +use crate::domain::entities::remote_environment::RemoteEnvironmentStatus; +use crate::infrastructure::memory::{MemoryRemoteEnvironmentRepository, MemorySecretStore}; +use crate::infrastructure::remote_host_client::{ + MockRemoteHostClient, PairWireResponse, RecordedHostCall, RemoteHostClientError, +}; + +const HOST_URL: &str = "https://mac-studio.tailnet.ts.net"; +const HOST_URL_DIRECT: &str = "http://100.101.102.103:3849"; +const TOKEN: &str = "rxd_live_0123456789abcdef"; + +fn descriptor(environment_id: &str) -> EnvironmentDescriptor { + EnvironmentDescriptor { + environment_id: environment_id.to_string(), + app_version: "0.81.0".to_string(), + protocol_version: PROTOCOL_VERSION, + min_client_protocol: PROTOCOL_VERSION, + platform: "macos".to_string(), + } +} + +fn pair_response(environment_id: &str) -> PairWireResponse { + PairWireResponse { + device_token: TOKEN.to_string(), + device_id: "device-1".to_string(), + scopes: vec![Scope::UiRead, Scope::UiOperate], + environment_id: environment_id.to_string(), + } +} + +struct Fixture { + repo: Arc, + secrets: Arc, + host: Arc, + service: RemoteEnvironmentService, +} + +fn fixture() -> Fixture { + fixture_with_host(MockRemoteHostClient::new( + descriptor("env-1"), + pair_response("env-1"), + )) +} + +fn fixture_with_host(host: MockRemoteHostClient) -> Fixture { + let repo = Arc::new(MemoryRemoteEnvironmentRepository::new()); + let secrets = Arc::new(MemorySecretStore::new()); + let host = Arc::new(host); + let service = RemoteEnvironmentService::new( + Arc::clone(&repo) as Arc, + Arc::clone(&secrets) as Arc, + Arc::clone(&host) as Arc, + ); + Fixture { + repo, + secrets, + host, + service, + } +} + +use crate::domain::repositories::RemoteEnvironmentRepository as _; +use crate::domain::services::SecretStore as _; + +// ============================================================================ +// Pairing against the mock host +// ============================================================================ + +#[tokio::test] +async fn pair_success_lands_an_active_row_with_the_token_in_the_secret_store() { + let f = fixture(); + + let env = f + .service + .pair(HOST_URL, "rxp_code", "Mac Studio") + .await + .expect("pairing should succeed"); + + assert_eq!(env.status, RemoteEnvironmentStatus::Active); + assert_eq!(env.environment_id, "env-1"); + let stored = f + .repo + .get(&env.id) + .await + .expect("get should succeed") + .expect("row should exist"); + assert_eq!(stored.status, RemoteEnvironmentStatus::Active); + assert_eq!( + f.secrets + .get_secret(&env.token_secret_ref) + .await + .expect("secret read should succeed") + .as_deref(), + Some(TOKEN) + ); +} + +#[tokio::test] +async fn pair_bad_code_leaves_no_row_and_no_secret() { + let f = fixture(); + *f.host.pair_response.lock().expect("mock") = Err(RemoteHostClientError::Rejected { + status: 401, + message: "invalid pairing code".to_string(), + }); + + let error = f + .service + .pair(HOST_URL, "rxp_wrong", "Mac Studio") + .await + .expect_err("bad code must fail"); + assert!(matches!(error, RemoteEnvironmentError::PairRejected(_))); + assert!(f.repo.list().await.expect("list").is_empty()); + // No dangling secret: the Keychain write only happens after a row exists. + assert!(f + .secrets + .get_secret("remote-env:any:token") + .await + .expect("secret read") + .is_none()); +} + +#[tokio::test] +async fn pair_version_skew_aborts_before_the_pair_exchange() { + let f = fixture(); + { + let mut descriptor_slot = f.host.descriptor.lock().expect("mock"); + let mut skewed = descriptor("env-1"); + skewed.min_client_protocol = PROTOCOL_VERSION + 1; + *descriptor_slot = Ok(skewed); + } + + let error = f + .service + .pair(HOST_URL, "rxp_code", "Mac Studio") + .await + .expect_err("skew must abort"); + assert!(matches!( + error, + RemoteEnvironmentError::VersionSkew { + host_min_client, .. + } if host_min_client == PROTOCOL_VERSION + 1 + )); + // The abort happened at the descriptor step: no pair call ever went out. + let calls = f.host.recorded_calls(); + assert!(calls + .iter() + .all(|call| !matches!(call, RecordedHostCall::Pair { .. }))); + assert!(f.repo.list().await.expect("list").is_empty()); +} + +#[tokio::test] +async fn pair_identity_mismatch_fails_closed_without_a_row() { + let f = fixture(); + *f.host.pair_response.lock().expect("mock") = Ok(pair_response("someone-else")); + + let error = f + .service + .pair(HOST_URL, "rxp_code", "Mac Studio") + .await + .expect_err("identity mismatch must fail"); + assert!(matches!( + error, + RemoteEnvironmentError::IdentityMismatch { .. } + )); + assert!(f.repo.list().await.expect("list").is_empty()); +} + +#[tokio::test] +async fn pair_rejects_non_http_urls_before_any_network_call() { + let f = fixture(); + + let error = f + .service + .pair("file:///etc/passwd", "rxp_code", "Mac Studio") + .await + .expect_err("non-http scheme must be rejected"); + assert!(matches!(error, RemoteEnvironmentError::InvalidUrl(_))); + assert!(f.host.recorded_calls().is_empty()); +} + +#[tokio::test] +async fn pairing_the_same_host_via_a_second_url_merges_into_one_environment() { + let f = fixture(); + + let first = f + .service + .pair(HOST_URL, "rxp_code", "Mac Studio") + .await + .expect("first pairing should succeed"); + let second = f + .service + .pair(HOST_URL_DIRECT, "rxp_code2", "Mac Studio") + .await + .expect("second pairing should merge"); + + let all = f.repo.list().await.expect("list"); + assert_eq!(all.len(), 1, "one host identity → one environment"); + assert_eq!(second.id, first.id); + assert_eq!(second.base_url, HOST_URL); + assert_eq!(second.candidate_urls, vec![HOST_URL_DIRECT.to_string()]); + // The refreshed token overwrote the SAME Keychain entry. + assert_eq!(second.token_secret_ref, first.token_secret_ref); + assert_eq!( + f.secrets + .get_secret(&second.token_secret_ref) + .await + .expect("secret read") + .as_deref(), + Some(TOKEN) + ); +} + +// ============================================================================ +// P-18 — the token never reaches JS-serializable surfaces +// ============================================================================ + +#[tokio::test] +async fn no_pairing_surface_serializes_the_raw_token() { + let f = fixture(); + + let env = f + .service + .pair(HOST_URL, "rxp_code", "Mac Studio") + .await + .expect("pairing should succeed"); + let listed = f.service.list().await.expect("list should succeed"); + + let env_json = serde_json::to_string(&env).expect("environment should serialize"); + let list_json = serde_json::to_string(&listed).expect("list should serialize"); + assert!( + !env_json.contains("rxd_live_"), + "pair result must never carry the device token: {env_json}" + ); + assert!( + !list_json.contains("rxd_live_"), + "list result must never carry the device token: {list_json}" + ); +} + +// ============================================================================ +// P-26 — active-environment binding +// ============================================================================ + +/// Pairs and activates two environments (env-1 and env-2) and returns their row ids. +async fn two_paired_environments(f: &Fixture) -> (String, String) { + let env_a = f + .service + .pair(HOST_URL, "rxp_code", "Mac Studio") + .await + .expect("pair A"); + *f.host.descriptor.lock().expect("mock") = Ok(descriptor("env-2")); + *f.host.pair_response.lock().expect("mock") = Ok(pair_response("env-2")); + let env_b = f + .service + .pair("https://mini.tailnet.ts.net", "rxp_code2", "Mac mini") + .await + .expect("pair B"); + ( + env_a.id.as_str().to_string(), + env_b.id.as_str().to_string(), + ) +} + +#[tokio::test] +async fn invoke_for_a_non_active_environment_is_rejected() { + let f = fixture(); + let (a, b) = two_paired_environments(&f).await; + f.service + .set_active_environment(&a) + .await + .expect("activating A should succeed"); + + let error = f + .service + .invoke(&b, "req-1", "health_check", serde_json::json!({})) + .await + .expect_err("invoke for background env must be rejected"); + assert!(matches!( + &error, + RemoteEnvironmentError::NotActiveEnvironment { requested, active } + if requested == &b && active == &a + )); + assert_eq!(error.code(), "REMOTE_FORBIDDEN"); +} + +#[tokio::test] +async fn invoke_for_the_active_environment_passes_binding_and_hits_the_stub() { + let f = fixture(); + let (a, _b) = two_paired_environments(&f).await; + f.service + .set_active_environment(&a) + .await + .expect("activating A should succeed"); + + let error = f + .service + .invoke(&a, "req-1", "health_check", serde_json::json!({})) + .await + .expect_err("transport is a PR 2.2 stub"); + assert!(matches!(error, RemoteEnvironmentError::NotConnected)); + assert_eq!(error.code(), "NOT_CONNECTED"); +} + +#[tokio::test] +async fn non_health_fetch_for_a_background_environment_is_rejected() { + let f = fixture(); + let (a, b) = two_paired_environments(&f).await; + f.service + .set_active_environment(&a) + .await + .expect("activating A should succeed"); + + let error = f + .service + .fetch(&b, "/api/tasks") + .await + .expect_err("background env must be health-only"); + assert!(matches!( + error, + RemoteEnvironmentError::NotActiveEnvironment { .. } + )); +} + +#[tokio::test] +async fn descriptor_probe_for_a_background_environment_succeeds() { + let f = fixture(); + let (a, b) = two_paired_environments(&f).await; + f.service + .set_active_environment(&a) + .await + .expect("activating A should succeed"); + + let value = f + .service + .fetch( + &b, + crate::infrastructure::remote_host_client::REMOTE_DESCRIPTOR_PATH, + ) + .await + .expect("health probe for a background env must be allowed"); + assert_eq!(value["environmentId"], "env-2"); +} + +#[tokio::test] +async fn proxy_calls_for_the_local_environment_are_refused() { + let f = fixture(); + + let error = f + .service + .invoke( + LOCAL_ENVIRONMENT_ID, + "req-1", + "health_check", + serde_json::json!({}), + ) + .await + .expect_err("local never routes through the remote proxy"); + assert!(matches!(error, RemoteEnvironmentError::LocalEnvironment)); +} + +#[tokio::test] +async fn set_active_environment_rejects_unknown_and_non_active_rows() { + let f = fixture(); + let env = f + .service + .pair(HOST_URL, "rxp_code", "Mac Studio") + .await + .expect("pairing should succeed"); + + assert!(matches!( + f.service.set_active_environment("nope").await, + Err(RemoteEnvironmentError::UnknownEnvironment(_)) + )); + + f.repo + .set_status(&env.id, RemoteEnvironmentStatus::PendingDelete) + .await + .expect("status write"); + assert!(matches!( + f.service.set_active_environment(env.id.as_str()).await, + Err(RemoteEnvironmentError::EnvironmentNotUsable(..)) + )); + assert_eq!(f.service.active_environment_id().await, "local"); +} + +#[tokio::test] +async fn removing_the_active_environment_resets_the_mirror_to_local() { + let f = fixture(); + let env = f + .service + .pair(HOST_URL, "rxp_code", "Mac Studio") + .await + .expect("pairing should succeed"); + f.service + .set_active_environment(env.id.as_str()) + .await + .expect("activation should succeed"); + + f.service + .remove(env.id.as_str()) + .await + .expect("removal should succeed"); + assert_eq!(f.service.active_environment_id().await, "local"); +} + +// ============================================================================ +// Staged remove ordering (P-27: revoke → Keychain → row) +// ============================================================================ + +#[tokio::test] +async fn remove_revokes_on_the_host_then_deletes_secret_then_row() { + let f = fixture(); + let env = f + .service + .pair(HOST_URL, "rxp_code", "Mac Studio") + .await + .expect("pairing should succeed"); + + f.service + .remove(env.id.as_str()) + .await + .expect("removal should succeed"); + + // Revoke went out with the real bearer before local state was destroyed. + assert!(f.host.recorded_calls().iter().any(|call| matches!( + call, + RecordedHostCall::Revoke { token, .. } if token == TOKEN + ))); + assert!(f + .secrets + .get_secret(&env.token_secret_ref) + .await + .expect("secret read") + .is_none()); + assert!(f.repo.get(&env.id).await.expect("get").is_none()); +} + +#[tokio::test] +async fn remove_survives_an_unreachable_host_because_revoke_is_best_effort() { + let f = fixture(); + let env = f + .service + .pair(HOST_URL, "rxp_code", "Mac Studio") + .await + .expect("pairing should succeed"); + *f.host.revoke_response.lock().expect("mock") = Err(RemoteHostClientError::Unreachable( + "host offline".to_string(), + )); + + f.service + .remove(env.id.as_str()) + .await + .expect("removal must not require the host"); + assert!(f + .secrets + .get_secret(&env.token_secret_ref) + .await + .expect("secret read") + .is_none()); + assert!(f.repo.get(&env.id).await.expect("get").is_none()); +} + +/// SecretStore decorator that fails deletes, for proving the Keychain→row ordering. +struct FailingDeleteSecretStore { + inner: Arc, + fail_delete: StdMutex, +} + +#[async_trait] +impl crate::domain::services::SecretStore for FailingDeleteSecretStore { + async fn put_secret(&self, key: &str, value: &str) -> Result<(), SecretStoreError> { + self.inner.put_secret(key, value).await + } + + async fn get_secret(&self, key: &str) -> Result, SecretStoreError> { + self.inner.get_secret(key).await + } + + async fn delete_secret(&self, key: &str) -> Result<(), SecretStoreError> { + if *self.fail_delete.lock().expect("flag") { + return Err(SecretStoreError::Unavailable("keychain locked".to_string())); + } + self.inner.delete_secret(key).await + } +} + +#[tokio::test] +async fn remove_keeps_the_pending_delete_row_when_the_keychain_delete_fails() { + let repo = Arc::new(MemoryRemoteEnvironmentRepository::new()); + let inner_secrets = Arc::new(MemorySecretStore::new()); + let secrets = Arc::new(FailingDeleteSecretStore { + inner: Arc::clone(&inner_secrets), + fail_delete: StdMutex::new(true), + }); + let host = Arc::new(MockRemoteHostClient::new( + descriptor("env-1"), + pair_response("env-1"), + )); + let service = RemoteEnvironmentService::new( + Arc::clone(&repo) as _, + Arc::clone(&secrets) as _, + Arc::clone(&host) as _, + ); + + let env = service + .pair(HOST_URL, "rxp_code", "Mac Studio") + .await + .expect("pairing should succeed"); + let error = service + .remove(env.id.as_str()) + .await + .expect_err("a failed Keychain delete must not report success"); + assert!(matches!(error, RemoteEnvironmentError::Secret(_))); + + // The row survives, still referencing the secret, so the reconciler can + // finish the removal — deleting it would orphan a valid bearer. + let row = repo + .get(&env.id) + .await + .expect("get") + .expect("row must survive"); + assert_eq!(row.status, RemoteEnvironmentStatus::PendingDelete); + assert!(inner_secrets + .get_secret(&env.token_secret_ref) + .await + .expect("secret read") + .is_some()); +} + +// ============================================================================ +// P-27 — startup reconciler partial-failure matrix +// ============================================================================ + +/// Seeds a pending_add row WITHOUT a secret: the crash point between the row +/// write and the Keychain write. +async fn seed_husk(f: &Fixture) -> RemoteEnvironment { + f.repo + .upsert_paired(crate::domain::repositories::UpsertPairedEnvironment { + environment_id: "env-husk".to_string(), + name: "Mac Studio".to_string(), + url: HOST_URL.to_string(), + scopes: vec![Scope::UiRead], + protocol_version: PROTOCOL_VERSION, + }) + .await + .expect("seed should succeed") +} + +#[tokio::test] +async fn reconciler_deletes_a_pending_add_husk_with_no_secret() { + let f = fixture(); + let husk = seed_husk(&f).await; + + let report = f.service.reconcile_on_startup().await; + + assert_eq!(report.deleted_husks, vec![husk.id.as_str().to_string()]); + assert!(f.repo.get(&husk.id).await.expect("get").is_none()); +} + +#[tokio::test] +async fn reconciler_activates_a_pending_add_row_whose_secret_validates() { + // Crash point: after the Keychain write, before the flip to active. + let f = fixture(); + let env = seed_husk(&f).await; + f.secrets + .put_secret(&env.token_secret_ref, TOKEN) + .await + .expect("seed secret"); + + let report = f.service.reconcile_on_startup().await; + + assert_eq!(report.activated, vec![env.id.as_str().to_string()]); + let row = f + .repo + .get(&env.id) + .await + .expect("get") + .expect("row should exist"); + assert_eq!(row.status, RemoteEnvironmentStatus::Active); + // The validation went to the host with the stored bearer. + assert!(f.host.recorded_calls().iter().any(|call| matches!( + call, + RecordedHostCall::Validate { token, .. } if token == TOKEN + ))); +} + +#[tokio::test] +async fn reconciler_surfaces_a_refused_token_for_repair_instead_of_activating() { + let f = fixture(); + let env = seed_husk(&f).await; + f.secrets + .put_secret(&env.token_secret_ref, TOKEN) + .await + .expect("seed secret"); + *f.host.validate_response.lock().expect("mock") = Ok(false); + + let report = f.service.reconcile_on_startup().await; + + assert_eq!(report.needs_repair, vec![env.id.as_str().to_string()]); + let row = f + .repo + .get(&env.id) + .await + .expect("get") + .expect("row must survive for re-pair"); + assert_eq!(row.status, RemoteEnvironmentStatus::PendingAdd); +} + +#[tokio::test] +async fn reconciler_defers_when_the_host_is_unreachable_instead_of_guessing() { + let f = fixture(); + let env = seed_husk(&f).await; + f.secrets + .put_secret(&env.token_secret_ref, TOKEN) + .await + .expect("seed secret"); + *f.host.validate_response.lock().expect("mock") = Err(RemoteHostClientError::Unreachable( + "host offline".to_string(), + )); + + let report = f.service.reconcile_on_startup().await; + + // Fail closed: neither activated nor deleted — the bearer may be live. + assert_eq!(report.deferred, vec![env.id.as_str().to_string()]); + let row = f + .repo + .get(&env.id) + .await + .expect("get") + .expect("row must survive"); + assert_eq!(row.status, RemoteEnvironmentStatus::PendingAdd); + assert!(f + .secrets + .get_secret(&env.token_secret_ref) + .await + .expect("secret read") + .is_some()); +} + +/// SecretStore decorator whose reads fail: an unreadable Keychain must defer, +/// never delete (deleting the row would orphan a possibly-live bearer). +struct UnreadableSecretStore; + +#[async_trait] +impl crate::domain::services::SecretStore for UnreadableSecretStore { + async fn put_secret(&self, _key: &str, _value: &str) -> Result<(), SecretStoreError> { + Err(SecretStoreError::Unavailable("keychain locked".to_string())) + } + + async fn get_secret(&self, _key: &str) -> Result, SecretStoreError> { + Err(SecretStoreError::Unavailable("keychain locked".to_string())) + } + + async fn delete_secret(&self, _key: &str) -> Result<(), SecretStoreError> { + Err(SecretStoreError::Unavailable("keychain locked".to_string())) + } +} + +#[tokio::test] +async fn reconciler_defers_pending_add_when_the_keychain_read_errors() { + let repo = Arc::new(MemoryRemoteEnvironmentRepository::new()); + let host = Arc::new(MockRemoteHostClient::new( + descriptor("env-1"), + pair_response("env-1"), + )); + let service = RemoteEnvironmentService::new( + Arc::clone(&repo) as _, + Arc::new(UnreadableSecretStore) as _, + Arc::clone(&host) as _, + ); + let env = repo + .upsert_paired(crate::domain::repositories::UpsertPairedEnvironment { + environment_id: "env-1".to_string(), + name: "Mac Studio".to_string(), + url: HOST_URL.to_string(), + scopes: vec![Scope::UiRead], + protocol_version: PROTOCOL_VERSION, + }) + .await + .expect("seed"); + + let report = service.reconcile_on_startup().await; + + // A read ERROR is not "no data" (fail-closed read): the row survives. + assert_eq!(report.deferred, vec![env.id.as_str().to_string()]); + assert!(report.deleted_husks.is_empty()); + assert!(repo.get(&env.id).await.expect("get").is_some()); +} + +#[tokio::test] +async fn reconciler_completes_a_pending_delete_with_revoke_then_secret_then_row() { + // Crash point: after the pending_delete mark, before any deletion. + let f = fixture(); + let env = seed_husk(&f).await; + f.secrets + .put_secret(&env.token_secret_ref, TOKEN) + .await + .expect("seed secret"); + f.repo + .set_status(&env.id, RemoteEnvironmentStatus::PendingDelete) + .await + .expect("mark pending_delete"); + + let report = f.service.reconcile_on_startup().await; + + assert_eq!( + report.completed_removals, + vec![env.id.as_str().to_string()] + ); + assert!(f.host.recorded_calls().iter().any(|call| matches!( + call, + RecordedHostCall::Revoke { token, .. } if token == TOKEN + ))); + assert!(f + .secrets + .get_secret(&env.token_secret_ref) + .await + .expect("secret read") + .is_none()); + assert!(f.repo.get(&env.id).await.expect("get").is_none()); +} + +#[tokio::test] +async fn reconciler_deletes_a_pending_delete_row_whose_secret_is_already_gone() { + // Crash point: after the Keychain delete, before the row delete. + let f = fixture(); + let env = seed_husk(&f).await; + f.repo + .set_status(&env.id, RemoteEnvironmentStatus::PendingDelete) + .await + .expect("mark pending_delete"); + + let report = f.service.reconcile_on_startup().await; + + assert_eq!( + report.completed_removals, + vec![env.id.as_str().to_string()] + ); + assert!(f.repo.get(&env.id).await.expect("get").is_none()); + // No orphaned valid bearer anywhere: nothing was in the secret store. +} + +#[tokio::test] +async fn reconciler_leaves_active_rows_untouched() { + let f = fixture(); + let env = f + .service + .pair(HOST_URL, "rxp_code", "Mac Studio") + .await + .expect("pairing should succeed"); + + let report = f.service.reconcile_on_startup().await; + + assert_eq!(report, RemoteEnvironmentReconcileReport::default()); + let row = f + .repo + .get(&env.id) + .await + .expect("get") + .expect("row should exist"); + assert_eq!(row.status, RemoteEnvironmentStatus::Active); + assert!(f + .secrets + .get_secret(&env.token_secret_ref) + .await + .expect("secret read") + .is_some()); +} + +// ============================================================================ +// Proxy stubs: connect/disconnect +// ============================================================================ + +#[tokio::test] +async fn connect_requires_a_registered_active_environment() { + let f = fixture(); + + assert!(matches!( + f.service.connect("nope").await, + Err(RemoteEnvironmentError::UnknownEnvironment(_)) + )); + + let env = f + .service + .pair(HOST_URL, "rxp_code", "Mac Studio") + .await + .expect("pairing should succeed"); + // Transport is a stub until PR 2.3; the typed error proves authorization ran. + assert!(matches!( + f.service.connect(env.id.as_str()).await, + Err(RemoteEnvironmentError::NotConnected) + )); + assert!(f.service.disconnect(env.id.as_str()).await.is_ok()); +} From 91371c9c6dbfe29cb9bfd2a4704a8970f68347ff Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:06:22 +0300 Subject: [PATCH 045/416] feat: wire the remote environment service and startup reconciler --- src-tauri/src/application/app_state.rs | 46 ++++++++++++++++++++ src-tauri/src/application/startup_cleanup.rs | 20 +++++++++ 2 files changed, 66 insertions(+) diff --git a/src-tauri/src/application/app_state.rs b/src-tauri/src/application/app_state.rs index ec6eb5242e..916ce1f63f 100644 --- a/src-tauri/src/application/app_state.rs +++ b/src-tauri/src/application/app_state.rs @@ -37,6 +37,7 @@ use crate::application::GranolaIntegrationService; use crate::application::LinearIntegrationService; use crate::application::PermissionState; use crate::application::QuestionState; +use crate::application::RemoteEnvironmentService; use crate::application::ResumeValidator; use crate::application::TaskSchedulerService; use crate::application::TaskTransitionService; @@ -196,6 +197,9 @@ pub struct AppState { pub project_repo: Arc, /// API key repository for external API authentication pub api_key_repo: Arc, + /// Client-side remote environment registry, pairing, reconciler, and the + /// active-environment authority for the Rust proxy surface (PR 2.1). + pub remote_environment_service: Arc, /// Native Atlassian/Jira/Confluence integration service. pub atlassian_integration_service: Arc, /// Native Linear integration service. @@ -573,6 +577,43 @@ impl AppState { AgentClientBundle::standard_mock_runtime_clients() } + fn production_remote_environment_service( + shared_conn: &Arc>, + ) -> Arc { + let host_client: Arc = + match crate::infrastructure::HyperRemoteHostClient::new() { + Ok(client) => Arc::new(client), + Err(error) => { + tracing::warn!( + error = %error, + "Remote host HTTP client unavailable; pairing will fail until TLS roots are available" + ); + Arc::new(crate::infrastructure::UnavailableRemoteHostClient::new( + error, + )) + } + }; + Arc::new(RemoteEnvironmentService::new( + Arc::new( + crate::infrastructure::sqlite::SqliteRemoteEnvironmentRepository::from_shared( + Arc::clone(shared_conn), + ), + ), + Arc::new(MacosKeychainSecretStore::new()), + host_client, + )) + } + + fn memory_remote_environment_service() -> Arc { + Arc::new(RemoteEnvironmentService::new( + Arc::new(crate::infrastructure::memory::MemoryRemoteEnvironmentRepository::new()), + Arc::new(MemorySecretStore::new()), + Arc::new(crate::infrastructure::UnavailableRemoteHostClient::new( + "remote host client is not wired in tests", + )), + )) + } + fn production_atlassian_integration_service( shared_conn: &Arc>, ) -> Arc { @@ -1307,6 +1348,7 @@ impl AppState { api_key_repo: Arc::new(SqliteApiKeyRepository::from_shared(Arc::clone( &shared_conn, ))), + remote_environment_service: Self::production_remote_environment_service(&shared_conn), atlassian_integration_service: Self::production_atlassian_integration_service( &shared_conn, ), @@ -1619,6 +1661,7 @@ impl AppState { task_step_repo: Arc::new(MemoryTaskStepRepository::new()), project_repo: Arc::new(MemoryProjectRepository::new()), api_key_repo: Arc::new(MemoryApiKeyRepository::new()), + remote_environment_service: Self::memory_remote_environment_service(), atlassian_integration_service: Self::memory_atlassian_integration_service(), linear_integration_service: Self::memory_linear_integration_service(), clickup_integration_service: Self::memory_clickup_integration_service(), @@ -1812,6 +1855,7 @@ impl AppState { task_step_repo: Arc::new(MemoryTaskStepRepository::new()), project_repo: Arc::new(MemoryProjectRepository::new()), api_key_repo: Arc::new(MemoryApiKeyRepository::new()), + remote_environment_service: Self::memory_remote_environment_service(), atlassian_integration_service: Self::memory_atlassian_integration_service(), linear_integration_service: Self::memory_linear_integration_service(), clickup_integration_service: Self::memory_clickup_integration_service(), @@ -2015,6 +2059,7 @@ impl AppState { &shared_conn, ))), api_key_repo: Arc::new(MemoryApiKeyRepository::new()), + remote_environment_service: Self::memory_remote_environment_service(), atlassian_integration_service: Self::memory_atlassian_integration_service(), linear_integration_service: Self::memory_linear_integration_service(), clickup_integration_service: Self::memory_clickup_integration_service(), @@ -2211,6 +2256,7 @@ impl AppState { task_step_repo: Arc::new(MemoryTaskStepRepository::new()), project_repo, api_key_repo: Arc::new(MemoryApiKeyRepository::new()), + remote_environment_service: Self::memory_remote_environment_service(), atlassian_integration_service: Self::memory_atlassian_integration_service(), linear_integration_service: Self::memory_linear_integration_service(), clickup_integration_service: Self::memory_clickup_integration_service(), diff --git a/src-tauri/src/application/startup_cleanup.rs b/src-tauri/src/application/startup_cleanup.rs index 1018cf0347..bc0de58f2a 100644 --- a/src-tauri/src/application/startup_cleanup.rs +++ b/src-tauri/src/application/startup_cleanup.rs @@ -31,6 +31,26 @@ pub(crate) async fn run_startup_cleanup(app_state: &AppState) { let validation_run_repo = Arc::clone(&app_state.validation_run_repo); mark_orphaned_validation_runs_on_startup(validation_run_repo).await; + // Remote environment registry: resolve staged pending_add/pending_delete rows + // left behind by crashes (P-27). Spawned because reconciliation may need the + // network (token re-validation, revoke retries) and must not block startup; + // the service itself fails closed on unreachable hosts or an unreadable + // Keychain, so a partial run only defers work to the next boot. + { + let remote_environment_service = Arc::clone(&app_state.remote_environment_service); + tauri::async_runtime::spawn(async move { + let report = remote_environment_service.reconcile_on_startup().await; + info!( + activated = report.activated.len(), + deleted_husks = report.deleted_husks.len(), + completed_removals = report.completed_removals.len(), + needs_repair = report.needs_repair.len(), + deferred = report.deferred.len(), + "Remote environment startup reconciliation finished" + ); + }); + } + // All spawned processes are Tauri children — app restart means they are dead. let process_repo = Arc::clone(&app_state.process_repo); match process_repo.fail_all_active("app_restart").await { From 4622322f43ea1598ace046a3dad5f63954d71ae0 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:18:40 +0300 Subject: [PATCH 046/416] feat: expose the remote environment and proxy command surface --- .../remote_environment_service_tests.rs | 5 +- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/registry.rs | 10 + .../commands/remote_environment_commands.rs | 212 ++++++++++++++++++ .../remote_environment_commands_tests.rs | 147 ++++++++++++ 5 files changed, 373 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/commands/remote_environment_commands.rs create mode 100644 src-tauri/src/commands/remote_environment_commands_tests.rs diff --git a/src-tauri/src/application/remote_environment_service_tests.rs b/src-tauri/src/application/remote_environment_service_tests.rs index a54796d4ba..1723743aff 100644 --- a/src-tauri/src/application/remote_environment_service_tests.rs +++ b/src-tauri/src/application/remote_environment_service_tests.rs @@ -68,8 +68,9 @@ fn fixture_with_host(host: MockRemoteHostClient) -> Fixture { } } -use crate::domain::repositories::RemoteEnvironmentRepository as _; -use crate::domain::services::SecretStore as _; +// Trait methods on the concrete test doubles resolve through the imports the +// service module already provides via `use super::*` (RemoteEnvironmentRepository, +// SecretStore) — no extra trait imports needed here. // ============================================================================ // Pairing against the mock host diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 66614a775d..dabc252184 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -81,6 +81,7 @@ pub mod release_notes_commands; pub mod repository_settings_commands; #[cfg(test)] mod repository_settings_commands_tests; +pub mod remote_environment_commands; pub mod remote_host_commands; #[cfg(debug_assertions)] pub mod remote_transport_spike_commands; diff --git a/src-tauri/src/commands/registry.rs b/src-tauri/src/commands/registry.rs index 6d3b1aac9c..db37cea38e 100644 --- a/src-tauri/src/commands/registry.rs +++ b/src-tauri/src/commands/registry.rs @@ -21,6 +21,16 @@ macro_rules! register_tauri_commands { commands::notification_commands::get_unread_notification_count, #[cfg(debug_assertions)] commands::notification_commands::debug_send_test_notification, + // remote environment registry (PR 2.1) + commands::remote_environment_commands::pair_remote_environment, + commands::remote_environment_commands::list_remote_environments, + commands::remote_environment_commands::remove_remote_environment, + commands::remote_environment_commands::get_active_environment, + commands::remote_environment_commands::set_active_environment, + commands::remote_environment_commands::remote_connect, + commands::remote_environment_commands::remote_disconnect, + commands::remote_environment_commands::remote_invoke, + commands::remote_environment_commands::remote_fetch, commands::remote_host_commands::start_remote_listener, commands::remote_host_commands::stop_remote_listener, commands::remote_host_commands::set_remote_exposure_mode, diff --git a/src-tauri/src/commands/remote_environment_commands.rs b/src-tauri/src/commands/remote_environment_commands.rs new file mode 100644 index 0000000000..757e443711 --- /dev/null +++ b/src-tauri/src/commands/remote_environment_commands.rs @@ -0,0 +1,212 @@ +//! Client-side Tauri commands for the remote environment registry and the +//! Rust-proxy surface (§6.1, §6.4). +//! +//! P-18 by construction: no command in this module returns a device token or any +//! secret material, and there is no credential-fetch command. Responses go through +//! `RemoteEnvironmentSummary`, which never carries the token. The active-environment +//! id used by proxy authorization comes from the Rust-side mirror, never from a +//! trusted JS argument (P-26) — the `id` args below only SELECT a target, the +//! service decides whether that target is authorized. + +use serde::{Deserialize, Serialize}; +use tauri::State; + +use crate::application::remote_environment_service::{ + RemoteEnvironmentError, RemoteEnvironmentService, +}; +use crate::domain::entities::remote_environment::{RemoteEnvironment, RemoteEnvironmentStatus}; +use crate::AppState; + +/// JS-facing projection of a paired remote environment. +/// +/// Explicit field allowlist: the device token and the Keychain reference are +/// deliberately NOT part of this struct (P-18). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteEnvironmentSummary { + pub id: String, + pub environment_id: String, + pub name: String, + pub base_url: String, + pub candidate_urls: Vec, + pub scopes: Vec, + pub protocol_version: u32, + pub status: RemoteEnvironmentStatus, + pub created_at: String, + pub last_connected_at: Option, +} + +impl From for RemoteEnvironmentSummary { + fn from(env: RemoteEnvironment) -> Self { + Self { + id: env.id.as_str().to_string(), + environment_id: env.environment_id, + name: env.name, + base_url: env.base_url, + candidate_urls: env.candidate_urls, + scopes: env.scopes, + protocol_version: env.protocol_version, + status: env.status, + created_at: env.created_at, + last_connected_at: env.last_connected_at, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PairRemoteEnvironmentInput { + pub url: String, + pub code: String, + pub name: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteEnvironmentIdInput { + pub id: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteInvokeInput { + pub id: String, + pub request_id: String, + pub cmd: String, + #[serde(default)] + pub args: serde_json::Value, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteFetchInput { + pub id: String, + pub path: String, +} + +fn service<'a>(state: &'a State<'_, AppState>) -> &'a RemoteEnvironmentService { + &state.remote_environment_service +} + +fn to_command_error(error: RemoteEnvironmentError) -> String { + error.to_command_error() +} + +/// Performs the pairing exchange in the Rust backend (§4.2): descriptor → +/// pair → row (`pending_add`) → Keychain → `active`. The token goes straight +/// to the Keychain and is absent from the response. +#[tauri::command] +pub async fn pair_remote_environment( + input: PairRemoteEnvironmentInput, + state: State<'_, AppState>, +) -> Result { + service(&state) + .pair(&input.url, &input.code, &input.name) + .await + .map(RemoteEnvironmentSummary::from) + .map_err(to_command_error) +} + +#[tauri::command] +pub async fn list_remote_environments( + state: State<'_, AppState>, +) -> Result, String> { + service(&state) + .list() + .await + .map(|environments| { + environments + .into_iter() + .map(RemoteEnvironmentSummary::from) + .collect() + }) + .map_err(to_command_error) +} + +/// Staged removal: mark `pending_delete` → best-effort host revoke → Keychain +/// delete → row delete (P-27). +#[tauri::command] +pub async fn remove_remote_environment( + input: RemoteEnvironmentIdInput, + state: State<'_, AppState>, +) -> Result<(), String> { + service(&state) + .remove(&input.id) + .await + .map_err(to_command_error) +} + +/// Reads the Rust-side authoritative active environment id. +#[tauri::command] +pub async fn get_active_environment(state: State<'_, AppState>) -> Result { + Ok(service(&state).active_environment_id().await) +} + +/// Switches the Rust-side authoritative active environment (§6.4). The webview's +/// `environmentStore` mirrors this value; proxy authorization reads only the +/// Rust copy. +#[tauri::command] +pub async fn set_active_environment( + input: RemoteEnvironmentIdInput, + state: State<'_, AppState>, +) -> Result<(), String> { + service(&state) + .set_active_environment(&input.id) + .await + .map_err(to_command_error) +} + +/// Opens the Rust-owned outbound connection for an environment (WS body lands in +/// PR 2.3 — until then this returns `NOT_CONNECTED` after authorization). +#[tauri::command] +pub async fn remote_connect( + input: RemoteEnvironmentIdInput, + state: State<'_, AppState>, +) -> Result<(), String> { + service(&state) + .connect(&input.id) + .await + .map_err(to_command_error) +} + +#[tauri::command] +pub async fn remote_disconnect( + input: RemoteEnvironmentIdInput, + state: State<'_, AppState>, +) -> Result<(), String> { + service(&state) + .disconnect(&input.id) + .await + .map_err(to_command_error) +} + +/// Forwards one command invoke through the Rust proxy. Active-env-bound (P-26); +/// the bearer stays in Rust. HTTP transport lands in PR 2.2. +#[tauri::command] +pub async fn remote_invoke( + input: RemoteInvokeInput, + state: State<'_, AppState>, +) -> Result { + service(&state) + .invoke(&input.id, &input.request_id, &input.cmd, input.args) + .await + .map_err(to_command_error) +} + +/// Fetches a host resource through the Rust proxy. Health paths (descriptor, +/// health probe) are allowed for background environments; everything else is +/// active-env-bound (P-26). +#[tauri::command] +pub async fn remote_fetch( + input: RemoteFetchInput, + state: State<'_, AppState>, +) -> Result { + service(&state) + .fetch(&input.id, &input.path) + .await + .map_err(to_command_error) +} + +#[cfg(test)] +#[path = "remote_environment_commands_tests.rs"] +mod tests; diff --git a/src-tauri/src/commands/remote_environment_commands_tests.rs b/src-tauri/src/commands/remote_environment_commands_tests.rs new file mode 100644 index 0000000000..19d6b27914 --- /dev/null +++ b/src-tauri/src/commands/remote_environment_commands_tests.rs @@ -0,0 +1,147 @@ +//! Command-surface tests: P-18 (no token reaches JS), serde casing (rule 14), +//! and registry guards (no credential-fetch command exists). + +use super::*; +use crate::domain::entities::remote_environment::{ + RemoteEnvironment, RemoteEnvironmentId, RemoteEnvironmentStatus, +}; + +fn sample_environment() -> RemoteEnvironment { + RemoteEnvironment { + id: RemoteEnvironmentId::from_string("row-1"), + environment_id: "env-1".to_string(), + name: "Mac Studio".to_string(), + base_url: "https://mac-studio.tailnet.ts.net".to_string(), + candidate_urls: vec!["http://100.101.102.103:3849".to_string()], + token_secret_ref: "remote-env:row-1:token".to_string(), + scopes: vec![ + ralphx_remote_protocol::Scope::UiRead, + ralphx_remote_protocol::Scope::UiOperate, + ], + protocol_version: 1, + status: RemoteEnvironmentStatus::Active, + created_at: "2026-07-27T19:15:00+00:00".to_string(), + last_connected_at: None, + } +} + +// ============================================================================ +// P-18 — the JS-facing projection never carries secret material +// ============================================================================ + +#[test] +fn summary_is_an_explicit_allowlist_without_token_material() { + let summary = RemoteEnvironmentSummary::from(sample_environment()); + let json = serde_json::to_value(&summary).expect("summary should serialize"); + + let object = json.as_object().expect("summary should be an object"); + let keys: Vec<&str> = object.keys().map(String::as_str).collect(); + assert_eq!( + keys, + vec![ + "id", + "environmentId", + "name", + "baseUrl", + "candidateUrls", + "scopes", + "protocolVersion", + "status", + "createdAt", + "lastConnectedAt", + ], + "summary field set is an explicit allowlist — extending it requires a P-18 review" + ); + // Neither the token nor the Keychain reference crosses the IPC boundary. + let serialized = json.to_string(); + assert!(!serialized.contains("rxd_live_")); + assert!(!serialized.contains("token")); +} + +#[test] +fn summary_serializes_camel_case_with_snake_case_status_values() { + let summary = RemoteEnvironmentSummary::from(sample_environment()); + let json = serde_json::to_value(&summary).expect("summary should serialize"); + + assert_eq!(json["environmentId"], "env-1"); + assert_eq!(json["baseUrl"], "https://mac-studio.tailnet.ts.net"); + assert_eq!(json["status"], "active"); + assert_eq!(json["scopes"], serde_json::json!(["ui:read", "ui:operate"])); +} + +// ============================================================================ +// Rule 14 — invoke inputs deserialize from camelCase +// ============================================================================ + +#[test] +fn invoke_input_accepts_camel_case_fields() { + let input: RemoteInvokeInput = serde_json::from_value(serde_json::json!({ + "id": "row-1", + "requestId": "req-1", + "cmd": "health_check", + "args": {"limit": 1}, + })) + .expect("camelCase input should deserialize"); + assert_eq!(input.request_id, "req-1"); + assert_eq!(input.args["limit"], 1); +} + +#[test] +fn invoke_input_defaults_missing_args_to_null() { + let input: RemoteInvokeInput = serde_json::from_value(serde_json::json!({ + "id": "row-1", + "requestId": "req-1", + "cmd": "health_check", + })) + .expect("args should be optional"); + assert!(input.args.is_null()); +} + +#[test] +fn pair_input_accepts_camel_case_fields() { + let input: PairRemoteEnvironmentInput = serde_json::from_value(serde_json::json!({ + "url": "https://mac-studio.tailnet.ts.net", + "code": "rxp_code", + "name": "Mac Studio", + })) + .expect("pair input should deserialize"); + assert_eq!(input.name, "Mac Studio"); +} + +// ============================================================================ +// P-18 — registry guards over the command surface +// ============================================================================ + +const REGISTRY_SOURCE: &str = include_str!("registry.rs"); + +#[test] +fn there_is_no_credential_fetch_command() { + assert!( + !REGISTRY_SOURCE.contains("get_credential"), + "a get_credential-to-JS command must never exist (P-18)" + ); + assert!( + !REGISTRY_SOURCE.contains("get_remote_token"), + "no command may hand the device token to JS (P-18)" + ); +} + +#[test] +fn the_remote_environment_command_surface_is_registered() { + for command in [ + "remote_environment_commands::pair_remote_environment", + "remote_environment_commands::list_remote_environments", + "remote_environment_commands::remove_remote_environment", + "remote_environment_commands::get_active_environment", + "remote_environment_commands::set_active_environment", + "remote_environment_commands::remote_connect", + "remote_environment_commands::remote_disconnect", + "remote_environment_commands::remote_invoke", + "remote_environment_commands::remote_fetch", + ] { + assert!( + REGISTRY_SOURCE.contains(command), + "{command} must be registered in the Tauri invoke handler" + ); + } +} From fe7cff8d36c0e67b5fa570dfa3a2ea7bd7fb19fc Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:23:47 +0300 Subject: [PATCH 047/416] feat: introduce the remoteEnvironments feature flag, default off --- config/ralphx.yaml | 3 +++ frontend/src/types/feature-flags.ts | 3 +++ src-tauri/src/commands/ui_commands.rs | 3 +++ src-tauri/src/commands/ui_commands_tests.rs | 14 ++++++++++++++ .../agents/claude/agent_config/runtime_config.rs | 3 +++ .../agents/claude/agent_config/tests.rs | 13 +++++++++++++ .../agents/claude/agent_config/ui_config.rs | 4 ++++ 7 files changed, 43 insertions(+) diff --git a/config/ralphx.yaml b/config/ralphx.yaml index cf71c8a1b9..9c650c30d9 100644 --- a/config/ralphx.yaml +++ b/config/ralphx.yaml @@ -47,6 +47,9 @@ ui: standalone_conversations: false # A20 escape hatch: skip --resume on the send after a persona switch. persona_switch_forces_fresh_provider_session: false + # Remote multi-environment client UI (Phase 2 ships dark behind this). + # Env override: RALPHX_UI_REMOTE_ENVIRONMENTS=true|false + remote_environments: false # Compatibility runtime row for the PersonaBuilder-only extractor. Canonical # `agents/ralphx-persona-extractor/agent.yaml` remains authoritative. diff --git a/frontend/src/types/feature-flags.ts b/frontend/src/types/feature-flags.ts index 0195fde29b..882ac5e0a1 100644 --- a/frontend/src/types/feature-flags.ts +++ b/frontend/src/types/feature-flags.ts @@ -11,6 +11,7 @@ export const featureFlagsSchema = z.object({ agentConversationWorkflows: z.boolean().default(false), standaloneConversations: z.boolean().default(false), agentConversationAutopilot: z.boolean().default(false), + remoteEnvironments: z.boolean().default(false), }); /** @@ -24,10 +25,12 @@ export type FeatureFlags = Omit< | "agentConversationWorkflows" | "standaloneConversations" | "agentConversationAutopilot" + | "remoteEnvironments" > & { agentPersonas?: boolean; agentConversationTeam?: boolean; agentConversationWorkflows?: boolean; standaloneConversations?: boolean; agentConversationAutopilot?: boolean; + remoteEnvironments?: boolean; }; diff --git a/src-tauri/src/commands/ui_commands.rs b/src-tauri/src/commands/ui_commands.rs index d661835512..6f8ac8675d 100644 --- a/src-tauri/src/commands/ui_commands.rs +++ b/src-tauri/src/commands/ui_commands.rs @@ -26,6 +26,8 @@ pub struct UiFeatureFlagsResponse { pub agent_conversation_team: bool, pub agent_conversation_workflows: bool, pub agent_conversation_autopilot: bool, + /// Remote multi-environment client UI (Phase 2 ships dark behind this). + pub remote_environments: bool, } #[derive(Debug, Clone, Deserialize)] @@ -58,6 +60,7 @@ fn ui_feature_flags_response_with_standalone( agent_conversation_team: agent_capabilities.team, agent_conversation_workflows: agent_capabilities.workflows, agent_conversation_autopilot: agent_capabilities.autopilot, + remote_environments: flags.remote_environments, } } diff --git a/src-tauri/src/commands/ui_commands_tests.rs b/src-tauri/src/commands/ui_commands_tests.rs index 624b8af81a..2f05eb207a 100644 --- a/src-tauri/src/commands/ui_commands_tests.rs +++ b/src-tauri/src/commands/ui_commands_tests.rs @@ -56,6 +56,20 @@ fn get_ui_feature_flags_includes_agent_personas() { assert!(json.get("agent_personas").is_none()); } +#[test] +fn get_ui_feature_flags_ships_remote_environments_dark_by_default() { + let state = AppState::new_test(); + let response = ui_feature_flags_response(&state); + let json = serde_json::to_value(response).expect("feature flags response should serialize"); + + assert_eq!( + json.get("remoteEnvironments"), + Some(&serde_json::json!(false)), + "remoteEnvironments must default OFF — every Phase-2 PR ships dark behind it" + ); + assert!(json.get("remote_environments").is_none()); +} + #[test] fn get_ui_feature_flags_reports_the_effective_standalone_value() { let state = AppState::new_test(); diff --git a/src-tauri/src/infrastructure/agents/claude/agent_config/runtime_config.rs b/src-tauri/src/infrastructure/agents/claude/agent_config/runtime_config.rs index 824ceab66c..a0e287c5da 100644 --- a/src-tauri/src/infrastructure/agents/claude/agent_config/runtime_config.rs +++ b/src-tauri/src/infrastructure/agents/claude/agent_config/runtime_config.rs @@ -1227,6 +1227,9 @@ fn apply_env_overrides_with(cfg: &mut AllRuntimeConfig, lookup: &dyn Fn(&str) -> cfg.ui_feature_flags.standalone_conversations = matches!(v.to_lowercase().as_str(), "true" | "1"); } + if let Some(v) = lookup("RALPHX_UI_REMOTE_ENVIRONMENTS") { + cfg.ui_feature_flags.remote_environments = matches!(v.to_lowercase().as_str(), "true" | "1"); + } } /// Validate ReconciliationConfig fields and clamp to safe defaults on invalid values (GAP M7). diff --git a/src-tauri/src/infrastructure/agents/claude/agent_config/tests.rs b/src-tauri/src/infrastructure/agents/claude/agent_config/tests.rs index bc8e10604c..4f61720f2d 100644 --- a/src-tauri/src/infrastructure/agents/claude/agent_config/tests.rs +++ b/src-tauri/src/infrastructure/agents/claude/agent_config/tests.rs @@ -3054,6 +3054,7 @@ fn test_env_override_true_value_enables_flag() { agent_personas: false, persona_switch_forces_fresh_provider_session: false, standalone_conversations: false, + remote_environments: false, }, }; runtime_config::apply_env_overrides_with_lookup(&mut cfg, &|name| match name { @@ -3061,6 +3062,18 @@ fn test_env_override_true_value_enables_flag() { "RALPHX_UI_EXTENSIBILITY_PAGE" => Some("1".to_string()), _ => None, }); + assert!( + !cfg.ui_feature_flags.remote_environments, + "remote_environments untouched without its env var" + ); + runtime_config::apply_env_overrides_with_lookup(&mut cfg, &|name| match name { + "RALPHX_UI_REMOTE_ENVIRONMENTS" => Some("true".to_string()), + _ => None, + }); + assert!( + cfg.ui_feature_flags.remote_environments, + "env 'true' should enable remote_environments" + ); assert!( cfg.ui_feature_flags.activity_page, "env 'true' should enable activity_page" diff --git a/src-tauri/src/infrastructure/agents/claude/agent_config/ui_config.rs b/src-tauri/src/infrastructure/agents/claude/agent_config/ui_config.rs index e13eb3cc22..a125a27a39 100644 --- a/src-tauri/src/infrastructure/agents/claude/agent_config/ui_config.rs +++ b/src-tauri/src/infrastructure/agents/claude/agent_config/ui_config.rs @@ -31,6 +31,9 @@ pub struct UiFeatureFlagsConfig { pub persona_switch_forces_fresh_provider_session: bool, /// Enable or disable projectless (standalone) conversations. Default: false. pub standalone_conversations: bool, + /// Enable or disable the remote multi-environment client UI. Default: false. + /// Phase-2 remote work ships dark behind this flag. + pub remote_environments: bool, } impl Default for UiFeatureFlagsConfig { @@ -44,6 +47,7 @@ impl Default for UiFeatureFlagsConfig { agent_personas: false, persona_switch_forces_fresh_provider_session: false, standalone_conversations: false, + remote_environments: false, } } } From 2addc2bf44d892f8d6b4cdf5f39a7dcf16d0bd09 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:25:41 +0300 Subject: [PATCH 048/416] feat: add the frontend environment store identity slice --- frontend/src/api/remote-environments.ts | 63 +++++++ frontend/src/stores/environmentStore.test.ts | 189 +++++++++++++++++++ frontend/src/stores/environmentStore.ts | 147 +++++++++++++++ 3 files changed, 399 insertions(+) create mode 100644 frontend/src/api/remote-environments.ts create mode 100644 frontend/src/stores/environmentStore.test.ts create mode 100644 frontend/src/stores/environmentStore.ts diff --git a/frontend/src/api/remote-environments.ts b/frontend/src/api/remote-environments.ts new file mode 100644 index 0000000000..3968bbf85d --- /dev/null +++ b/frontend/src/api/remote-environments.ts @@ -0,0 +1,63 @@ +// Tauri invoke wrappers for the remote environment registry (PR 2.1, §6.1/§6.4). +// +// P-18: no wrapper here can see a device token — the backend never serializes one. +// The active-environment id is MIRRORED to Rust via setActiveEnvironment; the Rust +// copy is the authority the proxy commands enforce (P-26). + +import { z } from "zod"; +import { typedInvoke } from "@/lib/tauri"; + +export const remoteEnvironmentStatusSchema = z.enum([ + "active", + "pending_add", + "pending_delete", +]); + +export type RemoteEnvironmentStatus = z.infer; + +export const remoteEnvironmentSummarySchema = z.object({ + id: z.string(), + environmentId: z.string(), + name: z.string(), + baseUrl: z.string(), + candidateUrls: z.array(z.string()), + scopes: z.array(z.string()), + protocolVersion: z.number(), + status: remoteEnvironmentStatusSchema, + createdAt: z.string(), + lastConnectedAt: z.string().nullable(), +}); + +export type RemoteEnvironmentSummary = z.infer; + +export const remoteEnvironmentsApi = { + /** Pairing exchange runs entirely in the Rust backend (§4.2). */ + pair(url: string, code: string, name: string): Promise { + return typedInvoke( + "pair_remote_environment", + { input: { url, code, name } }, + remoteEnvironmentSummarySchema + ); + }, + + list(): Promise { + return typedInvoke( + "list_remote_environments", + {}, + z.array(remoteEnvironmentSummarySchema) + ); + }, + + remove(id: string): Promise { + return typedInvoke("remove_remote_environment", { input: { id } }, z.null()); + }, + + getActiveEnvironment(): Promise { + return typedInvoke("get_active_environment", {}, z.string()); + }, + + /** Mirrors the switch into the Rust-side authoritative store (§6.4). */ + setActiveEnvironment(id: string): Promise { + return typedInvoke("set_active_environment", { input: { id } }, z.null()); + }, +}; diff --git a/frontend/src/stores/environmentStore.test.ts b/frontend/src/stores/environmentStore.test.ts new file mode 100644 index 0000000000..6a13b38b79 --- /dev/null +++ b/frontend/src/stores/environmentStore.test.ts @@ -0,0 +1,189 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { remoteEnvironmentsApi } from "@/api/remote-environments"; +import type { RemoteEnvironmentSummary } from "@/api/remote-environments"; +import { + LOCAL_ENVIRONMENT_ID, + useEnvironmentStore, +} from "./environmentStore"; + +vi.mock("@/api/remote-environments", () => ({ + remoteEnvironmentsApi: { + pair: vi.fn(), + list: vi.fn(), + remove: vi.fn(), + getActiveEnvironment: vi.fn(), + setActiveEnvironment: vi.fn(), + }, +})); + +const mockedApi = vi.mocked(remoteEnvironmentsApi); + +const summary = ( + overrides: Partial = {} +): RemoteEnvironmentSummary => ({ + id: "row-1", + environmentId: "env-1", + name: "Mac Studio", + baseUrl: "https://mac-studio.tailnet.ts.net", + candidateUrls: [], + scopes: ["ui:read", "ui:operate"], + protocolVersion: 1, + status: "active", + createdAt: "2026-07-27T19:15:00+00:00", + lastConnectedAt: null, + ...overrides, +}); + +function resetStore() { + useEnvironmentStore.setState({ + activeEnvironmentId: LOCAL_ENVIRONMENT_ID, + environments: [ + { id: LOCAL_ENVIRONMENT_ID, name: "This Mac", kind: "local" }, + ], + connectionStates: { [LOCAL_ENVIRONMENT_ID]: "connected" }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + resetStore(); +}); + +describe("environmentStore identity (§6.4)", () => { + it('always contains "local", which has no supervisor', () => { + const state = useEnvironmentStore.getState(); + expect(state.environments.map((entry) => entry.id)).toContain( + LOCAL_ENVIRONMENT_ID + ); + expect(state.activeEnvironmentId).toBe(LOCAL_ENVIRONMENT_ID); + expect(state.connectionStates[LOCAL_ENVIRONMENT_ID]).toBe("connected"); + + // Local never gets a supervisor-driven connection state. + state.setConnectionState(LOCAL_ENVIRONMENT_ID, "backoff"); + expect( + useEnvironmentStore.getState().connectionStates[LOCAL_ENVIRONMENT_ID] + ).toBe("connected"); + }); + + it("keeps local first when the registry loads", async () => { + mockedApi.list.mockResolvedValue([summary()]); + + await useEnvironmentStore.getState().loadEnvironments(); + + const ids = useEnvironmentStore + .getState() + .environments.map((entry) => entry.id); + expect(ids).toEqual([LOCAL_ENVIRONMENT_ID, "row-1"]); + }); +}); + +describe("setActiveEnvironment (first paint + Rust authority)", () => { + beforeEach(() => { + useEnvironmentStore + .getState() + .setEnvironments([summary()]); + }); + + it("updates the store synchronously before the Rust mirror resolves", async () => { + let resolveMirror: (value: null) => void = () => {}; + mockedApi.setActiveEnvironment.mockImplementation( + () => + new Promise((resolve) => { + resolveMirror = resolve; + }) + ); + + const pending = useEnvironmentStore + .getState() + .setActiveEnvironment("row-1"); + + // First paint wins: state is switched while the invoke is still in flight. + expect(useEnvironmentStore.getState().activeEnvironmentId).toBe("row-1"); + expect(mockedApi.setActiveEnvironment).toHaveBeenCalledWith("row-1"); + + resolveMirror(null); + await pending; + expect(useEnvironmentStore.getState().activeEnvironmentId).toBe("row-1"); + }); + + it("reverts when the Rust authority refuses the switch", async () => { + mockedApi.setActiveEnvironment.mockRejectedValue( + new Error("REMOTE_COMMAND_UNAVAILABLE: no paired remote environment") + ); + + await expect( + useEnvironmentStore.getState().setActiveEnvironment("row-1") + ).rejects.toThrow("REMOTE_COMMAND_UNAVAILABLE"); + + expect(useEnvironmentStore.getState().activeEnvironmentId).toBe( + LOCAL_ENVIRONMENT_ID + ); + }); + + it("ignores unknown environment ids without touching Rust", async () => { + await useEnvironmentStore.getState().setActiveEnvironment("ghost"); + + expect(useEnvironmentStore.getState().activeEnvironmentId).toBe( + LOCAL_ENVIRONMENT_ID + ); + expect(mockedApi.setActiveEnvironment).not.toHaveBeenCalled(); + }); + + it("is a no-op when switching to the already-active environment", async () => { + await useEnvironmentStore + .getState() + .setActiveEnvironment(LOCAL_ENVIRONMENT_ID); + expect(mockedApi.setActiveEnvironment).not.toHaveBeenCalled(); + }); +}); + +describe("registry refresh", () => { + it("falls back to local when the active environment disappears", async () => { + useEnvironmentStore.getState().setEnvironments([summary()]); + mockedApi.setActiveEnvironment.mockResolvedValue(null); + await useEnvironmentStore.getState().setActiveEnvironment("row-1"); + + // The environment was removed backend-side; a refresh no longer lists it. + useEnvironmentStore.getState().setEnvironments([]); + + expect(useEnvironmentStore.getState().activeEnvironmentId).toBe( + LOCAL_ENVIRONMENT_ID + ); + }); + + it("prunes connection states of removed environments", () => { + useEnvironmentStore.getState().setEnvironments([summary()]); + useEnvironmentStore.getState().setConnectionState("row-1", "connecting"); + + useEnvironmentStore.getState().setEnvironments([]); + + expect( + useEnvironmentStore.getState().connectionStates["row-1"] + ).toBeUndefined(); + expect( + useEnvironmentStore.getState().connectionStates[LOCAL_ENVIRONMENT_ID] + ).toBe("connected"); + }); +}); + +describe("hydrateActiveEnvironment", () => { + it("adopts the Rust-side authoritative id when it is known", async () => { + useEnvironmentStore.getState().setEnvironments([summary()]); + mockedApi.getActiveEnvironment.mockResolvedValue("row-1"); + + await useEnvironmentStore.getState().hydrateActiveEnvironment(); + + expect(useEnvironmentStore.getState().activeEnvironmentId).toBe("row-1"); + }); + + it("falls back to local for an unknown authoritative id", async () => { + mockedApi.getActiveEnvironment.mockResolvedValue("stale-row"); + + await useEnvironmentStore.getState().hydrateActiveEnvironment(); + + expect(useEnvironmentStore.getState().activeEnvironmentId).toBe( + LOCAL_ENVIRONMENT_ID + ); + }); +}); diff --git a/frontend/src/stores/environmentStore.ts b/frontend/src/stores/environmentStore.ts new file mode 100644 index 0000000000..ccce39c97f --- /dev/null +++ b/frontend/src/stores/environmentStore.ts @@ -0,0 +1,147 @@ +/** + * Environment store — multi-environment identity slice (PR 2.1, §6.4). + * + * `{ activeEnvironmentId, environments, connectionStates }` with `"local"` always + * present. Local has NO supervisor and no connection lifecycle — its connection + * state is pinned to "connected". + * + * Authority model: this store is the UI mirror; the Rust backend holds the + * authoritative active-environment id that the proxy commands enforce (P-26). + * `setActiveEnvironment` paints synchronously FIRST (rule 24: first paint wins), + * then mirrors the switch to Rust; if Rust refuses the switch the store reverts, + * so the UI can never sit on an environment the proxy will not serve. + * + * Supervisors, per-environment QueryClients, and connection lifecycles land in + * PR 2.2+; `connectionStates` already carries the canonical FSM vocabulary so + * those PRs extend this store instead of introducing a second owner. + */ + +import { create } from "zustand"; +import { + remoteEnvironmentsApi, + type RemoteEnvironmentSummary, +} from "@/api/remote-environments"; + +export const LOCAL_ENVIRONMENT_ID = "local"; + +/** Canonical supervisor FSM vocabulary (§6.5); "connected" is all local ever is. */ +export type EnvironmentConnectionState = + | "idle" + | "connecting" + | "connected" + | "backoff" + | "offline" + | "blocked" + | "suspended"; + +export interface EnvironmentEntry { + id: string; + name: string; + kind: "local" | "remote"; + /** Registry summary for remote entries; absent for local. */ + remote?: RemoteEnvironmentSummary; +} + +const LOCAL_ENTRY: EnvironmentEntry = { + id: LOCAL_ENVIRONMENT_ID, + name: "This Mac", + kind: "local", +}; + +interface EnvironmentState { + activeEnvironmentId: string; + environments: EnvironmentEntry[]; + connectionStates: Record; + /** + * Switches the active environment. Synchronous state update first (first + * paint), then mirrors to the Rust authority; reverts on rejection. + */ + setActiveEnvironment: (id: string) => Promise; + /** Replaces the remote entries from registry summaries; local always stays. */ + setEnvironments: (summaries: RemoteEnvironmentSummary[]) => void; + /** Loads the registry from the backend. */ + loadEnvironments: () => Promise; + /** Adopts the Rust-side authoritative active id (startup hydration). */ + hydrateActiveEnvironment: () => Promise; + setConnectionState: (id: string, state: EnvironmentConnectionState) => void; +} + +function toEntry(summary: RemoteEnvironmentSummary): EnvironmentEntry { + return { + id: summary.id, + name: summary.name, + kind: "remote", + remote: summary, + }; +} + +export const useEnvironmentStore = create((set, get) => ({ + activeEnvironmentId: LOCAL_ENVIRONMENT_ID, + environments: [LOCAL_ENTRY], + connectionStates: { [LOCAL_ENVIRONMENT_ID]: "connected" }, + + setActiveEnvironment: async (id) => { + const previous = get().activeEnvironmentId; + if (previous === id) return; + const known = get().environments.some((entry) => entry.id === id); + if (!known) return; + + // First paint wins: the switch is visible before any backend round-trip. + set({ activeEnvironmentId: id }); + try { + await remoteEnvironmentsApi.setActiveEnvironment(id); + } catch (error) { + // Rust is authoritative — a refused switch must not leave the UI on an + // environment the proxy will reject. + if (get().activeEnvironmentId === id) { + set({ activeEnvironmentId: previous }); + } + throw error; + } + }, + + setEnvironments: (summaries) => { + set((state) => { + const environments = [LOCAL_ENTRY, ...summaries.map(toEntry)]; + const knownIds = new Set(environments.map((entry) => entry.id)); + const connectionStates: Record = { + [LOCAL_ENVIRONMENT_ID]: "connected", + }; + for (const [id, connection] of Object.entries(state.connectionStates)) { + if (knownIds.has(id) && id !== LOCAL_ENVIRONMENT_ID) { + connectionStates[id] = connection; + } + } + return { + environments, + connectionStates, + // A removed environment cannot stay active; Rust already fell back to + // local when the row died, so the mirror follows. + activeEnvironmentId: knownIds.has(state.activeEnvironmentId) + ? state.activeEnvironmentId + : LOCAL_ENVIRONMENT_ID, + }; + }); + }, + + loadEnvironments: async () => { + const summaries = await remoteEnvironmentsApi.list(); + get().setEnvironments(summaries); + }, + + hydrateActiveEnvironment: async () => { + const id = await remoteEnvironmentsApi.getActiveEnvironment(); + set((state) => ({ + activeEnvironmentId: state.environments.some((entry) => entry.id === id) + ? id + : LOCAL_ENVIRONMENT_ID, + })); + }, + + setConnectionState: (id, connection) => { + if (id === LOCAL_ENVIRONMENT_ID) return; // local has no supervisor + set((state) => ({ + connectionStates: { ...state.connectionStates, [id]: connection }, + })); + }, +})); From f36225a1507de7831b758145d75d1864deabd26c Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:26:41 +0300 Subject: [PATCH 049/416] feat: add the live-session registry with revocation kill-channels The durable remote_sessions table is bookkeeping; this map is the enforcement handle. Revoking a device, narrowing its agent-control grant, or disabling the listener fires that device's kill channels so a live connection tears down immediately instead of waiting for its next HTTP call. Signalling happens before the senders drop, so a receiver observes the reason rather than a bare channel close, and teardown removes the entries so a repeat revoke reports zero rather than double-counting. The per-device session cap lives here too, keeping the cap and the registry from disagreeing about how many sessions a device holds. test: prove per-device isolation, listener-wide teardown, idempotent teardown, self-close cleanup, the cap, and channel replacement on session-id reuse. --- .../src/remote_server/session_registry.rs | 199 ++++++++++++++++++ .../remote_server/session_registry_tests.rs | 169 +++++++++++++++ 2 files changed, 368 insertions(+) create mode 100644 src-tauri/src/remote_server/session_registry.rs create mode 100644 src-tauri/src/remote_server/session_registry_tests.rs diff --git a/src-tauri/src/remote_server/session_registry.rs b/src-tauri/src/remote_server/session_registry.rs new file mode 100644 index 0000000000..17136f8561 --- /dev/null +++ b/src-tauri/src/remote_server/session_registry.rs @@ -0,0 +1,199 @@ +//! In-memory live-session registry — the enforcement handle for revocation teardown (§4.4). +//! +//! `remote_sessions` is durable bookkeeping; **this** map is what actually tears a live +//! connection down. Revoking a device, disabling the listener, or narrowing a device's +//! agent-control grant fires the device's kill channels, and every WS task will `select!` on +//! its channel (PR 1.4) so the socket closes immediately rather than at the next HTTP call. + +// The admission half of this contract (`register`, `unregister`, and the kill-channel +// receiver) has no production caller until PR 1.4 mounts the WebSocket upgrade. It ships +// with the teardown half deliberately: the cap, the channel, and the revocation path are one +// invariant and are tested together here rather than split across two PRs. +#![allow(dead_code)] + +use std::collections::HashMap; +use std::sync::Arc; + +use dashmap::DashMap; +use ralphx_remote_protocol::ResetReason; +use tokio::sync::broadcast; + +use crate::domain::entities::{RemoteDeviceId, RemoteSessionId}; + +/// Per-device cap on concurrent live sessions (§4.4 abuse controls). +/// +/// Enforced at WS upgrade in PR 1.4; the admission decision lives here so the cap and the +/// registry can never disagree about how many sessions a device holds. +pub(crate) const MAX_SESSIONS_PER_DEVICE: usize = 8; + +/// Kill-channel capacity. One slot is enough: teardown is terminal, not a stream. +const KILL_CHANNEL_CAPACITY: usize = 1; + +/// The receiving half handed to a live session. +/// +/// PR 1.4's WS task selects on [`RemoteSessionKillChannel::recv`]; when it resolves, the task +/// sends `error{code:"revoked"}` / `reset(host_disabled)` and closes. +pub(crate) struct RemoteSessionKillChannel { + receiver: broadcast::Receiver, +} + +impl RemoteSessionKillChannel { + /// Resolves when the host tears this session down. + /// + /// `None` means the sender was dropped without a reason — also a teardown, so callers + /// must close either way rather than treating it as "keep running". + pub(crate) async fn recv(&mut self) -> Option { + self.receiver.recv().await.ok() + } + + /// Non-blocking probe, used by tests and by the heartbeat re-check path. + pub(crate) fn try_recv(&mut self) -> Option { + self.receiver.try_recv().ok() + } +} + +/// Whether a session may join the registry. +pub(crate) enum RemoteSessionAdmission { + Admitted(RemoteSessionKillChannel), + /// The device already holds [`MAX_SESSIONS_PER_DEVICE`] live sessions. + CapExceeded { + limit: usize, + }, +} + +/// `device_id → {session_id → kill channel}`. +/// +/// Cloning shares the same map: the router, the Tauri revoke commands, and the listener +/// lifecycle all act on one registry, never on private copies. +#[derive(Clone, Default)] +pub(crate) struct RemoteSessionRegistry { + sessions: + Arc>>>, +} + +impl RemoteSessionRegistry { + pub(crate) fn new() -> Self { + Self::default() + } + + /// Admits a session, returning its kill channel. + /// + /// Re-registering the same session id replaces its channel, so a reconnect under a reused + /// id can never leave an orphaned sender that teardown would miss. + pub(crate) fn register( + &self, + device_id: &RemoteDeviceId, + session_id: &RemoteSessionId, + ) -> RemoteSessionAdmission { + let mut device_sessions = self.sessions.entry(device_id.clone()).or_default(); + if !device_sessions.contains_key(session_id) + && device_sessions.len() >= MAX_SESSIONS_PER_DEVICE + { + return RemoteSessionAdmission::CapExceeded { + limit: MAX_SESSIONS_PER_DEVICE, + }; + } + let (sender, receiver) = broadcast::channel(KILL_CHANNEL_CAPACITY); + device_sessions.insert(session_id.clone(), sender); + RemoteSessionAdmission::Admitted(RemoteSessionKillChannel { receiver }) + } + + /// Drops a session that closed on its own. Idempotent. + pub(crate) fn unregister(&self, device_id: &RemoteDeviceId, session_id: &RemoteSessionId) { + let mut empty = false; + if let Some(mut device_sessions) = self.sessions.get_mut(device_id) { + device_sessions.remove(session_id); + empty = device_sessions.is_empty(); + } + if empty { + self.sessions + .remove_if(device_id, |_, value| value.is_empty()); + } + } + + /// Fires every kill channel for one device and forgets its sessions. + /// + /// Returns how many sessions were signalled. Callers must have already written the + /// durable authority (`revoked_at`, narrowed scopes) — the registry is the *effect*, + /// never the proof (§4.4 teardown order). + pub(crate) fn kill_device(&self, device_id: &RemoteDeviceId, reason: ResetReason) -> usize { + let Some((_, device_sessions)) = self.sessions.remove(device_id) else { + return 0; + }; + signal_all(device_sessions, reason) + } + + /// Fires one session's kill channel and forgets it. + /// + /// Returns whether a live channel was actually signalled, so a caller can distinguish + /// "torn down" from "already gone" instead of reporting success either way. + pub(crate) fn kill_session( + &self, + device_id: &RemoteDeviceId, + session_id: &RemoteSessionId, + reason: ResetReason, + ) -> bool { + let mut signalled = false; + let mut empty = false; + if let Some(mut device_sessions) = self.sessions.get_mut(device_id) { + if let Some(sender) = device_sessions.remove(session_id) { + let _ = sender.send(reason); + signalled = true; + } + empty = device_sessions.is_empty(); + } + if empty { + self.sessions + .remove_if(device_id, |_, value| value.is_empty()); + } + signalled + } + + /// Fires every kill channel on the host (listener disable). + pub(crate) fn kill_all(&self, reason: ResetReason) -> usize { + let device_ids: Vec = self + .sessions + .iter() + .map(|entry| entry.key().clone()) + .collect(); + device_ids + .into_iter() + .map(|device_id| self.kill_device(&device_id, reason)) + .sum() + } + + /// Live session ids for a device, in no particular order. + pub(crate) fn live_sessions(&self, device_id: &RemoteDeviceId) -> Vec { + self.sessions + .get(device_id) + .map(|entry| entry.keys().cloned().collect()) + .unwrap_or_default() + } + + pub(crate) fn device_session_count(&self, device_id: &RemoteDeviceId) -> usize { + self.sessions + .get(device_id) + .map(|entry| entry.len()) + .unwrap_or(0) + } + + pub(crate) fn live_session_count(&self) -> usize { + self.sessions.iter().map(|entry| entry.len()).sum() + } +} + +/// Sends before dropping the senders so a receiver still observes the *reason* rather than a +/// bare channel close. +fn signal_all( + device_sessions: HashMap>, + reason: ResetReason, +) -> usize { + let mut signalled = 0; + for (_, sender) in device_sessions.into_iter() { + // A send error only means the session already dropped its receiver; the session is + // gone either way, so it still counts as torn down. + let _ = sender.send(reason); + signalled += 1; + } + signalled +} diff --git a/src-tauri/src/remote_server/session_registry_tests.rs b/src-tauri/src/remote_server/session_registry_tests.rs new file mode 100644 index 0000000000..87ae27fc1b --- /dev/null +++ b/src-tauri/src/remote_server/session_registry_tests.rs @@ -0,0 +1,169 @@ +use ralphx_remote_protocol::ResetReason; + +use super::session_registry::{ + RemoteSessionAdmission, RemoteSessionRegistry, MAX_SESSIONS_PER_DEVICE, +}; +use crate::domain::entities::{RemoteDeviceId, RemoteSessionId}; + +fn admit( + registry: &RemoteSessionRegistry, + device_id: &RemoteDeviceId, + session_id: &RemoteSessionId, +) -> super::session_registry::RemoteSessionKillChannel { + match registry.register(device_id, session_id) { + RemoteSessionAdmission::Admitted(channel) => channel, + RemoteSessionAdmission::CapExceeded { limit } => { + panic!("session should be admitted below the cap of {limit}") + } + } +} + +#[test] +fn revoking_a_device_signals_every_one_of_its_sessions_and_forgets_them() { + let registry = RemoteSessionRegistry::new(); + let device = RemoteDeviceId::from_string("device-1"); + let first_id = RemoteSessionId::from_string("session-1"); + let second_id = RemoteSessionId::from_string("session-2"); + let mut first = admit(®istry, &device, &first_id); + let mut second = admit(®istry, &device, &second_id); + + let signalled = registry.kill_device(&device, ResetReason::Revoked); + + assert_eq!(signalled, 2); + assert_eq!(first.try_recv(), Some(ResetReason::Revoked)); + assert_eq!(second.try_recv(), Some(ResetReason::Revoked)); + assert_eq!(registry.device_session_count(&device), 0); + assert_eq!(registry.live_session_count(), 0); +} + +#[test] +fn teardown_never_reaches_another_devices_sessions() { + let registry = RemoteSessionRegistry::new(); + let revoked = RemoteDeviceId::from_string("device-revoked"); + let bystander = RemoteDeviceId::from_string("device-bystander"); + let mut revoked_channel = admit( + ®istry, + &revoked, + &RemoteSessionId::from_string("session-a"), + ); + let mut bystander_channel = admit( + ®istry, + &bystander, + &RemoteSessionId::from_string("session-b"), + ); + + registry.kill_device(&revoked, ResetReason::Revoked); + + assert_eq!(revoked_channel.try_recv(), Some(ResetReason::Revoked)); + assert_eq!( + bystander_channel.try_recv(), + None, + "an unrelated device must keep its live session" + ); + assert_eq!(registry.device_session_count(&bystander), 1); +} + +#[test] +fn disabling_the_listener_tears_down_every_device() { + let registry = RemoteSessionRegistry::new(); + let first = RemoteDeviceId::from_string("device-1"); + let second = RemoteDeviceId::from_string("device-2"); + let mut first_channel = admit(®istry, &first, &RemoteSessionId::from_string("s-1")); + let mut second_channel = admit(®istry, &second, &RemoteSessionId::from_string("s-2")); + + let signalled = registry.kill_all(ResetReason::HostDisabled); + + assert_eq!(signalled, 2); + assert_eq!(first_channel.try_recv(), Some(ResetReason::HostDisabled)); + assert_eq!(second_channel.try_recv(), Some(ResetReason::HostDisabled)); + assert_eq!(registry.live_session_count(), 0); +} + +#[test] +fn a_second_teardown_of_the_same_device_signals_nothing() { + let registry = RemoteSessionRegistry::new(); + let device = RemoteDeviceId::from_string("device-1"); + let _channel = admit(®istry, &device, &RemoteSessionId::from_string("s-1")); + + let first = registry.kill_device(&device, ResetReason::Revoked); + let second = registry.kill_device(&device, ResetReason::Revoked); + + assert_eq!(first, 1); + assert_eq!(second, 0, "teardown must be idempotent, not double-counted"); +} + +#[test] +fn a_session_that_closes_on_its_own_leaves_no_stale_entry() { + let registry = RemoteSessionRegistry::new(); + let device = RemoteDeviceId::from_string("device-1"); + let session = RemoteSessionId::from_string("s-1"); + let _channel = admit(®istry, &device, &session); + + registry.unregister(&device, &session); + registry.unregister(&device, &session); + + assert_eq!(registry.live_sessions(&device), Vec::new()); + assert_eq!(registry.live_session_count(), 0); + assert_eq!(registry.kill_device(&device, ResetReason::Revoked), 0); +} + +#[test] +fn a_device_cannot_exceed_the_session_cap() { + let registry = RemoteSessionRegistry::new(); + let device = RemoteDeviceId::from_string("device-1"); + let mut channels = Vec::new(); + for index in 0..MAX_SESSIONS_PER_DEVICE { + channels.push(admit( + ®istry, + &device, + &RemoteSessionId::from_string(format!("session-{index}")), + )); + } + + let over_cap = registry.register(&device, &RemoteSessionId::from_string("session-extra")); + + assert!(matches!( + over_cap, + RemoteSessionAdmission::CapExceeded { + limit: MAX_SESSIONS_PER_DEVICE + } + )); + assert_eq!( + registry.device_session_count(&device), + MAX_SESSIONS_PER_DEVICE + ); +} + +#[test] +fn re_registering_a_session_id_replaces_its_channel_without_consuming_cap() { + let registry = RemoteSessionRegistry::new(); + let device = RemoteDeviceId::from_string("device-1"); + let session = RemoteSessionId::from_string("session-1"); + let mut stale = admit(®istry, &device, &session); + let mut fresh = admit(®istry, &device, &session); + + registry.kill_device(&device, ResetReason::Revoked); + + assert_eq!(registry.device_session_count(&device), 0); + assert_eq!(fresh.try_recv(), Some(ResetReason::Revoked)); + assert_eq!( + stale.try_recv(), + None, + "the replaced channel must not be double-signalled" + ); +} + +#[tokio::test] +async fn a_live_session_awaits_its_kill_signal() { + let registry = RemoteSessionRegistry::new(); + let device = RemoteDeviceId::from_string("device-1"); + let mut channel = admit(®istry, &device, &RemoteSessionId::from_string("s-1")); + let waiter = tokio::spawn(async move { channel.recv().await }); + + registry.kill_device(&device, ResetReason::Revoked); + + assert_eq!( + waiter.await.expect("waiter should finish"), + Some(ResetReason::Revoked) + ); +} From 688e77678c75eed71415fd182aac20e6405d2895 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:26:49 +0300 Subject: [PATCH 050/416] feat: add Serve-aware rate limiting for the remote listener Token bucket plus failure lockout with the external-MCP limiter's parameters (10 rps, 5 failures, 30 s), a per-device HTTP concurrency cap, and an RAII slot guard so a cancelled handler cannot leak capacity. The identity boundary is the point: under Tailscale Serve every request arrives from loopback, so keying on the socket would let one tailnet peer lock every device out. Pre-auth limiting therefore keys on a global bucket plus the presented pairing code, post-auth on device_id, and only direct-tailnet mode keys on a real peer address. Because pre-auth keys are per pairing code, a brute-forcer mints a fresh identity per guess; the maps are bounded by sweeping identities that hold no live lockout and no drained bucket, never a live one. test: burst/refill, the five-failure lockout and its expiry, independence across codes and devices, the Serve/direct keying split, the concurrency cap, and that pruning spares a live lockout. --- src-tauri/src/remote_server/rate_limit.rs | 298 ++++++++++++++++++ .../src/remote_server/rate_limit_tests.rs | 259 +++++++++++++++ 2 files changed, 557 insertions(+) create mode 100644 src-tauri/src/remote_server/rate_limit.rs create mode 100644 src-tauri/src/remote_server/rate_limit_tests.rs diff --git a/src-tauri/src/remote_server/rate_limit.rs b/src-tauri/src/remote_server/rate_limit.rs new file mode 100644 index 0000000000..c10dfd6530 --- /dev/null +++ b/src-tauri/src/remote_server/rate_limit.rs @@ -0,0 +1,298 @@ +//! Rate limiting and abuse controls for the remote listener (§4.4). +//! +//! Parameters mirror the external-MCP limiter (`plugins/app/ralphx-external-mcp/src/ +//! rate-limiter.ts:14-17,45-114`): 10 rps token bucket, 5 consecutive auth failures → 30 s +//! lockout. +//! +//! **Serve identity boundary.** Under Tailscale Serve every request arrives from loopback, so +//! keying on the socket address would let one tailnet peer lock every device out and would +//! make `remote_addr` meaningless. Pre-auth limiting therefore keys on a global bucket plus +//! the *presented pairing code* (so two devices redeeming different codes lock out +//! independently), and post-auth limiting keys on `device_id`. Only direct-tailnet mode, +//! where the source really is the peer's `100.x` address, keys on the peer. + +use std::net::IpAddr; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use dashmap::DashMap; + +use crate::domain::entities::RemoteDeviceId; +use crate::remote_server::settings::RemoteExposureMode; + +/// Tunables for the remote limiter, sourced from the external-MCP limiter. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct RemoteRateLimitParams { + pub requests_per_second: f64, + pub auth_failures_before_lockout: u32, + pub lockout: Duration, + pub max_in_flight_per_device: usize, +} + +/// Soft cap on tracked identities. Pre-auth keying is by *presented pairing code*, so a +/// brute-forcer mints a fresh key per guess; without a bound the maps would grow without +/// limit. Exceeding the cap drops entries that are already idle — never a live lockout. +const IDENTITY_MAP_SOFT_CAP: usize = 4096; + +pub(crate) const REMOTE_RATE_LIMIT_DEFAULTS: RemoteRateLimitParams = RemoteRateLimitParams { + requests_per_second: 10.0, + auth_failures_before_lockout: 5, + lockout: Duration::from_secs(30), + max_in_flight_per_device: 8, +}; + +/// What a bucket is keyed on. Never the socket address under Serve (§4.4). +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub(crate) enum RemoteRateLimitKey { + /// Coarse flood ceiling shared by all pre-auth callers under Serve. + Global, + /// The SHA-256 of a presented pairing code — the identity that carries the lockout, so + /// one peer's brute force cannot lock out a different device's pairing attempt. + PairingCode(String), + /// A paired device, post-auth. + Device(String), + /// A real tailnet peer address; direct-tailnet exposure only. + Peer(String), +} + +impl RemoteRateLimitKey { + pub(crate) fn device(device_id: &RemoteDeviceId) -> Self { + Self::Device(device_id.to_string()) + } + + pub(crate) fn pairing_code(code_hash: impl Into) -> Self { + Self::PairingCode(code_hash.into()) + } +} + +/// Chooses the pre-auth limiting identity for the active exposure mode. +/// +/// Serve collapses every peer onto loopback, so the socket address is deliberately ignored +/// there; the punitive lockout rides on the pairing-code identity instead. +pub(crate) fn auth_endpoint_key( + exposure_mode: RemoteExposureMode, + peer: Option, +) -> RemoteRateLimitKey { + match (exposure_mode, peer) { + (RemoteExposureMode::TailnetDirect, Some(peer)) if !peer.is_loopback() => { + RemoteRateLimitKey::Peer(peer.to_string()) + } + _ => RemoteRateLimitKey::Global, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RemoteRateLimitDecision { + Allowed, + /// The token bucket is empty. + RateLimited { + retry_after_secs: u64, + }, + /// Consecutive auth failures tripped the lockout. + LockedOut { + retry_after_secs: u64, + }, +} + +impl RemoteRateLimitDecision { + pub(crate) fn is_allowed(self) -> bool { + matches!(self, Self::Allowed) + } + + pub(crate) fn retry_after_secs(self) -> Option { + match self { + Self::Allowed => None, + Self::RateLimited { retry_after_secs } | Self::LockedOut { retry_after_secs } => { + Some(retry_after_secs) + } + } + } +} + +#[derive(Debug, Clone, Copy)] +struct BucketState { + tokens: f64, + last_refill_at: Instant, +} + +#[derive(Debug, Clone, Copy, Default)] +struct FailureState { + failure_count: u32, + locked_until: Option, +} + +/// RAII guard for a device's HTTP concurrency slot. +/// +/// Releasing on drop means a panicking or cancelled handler cannot leak capacity. +pub(crate) struct RemoteDeviceSlot { + in_flight: Arc>, + device_id: String, +} + +impl Drop for RemoteDeviceSlot { + fn drop(&mut self) { + if let Some(mut count) = self.in_flight.get_mut(&self.device_id) { + *count = count.saturating_sub(1); + } + self.in_flight + .remove_if(&self.device_id, |_, count| *count == 0); + } +} + +/// Token buckets, failure lockouts, and per-device concurrency for the remote listener. +/// +/// Cloning shares state — the router holds one limiter for the listener's lifetime. +#[derive(Clone)] +pub(crate) struct RemoteRateLimiter { + params: RemoteRateLimitParams, + buckets: Arc>, + failures: Arc>, + in_flight: Arc>, +} + +impl Default for RemoteRateLimiter { + fn default() -> Self { + Self::new(REMOTE_RATE_LIMIT_DEFAULTS) + } +} + +impl RemoteRateLimiter { + pub(crate) fn new(params: RemoteRateLimitParams) -> Self { + Self { + params, + buckets: Arc::new(DashMap::new()), + failures: Arc::new(DashMap::new()), + in_flight: Arc::new(DashMap::new()), + } + } + + /// Checks lockout then the token bucket for `key`, consuming one token when allowed. + pub(crate) fn check(&self, key: &RemoteRateLimitKey) -> RemoteRateLimitDecision { + self.check_at(key, Instant::now()) + } + + /// Deterministic variant used by tests; production passes `Instant::now()`. + pub(crate) fn check_at( + &self, + key: &RemoteRateLimitKey, + now: Instant, + ) -> RemoteRateLimitDecision { + if let Some(retry_after_secs) = self.lockout_remaining_at(key, now) { + return RemoteRateLimitDecision::LockedOut { retry_after_secs }; + } + + let mut bucket = self.buckets.entry(key.clone()).or_insert(BucketState { + tokens: self.params.requests_per_second, + last_refill_at: now, + }); + let elapsed = now.saturating_duration_since(bucket.last_refill_at); + bucket.tokens = (bucket.tokens + elapsed.as_secs_f64() * self.params.requests_per_second) + .min(self.params.requests_per_second); + bucket.last_refill_at = now; + + if bucket.tokens < 1.0 { + // Time to earn one whole token back, rounded up to a whole second. + let deficit = 1.0 - bucket.tokens; + let seconds = (deficit / self.params.requests_per_second).ceil() as u64; + return RemoteRateLimitDecision::RateLimited { + retry_after_secs: seconds.max(1), + }; + } + bucket.tokens -= 1.0; + RemoteRateLimitDecision::Allowed + } + + /// Records a rejected auth attempt for `key`, tripping the lockout at the threshold. + pub(crate) fn record_failure(&self, key: &RemoteRateLimitKey) { + self.record_failure_at(key, Instant::now()); + } + + pub(crate) fn record_failure_at(&self, key: &RemoteRateLimitKey, now: Instant) { + self.prune_idle_identities(now); + let mut state = self.failures.entry(key.clone()).or_default(); + // Clear an expired lockout first so a stale one does not compound. + if state.locked_until.is_some_and(|until| now >= until) { + *state = FailureState::default(); + } + state.failure_count = state.failure_count.saturating_add(1); + if state.failure_count >= self.params.auth_failures_before_lockout { + state.locked_until = Some(now + self.params.lockout); + } + } + + /// Clears the failure streak after a successful auth. + pub(crate) fn record_success(&self, key: &RemoteRateLimitKey) { + self.failures.remove(key); + } + + fn lockout_remaining_at(&self, key: &RemoteRateLimitKey, now: Instant) -> Option { + let mut state = self.failures.get_mut(key)?; + let locked_until = state.locked_until?; + if now >= locked_until { + *state = FailureState::default(); + return None; + } + Some( + locked_until + .saturating_duration_since(now) + .as_secs() + .saturating_add(1), + ) + } + + /// Reserves one of the device's in-flight HTTP slots. + /// + /// `None` means the device is at its concurrency cap; the caller must refuse rather than + /// queue, so a single device cannot pin the listener's request capacity. + pub(crate) fn acquire_device_slot( + &self, + device_id: &RemoteDeviceId, + ) -> Option { + let key = device_id.to_string(); + let mut count = self.in_flight.entry(key.clone()).or_insert(0); + if *count >= self.params.max_in_flight_per_device { + return None; + } + *count += 1; + drop(count); + Some(RemoteDeviceSlot { + in_flight: Arc::clone(&self.in_flight), + device_id: key, + }) + } + + /// Bounds memory by dropping identities that hold no live lockout and no drained + /// bucket. An identity currently locked out or still short of tokens is always kept, so + /// pruning can never hand a flooder a fresh budget. + fn prune_idle_identities(&self, now: Instant) { + if self.failures.len() > IDENTITY_MAP_SOFT_CAP { + self.failures + .retain(|_, state| state.locked_until.is_some_and(|until| now < until)); + } + if self.buckets.len() > IDENTITY_MAP_SOFT_CAP { + let capacity = self.params.requests_per_second; + let rate = self.params.requests_per_second; + self.buckets.retain(|_, bucket| { + let refilled = bucket.tokens + + now + .saturating_duration_since(bucket.last_refill_at) + .as_secs_f64() + * rate; + refilled < capacity + }); + } + } + + #[cfg(test)] + pub(crate) fn identity_count(&self) -> (usize, usize) { + (self.failures.len(), self.buckets.len()) + } + + #[cfg(test)] + pub(crate) fn in_flight_for(&self, device_id: &RemoteDeviceId) -> usize { + self.in_flight + .get(&device_id.to_string()) + .map(|count| *count) + .unwrap_or(0) + } +} diff --git a/src-tauri/src/remote_server/rate_limit_tests.rs b/src-tauri/src/remote_server/rate_limit_tests.rs new file mode 100644 index 0000000000..488f11f7de --- /dev/null +++ b/src-tauri/src/remote_server/rate_limit_tests.rs @@ -0,0 +1,259 @@ +use std::net::{IpAddr, Ipv4Addr}; +use std::time::{Duration, Instant}; + +use super::rate_limit::{ + auth_endpoint_key, RemoteRateLimitDecision, RemoteRateLimitKey, RemoteRateLimiter, + REMOTE_RATE_LIMIT_DEFAULTS, +}; +use super::settings::RemoteExposureMode; +use crate::domain::entities::RemoteDeviceId; + +fn ip(value: &str) -> IpAddr { + value.parse().expect("test address should parse") +} + +/// The params must stay pinned to the external-MCP limiter (rate-limiter.ts:14-17). +#[test] +fn the_limiter_params_match_the_external_mcp_limiter() { + let params = REMOTE_RATE_LIMIT_DEFAULTS; + + assert_eq!(params.requests_per_second, 10.0); + assert_eq!(params.auth_failures_before_lockout, 5); + assert_eq!(params.lockout, Duration::from_secs(30)); +} + +#[test] +fn the_token_bucket_admits_the_burst_then_refuses() { + let limiter = RemoteRateLimiter::default(); + let key = RemoteRateLimitKey::Global; + let now = Instant::now(); + + let admitted = (0..10) + .filter(|_| limiter.check_at(&key, now).is_allowed()) + .count(); + let eleventh = limiter.check_at(&key, now); + + assert_eq!(admitted, 10); + assert!(matches!( + eleventh, + RemoteRateLimitDecision::RateLimited { .. } + )); + assert!(eleventh.retry_after_secs().is_some_and(|secs| secs >= 1)); +} + +#[test] +fn the_bucket_refills_over_time() { + let limiter = RemoteRateLimiter::default(); + let key = RemoteRateLimitKey::Global; + let start = Instant::now(); + for _ in 0..10 { + limiter.check_at(&key, start); + } + + let while_empty = limiter.check_at(&key, start); + let after_refill = limiter.check_at(&key, start + Duration::from_secs(1)); + + assert!(matches!( + while_empty, + RemoteRateLimitDecision::RateLimited { .. } + )); + assert!(after_refill.is_allowed()); +} + +/// P-8 / acceptance: the 6th failed pair attempt in the window is locked out. +#[test] +fn five_auth_failures_lock_the_identity_out_for_thirty_seconds() { + let limiter = RemoteRateLimiter::default(); + let key = RemoteRateLimitKey::pairing_code("hash-of-code-a"); + let start = Instant::now(); + + for _ in 0..5 { + assert!(limiter.check_at(&key, start).is_allowed()); + limiter.record_failure_at(&key, start); + } + let sixth = limiter.check_at(&key, start); + let during_lockout = limiter.check_at(&key, start + Duration::from_secs(29)); + let after_lockout = limiter.check_at(&key, start + Duration::from_secs(31)); + + assert!(matches!(sixth, RemoteRateLimitDecision::LockedOut { .. })); + assert!(matches!( + during_lockout, + RemoteRateLimitDecision::LockedOut { .. } + )); + assert!( + after_lockout.is_allowed(), + "the lockout must expire, not persist" + ); +} + +/// P-8: under Serve two devices redeeming different codes are limited independently, so one +/// peer's brute force cannot lock the owner's other device out of pairing. +#[test] +fn under_serve_one_peers_lockout_does_not_reach_another_devices_pairing_code() { + let limiter = RemoteRateLimiter::default(); + let attacked = RemoteRateLimitKey::pairing_code("hash-of-code-under-attack"); + let bystander = RemoteRateLimitKey::pairing_code("hash-of-a-different-code"); + let start = Instant::now(); + + for _ in 0..6 { + limiter.record_failure_at(&attacked, start); + } + + assert!(matches!( + limiter.check_at(&attacked, start), + RemoteRateLimitDecision::LockedOut { .. } + )); + assert!( + limiter.check_at(&bystander, start).is_allowed(), + "a second device's pairing attempt must stay admissible" + ); +} + +/// Post-auth the identity is the device, never the socket — same independence guarantee. +#[test] +fn per_device_buckets_are_independent() { + let limiter = RemoteRateLimiter::default(); + let noisy = RemoteRateLimitKey::device(&RemoteDeviceId::from_string("device-noisy")); + let quiet = RemoteRateLimitKey::device(&RemoteDeviceId::from_string("device-quiet")); + let now = Instant::now(); + + for _ in 0..10 { + limiter.check_at(&noisy, now); + } + + assert!(matches!( + limiter.check_at(&noisy, now), + RemoteRateLimitDecision::RateLimited { .. } + )); + assert!(limiter.check_at(&quiet, now).is_allowed()); +} + +#[test] +fn a_successful_auth_clears_the_failure_streak() { + let limiter = RemoteRateLimiter::default(); + let key = RemoteRateLimitKey::pairing_code("hash-of-code-a"); + let start = Instant::now(); + for _ in 0..4 { + limiter.record_failure_at(&key, start); + } + + limiter.record_success(&key); + for _ in 0..4 { + limiter.record_failure_at(&key, start); + } + + assert!( + limiter.check_at(&key, start).is_allowed(), + "four failures after a success must not trip the five-failure lockout" + ); +} + +/// Serve collapses every peer onto loopback, so the socket address must not become the key. +#[test] +fn serve_mode_never_keys_pre_auth_limiting_on_the_socket_address() { + let loopback = auth_endpoint_key(RemoteExposureMode::Serve, Some(ip("127.0.0.1"))); + let spoofed_peer = auth_endpoint_key(RemoteExposureMode::Serve, Some(ip("100.64.0.7"))); + let unknown = auth_endpoint_key(RemoteExposureMode::Serve, None); + + assert_eq!(loopback, RemoteRateLimitKey::Global); + assert_eq!(spoofed_peer, RemoteRateLimitKey::Global); + assert_eq!(unknown, RemoteRateLimitKey::Global); +} + +#[test] +fn direct_tailnet_mode_keys_on_the_real_peer_address() { + let peer = auth_endpoint_key( + RemoteExposureMode::TailnetDirect, + Some(IpAddr::V4(Ipv4Addr::new(100, 64, 0, 7))), + ); + let loopback = auth_endpoint_key(RemoteExposureMode::TailnetDirect, Some(ip("127.0.0.1"))); + let unknown = auth_endpoint_key(RemoteExposureMode::TailnetDirect, None); + + assert_eq!(peer, RemoteRateLimitKey::Peer("100.64.0.7".to_string())); + assert_eq!( + loopback, + RemoteRateLimitKey::Global, + "a loopback source in direct mode is a proxy, not a peer identity" + ); + assert_eq!(unknown, RemoteRateLimitKey::Global); +} + +#[test] +fn a_device_cannot_exceed_its_http_concurrency_cap() { + let limiter = RemoteRateLimiter::default(); + let device = RemoteDeviceId::from_string("device-1"); + let cap = REMOTE_RATE_LIMIT_DEFAULTS.max_in_flight_per_device; + + let slots: Vec<_> = (0..cap) + .map(|_| { + limiter + .acquire_device_slot(&device) + .expect("slots below the cap should be granted") + }) + .collect(); + let over_cap = limiter.acquire_device_slot(&device); + + assert!(over_cap.is_none()); + assert_eq!(limiter.in_flight_for(&device), cap); + drop(slots); + assert_eq!(limiter.in_flight_for(&device), 0); + assert!(limiter.acquire_device_slot(&device).is_some()); +} + +#[test] +fn one_devices_concurrency_cap_does_not_block_another_device() { + let limiter = RemoteRateLimiter::default(); + let saturated = RemoteDeviceId::from_string("device-saturated"); + let other = RemoteDeviceId::from_string("device-other"); + let _slots: Vec<_> = (0..REMOTE_RATE_LIMIT_DEFAULTS.max_in_flight_per_device) + .map(|_| { + limiter + .acquire_device_slot(&saturated) + .expect("slots below the cap should be granted") + }) + .collect(); + + assert!(limiter.acquire_device_slot(&saturated).is_none()); + assert!(limiter.acquire_device_slot(&other).is_some()); +} + +/// Pre-auth keys are per *pairing code*, so a brute-forcer mints a fresh identity per guess. +/// Pruning must bound that growth without ever releasing a live lockout. +#[test] +fn idle_identities_are_pruned_while_live_lockouts_survive() { + let limiter = RemoteRateLimiter::default(); + let start = Instant::now(); + let locked = RemoteRateLimitKey::pairing_code("hash-of-a-locked-code"); + for _ in 0..5 { + limiter.record_failure_at(&locked, start); + } + for index in 0..6000 { + limiter.record_failure_at( + &RemoteRateLimitKey::pairing_code(format!("guess-{index}")), + start, + ); + } + + // Well past every one-off guess's lockout, one more failure triggers the sweep. + let later = start + Duration::from_secs(600); + limiter.record_failure_at( + &RemoteRateLimitKey::pairing_code("hash-of-a-fresh-code"), + later, + ); + for _ in 0..5 { + limiter.record_failure_at(&locked, later); + } + let (failures, _) = limiter.identity_count(); + + assert!( + failures < 6000, + "idle identities must be swept, {failures} remained" + ); + assert!( + matches!( + limiter.check_at(&locked, later), + RemoteRateLimitDecision::LockedOut { .. } + ), + "a live lockout must survive pruning" + ); +} From 7f4af584b0994b915df76d707c0eacd8cd4550de Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:27:02 +0300 Subject: [PATCH 051/416] feat: land fail-closed bearer auth, pairing, and WS tickets on :3849 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces PR 1.1's refuse-everything slot with the real middleware stack: X-RalphX-* stripping outermost, then pre-auth flood control, then the bearer check. The auth context is a required part of the router state, so there is no router shape that can serve a non-allowlisted route without a device store to check against, and no zero-devices bootstrap pass. Absent, malformed, unknown, and revoked bearers are four distinct typed rejections that all answer 401 with one uniform message, so the surface is not an oracle for which tokens exist. A repository error is a fifth variant and answers 500 — collapsing it into 401 would let a database outage look like "this token is not paired" and invite a client to discard a good token. Store errors and rate limits deliberately do not feed the failure lockout. Authority is recorded before any handler runs: a failed audit write on an accepted request refuses it rather than granting untraceable access. Endpoints: /auth/pair exchanges a single-use code for a rxd_live_ token, rate-limited on the presented code so two devices lock out independently; /auth/ws-ticket issues a 60 s device-bound single-use ticket for PR 1.4; GET /session reports the currently effective grant re-read on every request, so a host-side agent-control toggle lands without re-pairing; DELETE ends the caller's own sessions. Disabling the listener now tears every session down. test: the fail-closed matrix including repo-error-is-500, header stripping and a forged tauri-local header, pairing replay/scope-subset/store-failure, the sixth-attempt lockout and its independence, ticket replay and expiry, the agent-control toggle round trip with teardown, and the audit trail. --- src-tauri/src/remote_server/auth.rs | 565 +++++++++++ src-tauri/src/remote_server/auth_endpoints.rs | 307 ++++++ src-tauri/src/remote_server/auth_tests.rs | 951 ++++++++++++++++++ src-tauri/src/remote_server/endpoints.rs | 13 +- src-tauri/src/remote_server/listener_tests.rs | 5 +- src-tauri/src/remote_server/mod.rs | 125 ++- src-tauri/src/remote_server/settings.rs | 5 + 7 files changed, 1938 insertions(+), 33 deletions(-) create mode 100644 src-tauri/src/remote_server/auth.rs create mode 100644 src-tauri/src/remote_server/auth_endpoints.rs create mode 100644 src-tauri/src/remote_server/auth_tests.rs diff --git a/src-tauri/src/remote_server/auth.rs b/src-tauri/src/remote_server/auth.rs new file mode 100644 index 0000000000..a692da2de0 --- /dev/null +++ b/src-tauri/src/remote_server/auth.rs @@ -0,0 +1,565 @@ +//! Fail-closed bearer authentication for the remote listener (§4.4). +//! +//! Three properties this module exists to guarantee: +//! +//! 1. **Absent ≠ invalid ≠ store failure.** Each is a distinct [`RemoteAuthRejection`] +//! variant with its own status. A repository or query error answers **500**, never a 401 +//! that would make a store outage indistinguishable from "this token is not paired" +//! (stateful-workflow "fail closed on reads"). +//! 2. **No bootstrap exception.** Unlike `require_admin_key`'s zero-keys pass +//! (`api_keys.rs:69-83`), a host with zero paired devices still answers only the +//! descriptor and `/pair` (A-2). +//! 3. **No trust headers.** Every inbound `X-RalphX-*` header is stripped before any handler +//! runs, so a remounted :3847 handler that transitively consults `ProjectScope` sees +//! `None`-scope semantics and no header-forgery path exists (§4.4). + +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; + +use axum::extract::{ConnectInfo, Request, State}; +use axum::http::{header, HeaderMap, HeaderName, StatusCode}; +use axum::middleware::Next; +use axum::response::Response; +use chrono::{DateTime, Duration as ChronoDuration, Utc}; +use ralphx_remote_protocol::{ErrorCode, Scope}; + +use crate::domain::entities::{RemoteAuditAction, RemoteDevice, RemoteDeviceId, RemoteScopeSet}; +use crate::domain::repositories::{ + RemoteAuditLogRepository, RemoteDeviceLookup, RemoteDeviceRepository, + RemotePairingCodeRepository, RemoteSessionRepository, RemoteWsTicketRepository, +}; +use crate::domain::services::key_crypto::{generate_prefixed_key, hash_key}; +use crate::infrastructure::sqlite::{DbConnection, SqliteRemoteAccessRepository}; +use crate::remote_server::endpoints::RemoteRouterState; +use crate::remote_server::rate_limit::{auth_endpoint_key, RemoteRateLimitKey, RemoteRateLimiter}; +use crate::remote_server::session_registry::RemoteSessionRegistry; +use crate::remote_server::settings::RemoteExposureMode; +use crate::remote_server::{remote_error_response, PRE_AUTH_ALLOWLIST}; + +/// Device bearer tokens. Greppable and visually distinct from `rxk_live_` (:3848) keys. +pub(crate) const REMOTE_DEVICE_TOKEN_PREFIX: &str = "rxd_live_"; +/// Pairing codes, shown once in the host UI and carried in the `ralphx://pair` URL hash. +pub(crate) const REMOTE_PAIRING_CODE_PREFIX: &str = "rxp_"; +/// WS upgrade tickets. +pub(crate) const REMOTE_WS_TICKET_PREFIX: &str = "rxt_"; + +/// Pairing codes live 10 minutes (§4.2). +pub(crate) const PAIRING_CODE_TTL_SECS: i64 = 600; +/// WS tickets live 60 seconds (§3.1). +pub(crate) const WS_TICKET_TTL_SECS: i64 = 60; + +/// Characters of a device token kept for display, e.g. `rxd_live_a3f2`. +const DEVICE_TOKEN_PREFIX_CHARS: usize = 13; +/// Cap on the audit `detail` column so a hostile URI cannot bloat the log. +const AUDIT_DETAIL_MAX_CHARS: usize = 200; + +/// :3847 trust headers that must never be believed on :3849 (§4.4). +pub(crate) const STRIPPED_TRUST_HEADERS: &[&str] = &[ + "x-ralphx-external-mcp", + "x-ralphx-key-id", + "x-ralphx-project-scope", + "x-ralphx-tauri-mcp", +]; + +/// Defense in depth: the whole vendor header namespace is dropped, not just the four names +/// that exist today, so a future :3847 trust header is stripped the day it is added. +pub(crate) const RALPHX_HEADER_NAMESPACE: &str = "x-ralphx-"; + +/// Fixed-width RFC3339 UTC, so lexicographic order over stored timestamps is chronological +/// order and expiry comparisons can run inside SQL or Rust interchangeably. +pub(crate) fn remote_timestamp(at: DateTime) -> String { + at.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string() +} + +pub(crate) fn now_timestamp() -> String { + remote_timestamp(Utc::now()) +} + +pub(crate) fn expiry_timestamp(from: DateTime, ttl_secs: i64) -> String { + remote_timestamp(from + ChronoDuration::seconds(ttl_secs)) +} + +/// Mints a raw device token. Returned to the client exactly once; only its hash is stored. +pub(crate) fn generate_device_token() -> String { + generate_prefixed_key(REMOTE_DEVICE_TOKEN_PREFIX) +} + +pub(crate) fn generate_pairing_code() -> String { + generate_prefixed_key(REMOTE_PAIRING_CODE_PREFIX) +} + +pub(crate) fn generate_ws_ticket() -> String { + generate_prefixed_key(REMOTE_WS_TICKET_PREFIX) +} + +/// Display prefix persisted alongside the hash, e.g. `rxd_live_a3f2`. +pub(crate) fn device_token_prefix(raw_token: &str) -> String { + raw_token.chars().take(DEVICE_TOKEN_PREFIX_CHARS).collect() +} + +/// The authenticated caller, attached to the request for downstream handlers. +/// +/// Scopes are the ones read from the device row **on this request**, so a host-side toggle +/// takes effect on the next call without re-pairing (§3.1 P-28). +#[derive(Debug, Clone)] +pub(crate) struct RemoteIdentity { + pub device_id: RemoteDeviceId, + pub device_name: String, + pub scopes: RemoteScopeSet, +} + +impl RemoteIdentity { + pub(crate) fn has_scope(&self, scope: Scope) -> bool { + self.scopes.contains(scope) + } + + pub(crate) fn agent_control_granted(&self) -> bool { + self.has_scope(Scope::UiAgent) + } +} + +impl From<&RemoteDevice> for RemoteIdentity { + fn from(device: &RemoteDevice) -> Self { + Self { + device_id: device.id.clone(), + device_name: device.name.clone(), + scopes: device.scopes.clone(), + } + } +} + +/// Every way a remote request can be refused, each with its own status. +/// +/// The split between [`Self::UnknownToken`] and [`Self::StoreUnavailable`] is the point of +/// this enum: collapsing them would let a database outage read as "not paired". +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub(crate) enum RemoteAuthRejection { + #[error("no bearer token was presented")] + MissingBearer, + #[error("the Authorization header is not a well-formed bearer token")] + MalformedBearer, + #[error("the presented device token is not paired with this host")] + UnknownToken, + #[error("the presented device token has been revoked")] + RevokedToken, + #[error("this device does not hold the required scope")] + InsufficientScope(Scope), + #[error("the remote device store is unavailable: {0}")] + StoreUnavailable(String), + #[error("too many requests")] + RateLimited { retry_after_secs: u64 }, + #[error("this device has too many requests in flight")] + TooManyConcurrentRequests, +} + +impl RemoteAuthRejection { + pub(crate) fn status(&self) -> StatusCode { + match self { + Self::MissingBearer + | Self::MalformedBearer + | Self::UnknownToken + | Self::RevokedToken => StatusCode::UNAUTHORIZED, + Self::InsufficientScope(_) => StatusCode::FORBIDDEN, + // A store failure is the host's fault, not the caller's. Answering 401 here + // would let an outage masquerade as a credential problem and would invite a + // client to discard a perfectly good token. + Self::StoreUnavailable(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::RateLimited { .. } | Self::TooManyConcurrentRequests => { + StatusCode::TOO_MANY_REQUESTS + } + } + } + + /// Wire code. The v1 protocol vocabulary (PR 0.2, snapshot-pinned) has no rate-limit or + /// internal-error member, so those reuse the closest existing codes and rely on the HTTP + /// status plus `Retry-After` to carry the distinction. + pub(crate) fn error_code(&self) -> ErrorCode { + match self { + Self::MissingBearer + | Self::MalformedBearer + | Self::UnknownToken + | Self::RevokedToken => ErrorCode::RemoteUnauthorized, + Self::InsufficientScope(_) => ErrorCode::RemoteForbidden, + Self::StoreUnavailable(_) => ErrorCode::RemoteUnreachable, + Self::RateLimited { .. } | Self::TooManyConcurrentRequests => { + ErrorCode::RemoteForbidden + } + } + } + + pub(crate) fn audit_action(&self) -> RemoteAuditAction { + match self { + Self::StoreUnavailable(_) => RemoteAuditAction::AuthStoreError, + Self::RateLimited { .. } | Self::TooManyConcurrentRequests => { + RemoteAuditAction::RateLimited + } + _ => RemoteAuditAction::AuthRejected, + } + } + + /// Whether this rejection counts toward the auth-failure lockout. + /// + /// Store failures and rate limits deliberately do not: a flaky database must not lock the + /// owner out, and counting a rate-limited request would compound the penalty. + pub(crate) fn counts_as_auth_failure(&self) -> bool { + matches!( + self, + Self::MalformedBearer | Self::UnknownToken | Self::RevokedToken + ) + } + + fn retry_after_secs(&self) -> Option { + match self { + Self::RateLimited { retry_after_secs } => Some(*retry_after_secs), + Self::TooManyConcurrentRequests => Some(1), + _ => None, + } + } + + /// Client-facing message. Deliberately uniform across absent/unknown/revoked so the + /// response cannot be used to probe which tokens exist. + fn message(&self) -> String { + match self { + Self::MissingBearer + | Self::MalformedBearer + | Self::UnknownToken + | Self::RevokedToken => "Remote authentication is required.".to_string(), + Self::InsufficientScope(scope) => { + format!("This device is not granted {}.", scope_label(*scope)) + } + Self::StoreUnavailable(_) => { + "The host could not verify this device right now.".to_string() + } + Self::RateLimited { .. } | Self::TooManyConcurrentRequests => { + "Too many remote requests. Retry shortly.".to_string() + } + } + } + + pub(crate) fn into_response(self) -> Response { + let retry_after = self.retry_after_secs(); + let mut response = remote_error_response(self.status(), self.error_code(), self.message()); + if let Some(retry_after) = retry_after { + if let Ok(value) = retry_after.to_string().parse() { + response.headers_mut().insert(header::RETRY_AFTER, value); + } + } + response + } +} + +pub(crate) fn scope_label(scope: Scope) -> &'static str { + match scope { + Scope::UiRead => "ui:read", + Scope::UiOperate => "ui:operate", + Scope::UiAgent => "ui:agent", + Scope::UiElevated => "ui:elevated", + } +} + +/// Everything the remote router needs to authenticate, authorize, and audit. +#[derive(Clone)] +pub(crate) struct RemoteAuthContext { + pub devices: Arc, + pub pairing_codes: Arc, + pub sessions: Arc, + pub tickets: Arc, + pub audit: Arc, + pub registry: RemoteSessionRegistry, + pub limiter: RemoteRateLimiter, + pub exposure_mode: RemoteExposureMode, +} + +impl RemoteAuthContext { + /// Builds the context from one SQLite store shared across all five repository roles. + pub(crate) fn from_db( + db: DbConnection, + registry: RemoteSessionRegistry, + exposure_mode: RemoteExposureMode, + ) -> Self { + let store = Arc::new(SqliteRemoteAccessRepository::from_db(db)); + Self { + devices: store.clone(), + pairing_codes: store.clone(), + sessions: store.clone(), + tickets: store.clone(), + audit: store, + registry, + limiter: RemoteRateLimiter::default(), + exposure_mode, + } + } + + /// Host-local view of the same stores and the same live-session registry. + /// + /// The exposure mode only steers request-path rate-limit keying, which host-local Tauri + /// commands never traverse, so `Serve` is a safe placeholder here. What must be shared is + /// the registry: a revoke command that killed channels in a private copy would report + /// success while the real sessions stayed live. + pub(crate) fn host_local(db: DbConnection, registry: RemoteSessionRegistry) -> Self { + Self::from_db(db, registry, RemoteExposureMode::Serve) + } + + /// Writes an audit row, returning whether it landed. + /// + /// Callers granting access must treat `false` as fatal: authorizing a request whose + /// decision left no trail is exactly the silent-failure this log exists to prevent. + pub(crate) async fn record_audit( + &self, + device_id: Option<&RemoteDeviceId>, + action: RemoteAuditAction, + detail: Option<&str>, + ) -> bool { + let truncated = detail.map(truncate_detail); + match self + .audit + .record(device_id, action, truncated.as_deref(), &now_timestamp()) + .await + { + Ok(()) => true, + Err(error) => { + tracing::error!(%error, ?action, "Remote audit log write failed"); + false + } + } + } + + /// Resolves a raw bearer token to a live device. + pub(crate) async fn resolve_device( + &self, + raw_token: &str, + ) -> Result { + match self + .devices + .lookup_by_token_hash(&hash_key(raw_token)) + .await + { + Ok(RemoteDeviceLookup::Active(device)) => Ok(device), + Ok(RemoteDeviceLookup::Revoked(_)) => Err(RemoteAuthRejection::RevokedToken), + Ok(RemoteDeviceLookup::Unknown) => Err(RemoteAuthRejection::UnknownToken), + Err(error) => Err(RemoteAuthRejection::StoreUnavailable(error.to_string())), + } + } + + /// Tears a device's live sessions down and closes their durable rows. + /// + /// Order is fixed: durable authority (`revoked_at` / narrowed scopes) must already be + /// written by the caller; this is only the effect (§4.4). + pub(crate) async fn tear_down_device_sessions( + &self, + device_id: &RemoteDeviceId, + reason: ralphx_remote_protocol::ResetReason, + ) -> usize { + let signalled = self.registry.kill_device(device_id, reason); + let now = now_timestamp(); + if let Err(error) = self.sessions.close_all_for_device(device_id, &now).await { + tracing::error!(%error, %device_id, "Closing remote session rows failed"); + } + if let Err(error) = self.tickets.consume_all_for_device(device_id, &now).await { + tracing::error!(%error, %device_id, "Invalidating remote ws tickets failed"); + } + signalled + } +} + +fn truncate_detail(detail: &str) -> String { + detail.chars().take(AUDIT_DETAIL_MAX_CHARS).collect() +} + +/// Extracts the raw bearer token, distinguishing "absent" from "malformed". +pub(crate) fn extract_bearer(headers: &HeaderMap) -> Result { + let Some(value) = headers.get(header::AUTHORIZATION) else { + return Err(RemoteAuthRejection::MissingBearer); + }; + let value = value + .to_str() + .map_err(|_| RemoteAuthRejection::MalformedBearer)?; + let (scheme, token) = value + .split_once(' ') + .ok_or(RemoteAuthRejection::MalformedBearer)?; + if !scheme.eq_ignore_ascii_case("bearer") { + return Err(RemoteAuthRejection::MalformedBearer); + } + let token = token.trim(); + if token.is_empty() { + return Err(RemoteAuthRejection::MalformedBearer); + } + Ok(token.to_string()) +} + +/// Enforces a scope on an authenticated identity. +pub(crate) fn require_scope( + identity: &RemoteIdentity, + scope: Scope, +) -> Result<(), RemoteAuthRejection> { + if identity.has_scope(scope) { + Ok(()) + } else { + Err(RemoteAuthRejection::InsufficientScope(scope)) + } +} + +fn peer_address(request: &Request) -> Option { + request + .extensions() + .get::>() + .map(|ConnectInfo(address)| address.ip()) +} + +fn request_detail(request: &Request) -> String { + format!("{} {}", request.method(), request.uri().path()) +} + +// --------------------------------------------------------------------------------------- +// Middleware +// --------------------------------------------------------------------------------------- + +/// Drops every inbound `X-RalphX-*` header before any handler or extractor sees it. +/// +/// Outermost layer on the remote router: even the pre-auth allowlisted routes run behind it. +pub(crate) async fn strip_trust_headers(mut request: Request, next: Next) -> Response { + let headers = request.headers_mut(); + let forged: Vec = headers + .keys() + .filter(|name| name.as_str().starts_with(RALPHX_HEADER_NAMESPACE)) + .cloned() + .collect(); + for name in forged { + headers.remove(&name); + } + for name in STRIPPED_TRUST_HEADERS { + if let Ok(name) = HeaderName::from_bytes(name.as_bytes()) { + headers.remove(&name); + } + } + next.run(request).await +} + +/// Token bucket + lockout ahead of `/remote/v1/auth/*`, before any body is read. +/// +/// The identity is the Serve-aware pre-auth key; the per-pairing-code lockout that keeps two +/// devices independent is applied inside the pair handler, where the code is known. +pub(crate) async fn enforce_auth_endpoint_rate_limit( + State(state): State, + request: Request, + next: Next, +) -> Response { + // Preflight is pre-auth by contract (C-15) and carries no credential to brute-force, so + // it must not spend the auth budget. + if request.method() == axum::http::Method::OPTIONS + || !request.uri().path().starts_with("/remote/v1/auth/") + { + return next.run(request).await; + } + let key = auth_endpoint_key(state.auth().exposure_mode, peer_address(&request)); + let decision = state.auth().limiter.check(&key); + if decision.is_allowed() { + return next.run(request).await; + } + let retry_after_secs = decision.retry_after_secs().unwrap_or(1); + state + .auth() + .record_audit( + None, + RemoteAuditAction::RateLimited, + Some(&request_detail(&request)), + ) + .await; + RemoteAuthRejection::RateLimited { retry_after_secs }.into_response() +} + +/// The global fail-closed bearer check. +/// +/// Preflight and the two pre-auth routes pass through unchanged; everything else must +/// present a live device token, hold a per-device rate-limit token and concurrency slot, and +/// leave an audit row before the handler runs. +pub(crate) async fn authenticate_remote_request( + State(state): State, + mut request: Request, + next: Next, +) -> Response { + if request.method() == axum::http::Method::OPTIONS { + return next.run(request).await; + } + if PRE_AUTH_ALLOWLIST.contains(&request.uri().path()) { + return next.run(request).await; + } + + let auth = state.auth().clone(); + let detail = request_detail(&request); + let peer_key = auth_endpoint_key(auth.exposure_mode, peer_address(&request)); + + let device = match extract_bearer(request.headers()) { + Ok(token) => match auth.resolve_device(&token).await { + Ok(device) => device, + Err(rejection) => return reject(&auth, rejection, None, &detail, &peer_key).await, + }, + Err(rejection) => return reject(&auth, rejection, None, &detail, &peer_key).await, + }; + + let device_key = RemoteRateLimitKey::device(&device.id); + if let Some(retry_after_secs) = auth.limiter.check(&device_key).retry_after_secs() { + return reject( + &auth, + RemoteAuthRejection::RateLimited { retry_after_secs }, + Some(&device.id), + &detail, + &peer_key, + ) + .await; + } + let Some(_slot) = auth.limiter.acquire_device_slot(&device.id) else { + return reject( + &auth, + RemoteAuthRejection::TooManyConcurrentRequests, + Some(&device.id), + &detail, + &peer_key, + ) + .await; + }; + + // Authority is established; record it before the handler can produce any effect. A + // failed audit write refuses the request rather than granting untraceable access. + if !auth + .record_audit( + Some(&device.id), + RemoteAuditAction::AuthAccepted, + Some(&detail), + ) + .await + { + return RemoteAuthRejection::StoreUnavailable("audit log write failed".to_string()) + .into_response(); + } + if let Err(error) = auth + .devices + .touch_last_seen(&device.id, &now_timestamp()) + .await + { + tracing::warn!(%error, device_id = %device.id, "Updating remote device last_seen_at failed"); + } + auth.limiter.record_success(&peer_key); + + request + .extensions_mut() + .insert(RemoteIdentity::from(&device)); + next.run(request).await +} + +async fn reject( + auth: &RemoteAuthContext, + rejection: RemoteAuthRejection, + device_id: Option<&RemoteDeviceId>, + detail: &str, + peer_key: &RemoteRateLimitKey, +) -> Response { + if rejection.counts_as_auth_failure() { + auth.limiter.record_failure(peer_key); + } + let audit_detail = format!("{detail} — {rejection}"); + auth.record_audit(device_id, rejection.audit_action(), Some(&audit_detail)) + .await; + tracing::debug!(%rejection, detail, "Remote request refused"); + rejection.into_response() +} diff --git a/src-tauri/src/remote_server/auth_endpoints.rs b/src-tauri/src/remote_server/auth_endpoints.rs new file mode 100644 index 0000000000..444e19c347 --- /dev/null +++ b/src-tauri/src/remote_server/auth_endpoints.rs @@ -0,0 +1,307 @@ +//! The three authenticated-surface endpoints PR 1.2 owns: pairing, WS tickets, and session +//! introspection/teardown (§3.1). +//! +//! `/remote/v1/auth/pair` is one of exactly two pre-auth routes; it is the only place a +//! device credential is ever minted, and the raw token appears exactly once — in the +//! response body, consumed by the client's Rust backend and stored in the Keychain (§4.2). + +use axum::extract::{Extension, State}; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use chrono::Utc; +use ralphx_remote_protocol::{ErrorCode, ResetReason, Scope, PROTOCOL_VERSION}; +use serde::{Deserialize, Serialize}; + +use crate::domain::entities::{RemoteAuditAction, RemoteDeviceId, RemoteScopeSet}; +use crate::domain::repositories::RemotePairingOutcome; +use crate::domain::services::key_crypto::hash_key; +use crate::remote_server::auth::{ + device_token_prefix, expiry_timestamp, generate_device_token, generate_ws_ticket, + now_timestamp, require_scope, RemoteAuthRejection, RemoteIdentity, WS_TICKET_TTL_SECS, +}; +use crate::remote_server::endpoints::RemoteRouterState; +use crate::remote_server::rate_limit::RemoteRateLimitKey; +use crate::remote_server::remote_error_response; + +/// Longest device name the host will store, so a hostile client cannot bloat the row. +const MAX_DEVICE_NAME_CHARS: usize = 120; +/// Body budget for the auth endpoints; a pairing request is a few hundred bytes. +pub(crate) const REMOTE_AUTH_BODY_LIMIT_BYTES: usize = 8 * 1024; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PairRequest { + pub pairing_code: String, + pub device_name: String, + #[serde(default)] + pub client_version: Option, + /// Must be a subset of the code's grant; absent takes the whole grant (§4.2). + #[serde(default)] + pub requested_scopes: Option>, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PairResponse { + /// The only time the raw token exists outside the client's Keychain. + pub device_token: String, + pub device_id: String, + pub scopes: Vec, + pub environment_id: String, + pub protocol_version: u32, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct WsTicketResponse { + pub ticket: String, + pub expires_in_secs: i64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SessionIntrospection { + pub device_id: String, + pub device_name: String, + /// The **currently effective** grant, re-read on every request — a host-side toggle + /// lands here without the client re-pairing (P-28). + pub scopes: Vec, + pub agent_control_granted: bool, + pub environment_id: String, + pub protocol_version: u32, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct SessionTeardownResponse { + pub closed_sessions: usize, +} + +/// Exchanges a single-use pairing code for a device token. +/// +/// Rate limiting here keys on the **presented code**, not the socket: under Serve every +/// caller is loopback, so a per-IP lockout would let one peer lock every device out (§4.4). +pub(crate) async fn pair_handler( + State(state): State, + Json(request): Json, +) -> Response { + let auth = state.auth().clone(); + let code_key = RemoteRateLimitKey::pairing_code(hash_key(&request.pairing_code)); + + if let Some(retry_after_secs) = auth.limiter.check(&code_key).retry_after_secs() { + auth.record_audit( + None, + RemoteAuditAction::RateLimited, + Some("POST /remote/v1/auth/pair"), + ) + .await; + return RemoteAuthRejection::RateLimited { retry_after_secs }.into_response(); + } + + let device_name = request.device_name.trim(); + if device_name.is_empty() || device_name.chars().count() > MAX_DEVICE_NAME_CHARS { + auth.limiter.record_failure(&code_key); + auth.record_audit( + None, + RemoteAuditAction::PairingRejected, + Some("device name is missing or too long"), + ) + .await; + return remote_error_response( + StatusCode::BAD_REQUEST, + ErrorCode::RemoteForbidden, + "A device name is required.", + ); + } + + let raw_token = generate_device_token(); + let redemption = crate::domain::repositories::RemotePairingRedemption { + code_hash: hash_key(&request.pairing_code), + device_id: RemoteDeviceId::new(), + device_name: device_name.to_string(), + token_hash: hash_key(&raw_token), + token_prefix: device_token_prefix(&raw_token), + requested_scopes: request + .requested_scopes + .clone() + .map(|scopes| RemoteScopeSet::from_scopes(scopes)), + now: now_timestamp(), + }; + + let outcome = match auth.pairing_codes.redeem(redemption).await { + Ok(outcome) => outcome, + Err(error) => { + // A store failure is not a pairing refusal: it must not consume the code's + // failure budget and must not read to the client as a bad code. + auth.record_audit( + None, + RemoteAuditAction::AuthStoreError, + Some(&format!("pair: {error}")), + ) + .await; + return RemoteAuthRejection::StoreUnavailable(error.to_string()).into_response(); + } + }; + + match outcome { + RemotePairingOutcome::Paired(device) => { + auth.limiter.record_success(&code_key); + if !auth + .record_audit( + Some(&device.id), + RemoteAuditAction::PairingSucceeded, + Some(&format!( + "{} ({})", + device.token_prefix, + request + .client_version + .as_deref() + .unwrap_or("unknown client") + )), + ) + .await + { + return RemoteAuthRejection::StoreUnavailable("audit log write failed".to_string()) + .into_response(); + } + tracing::info!(device_id = %device.id, "Remote device paired"); + ( + StatusCode::OK, + Json(PairResponse { + device_token: raw_token, + device_id: device.id.to_string(), + scopes: device.scopes.to_vec(), + environment_id: state.environment_id().to_string(), + protocol_version: PROTOCOL_VERSION, + }), + ) + .into_response() + } + RemotePairingOutcome::ScopeNotGranted(scope) => { + auth.limiter.record_failure(&code_key); + auth.record_audit( + None, + RemoteAuditAction::PairingRejected, + Some("requested scopes exceed the pairing grant"), + ) + .await; + RemoteAuthRejection::InsufficientScope(scope).into_response() + } + rejected => { + auth.limiter.record_failure(&code_key); + auth.record_audit( + None, + RemoteAuditAction::PairingRejected, + Some(&format!("{rejected:?}")), + ) + .await; + // Uniform message: unknown, expired, and already-consumed must be + // indistinguishable so the endpoint is not an oracle for outstanding codes. + remote_error_response( + StatusCode::UNAUTHORIZED, + ErrorCode::RemoteUnauthorized, + "This pairing code is not valid.", + ) + } + } +} + +/// Issues a single-use, device-bound WS upgrade ticket (PR 1.4 consumes it). +pub(crate) async fn ws_ticket_handler( + State(state): State, + Extension(identity): Extension, +) -> Response { + let auth = state.auth().clone(); + // The ticket buys access to the event stream, so it needs the read scope — no more. + if let Err(rejection) = require_scope(&identity, Scope::UiRead) { + auth.record_audit( + Some(&identity.device_id), + RemoteAuditAction::WsTicketRejected, + Some("ui:read is not granted"), + ) + .await; + return rejection.into_response(); + } + + let raw_ticket = generate_ws_ticket(); + let expires_at = expiry_timestamp(Utc::now(), WS_TICKET_TTL_SECS); + if let Err(error) = auth + .tickets + .issue(&hash_key(&raw_ticket), &identity.device_id, &expires_at) + .await + { + auth.record_audit( + Some(&identity.device_id), + RemoteAuditAction::AuthStoreError, + Some(&format!("ws-ticket: {error}")), + ) + .await; + return RemoteAuthRejection::StoreUnavailable(error.to_string()).into_response(); + } + auth.record_audit( + Some(&identity.device_id), + RemoteAuditAction::WsTicketIssued, + None, + ) + .await; + + ( + StatusCode::OK, + Json(WsTicketResponse { + ticket: raw_ticket, + expires_in_secs: WS_TICKET_TTL_SECS, + }), + ) + .into_response() +} + +/// Reports the caller's currently effective grant. +/// +/// Built from the identity the middleware resolved on **this** request, so it reflects +/// host-side toggles immediately and never replays the pair-time mint (P-28). +pub(crate) async fn session_introspection_handler( + State(state): State, + Extension(identity): Extension, +) -> Response { + ( + StatusCode::OK, + Json(SessionIntrospection { + device_id: identity.device_id.to_string(), + device_name: identity.device_name.clone(), + scopes: identity.scopes.to_vec(), + agent_control_granted: identity.agent_control_granted(), + environment_id: state.environment_id().to_string(), + protocol_version: PROTOCOL_VERSION, + }), + ) + .into_response() +} + +/// Ends the caller's own live sessions without revoking the device. +/// +/// PR 1.4 narrows this to the calling WS session once a session id exists at request time; +/// over plain HTTP the caller has no session identity beyond its device. +pub(crate) async fn session_teardown_handler( + State(state): State, + Extension(identity): Extension, +) -> Response { + let auth = state.auth().clone(); + let closed = auth + .tear_down_device_sessions(&identity.device_id, ResetReason::Revoked) + .await; + auth.record_audit( + Some(&identity.device_id), + RemoteAuditAction::SessionClosed, + Some("client requested session teardown"), + ) + .await; + + ( + StatusCode::OK, + Json(SessionTeardownResponse { + closed_sessions: closed, + }), + ) + .into_response() +} diff --git a/src-tauri/src/remote_server/auth_tests.rs b/src-tauri/src/remote_server/auth_tests.rs new file mode 100644 index 0000000000..d27f2929dd --- /dev/null +++ b/src-tauri/src/remote_server/auth_tests.rs @@ -0,0 +1,951 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use axum::{ + body::Body, + http::{header, HeaderMap, Method, Request, StatusCode}, + middleware, + response::Response, + routing::get, + Json, Router, +}; +use ralphx_remote_protocol::{ResetReason, Scope}; +use serde_json::{json, Value}; +use tower::ServiceExt; + +use super::auth::{ + device_token_prefix, expiry_timestamp, generate_device_token, generate_pairing_code, + generate_ws_ticket, now_timestamp, strip_trust_headers, RemoteAuthContext, RemoteAuthRejection, + PAIRING_CODE_TTL_SECS, RALPHX_HEADER_NAMESPACE, REMOTE_DEVICE_TOKEN_PREFIX, + REMOTE_PAIRING_CODE_PREFIX, REMOTE_WS_TICKET_PREFIX, STRIPPED_TRUST_HEADERS, + WS_TICKET_TTL_SECS, +}; +use super::endpoints::RemoteRouterState; +use super::session_registry::{RemoteSessionAdmission, RemoteSessionRegistry}; +use super::settings::RemoteExposureMode; +use super::{ + authenticated_remote_routes, DESCRIPTOR_PATH, HEALTH_PATH, PAIR_PATH, SESSION_PATH, + WS_TICKET_PATH, +}; +use crate::domain::entities::{ + RemoteAuditEntry, RemoteDevice, RemoteDeviceId, RemotePairingCode, RemotePairingCodeId, + RemoteScopeSet, RemoteSession, RemoteSessionId, +}; +use crate::domain::repositories::{ + RemoteDeviceLookup, RemoteDeviceRepository, RemotePairingCodeRepository, RemotePairingOutcome, + RemotePairingRedemption, RemoteWsTicketOutcome, +}; +use crate::domain::services::key_crypto::hash_key; +use crate::error::{AppError, AppResult}; +use crate::infrastructure::sqlite::{run_migrations, DbConnection}; + +const TEST_ENVIRONMENT_ID: &str = "11111111-2222-3333-4444-555555555555"; + +/// A migrated in-memory store plus a fresh registry — enough to serve the whole remote +/// router without touching the filesystem. +pub(super) fn in_memory_auth_context() -> RemoteAuthContext { + let conn = rusqlite::Connection::open_in_memory().expect("in-memory database should open"); + run_migrations(&conn).expect("migrations should apply to the in-memory database"); + RemoteAuthContext::from_db( + DbConnection::new(conn), + RemoteSessionRegistry::new(), + RemoteExposureMode::Serve, + ) +} + +fn router_for(context: &RemoteAuthContext) -> Router { + authenticated_remote_routes(RemoteRouterState::new(TEST_ENVIRONMENT_ID, context.clone())) +} + +async fn body_json(response: Response) -> Value { + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body should be readable"); + serde_json::from_slice(&bytes).unwrap_or(Value::Null) +} + +fn get_with_bearer(path: &str, token: Option<&str>) -> Request { + let mut builder = Request::builder().method(Method::GET).uri(path); + if let Some(token) = token { + builder = builder.header(header::AUTHORIZATION, format!("Bearer {token}")); + } + builder.body(Body::empty()).expect("request should build") +} + +fn post_json(path: &str, token: Option<&str>, body: Value) -> Request { + let mut builder = Request::builder() + .method(Method::POST) + .uri(path) + .header(header::CONTENT_TYPE, "application/json"); + if let Some(token) = token { + builder = builder.header(header::AUTHORIZATION, format!("Bearer {token}")); + } + builder + .body(Body::from(body.to_string())) + .expect("request should build") +} + +/// Mints a pairing code the way `generate_remote_pairing_code` does. +async fn mint_pairing_code(context: &RemoteAuthContext, scopes: RemoteScopeSet) -> String { + let raw = generate_pairing_code(); + context + .pairing_codes + .create(RemotePairingCode { + id: RemotePairingCodeId::new(), + code_hash: hash_key(&raw), + scopes, + created_at: now_timestamp(), + expires_at: expiry_timestamp(chrono::Utc::now(), PAIRING_CODE_TTL_SECS), + consumed_at: None, + }) + .await + .expect("pairing code should insert"); + raw +} + +/// Pairs a device through the real HTTP surface and returns its raw token. +async fn pair_device(context: &RemoteAuthContext, name: &str) -> (String, RemoteDeviceId) { + let code = mint_pairing_code(context, RemoteScopeSet::default_pairing_grant()).await; + let response = router_for(context) + .oneshot(post_json( + PAIR_PATH, + None, + json!({"pairingCode": code, "deviceName": name, "clientVersion": "0.81.0"}), + )) + .await + .expect("pair request should complete"); + assert_eq!(response.status(), StatusCode::OK, "pairing should succeed"); + let body = body_json(response).await; + ( + body["deviceToken"] + .as_str() + .expect("a device token is returned") + .to_string(), + RemoteDeviceId::from_string( + body["deviceId"] + .as_str() + .expect("a device id is returned") + .to_string(), + ), + ) +} + +async fn audit_actions(context: &RemoteAuthContext) -> Vec { + context + .audit + .list_recent(Some(200)) + .await + .expect("audit log should read") + .into_iter() + .map(|entry: RemoteAuditEntry| entry.action) + .collect() +} + +/// A device store whose every read fails — stands in for a locked or corrupted database. +struct FailingDeviceRepository; + +#[async_trait] +impl RemoteDeviceRepository for FailingDeviceRepository { + async fn lookup_by_token_hash(&self, _token_hash: &str) -> AppResult { + Err(AppError::Database("database is locked".to_string())) + } + + async fn get(&self, _id: &RemoteDeviceId) -> AppResult> { + Err(AppError::Database("database is locked".to_string())) + } + + async fn list(&self) -> AppResult> { + Err(AppError::Database("database is locked".to_string())) + } + + async fn revoke(&self, _id: &RemoteDeviceId, _now: &str) -> AppResult> { + Err(AppError::Database("database is locked".to_string())) + } + + async fn set_scopes( + &self, + _id: &RemoteDeviceId, + _scopes: &RemoteScopeSet, + ) -> AppResult> { + Err(AppError::Database("database is locked".to_string())) + } + + async fn touch_last_seen(&self, _id: &RemoteDeviceId, _now: &str) -> AppResult<()> { + Err(AppError::Database("database is locked".to_string())) + } +} + +/// A pairing-code store whose every call fails. +struct FailingPairingCodeRepository; + +#[async_trait] +impl RemotePairingCodeRepository for FailingPairingCodeRepository { + async fn create(&self, _code: RemotePairingCode) -> AppResult { + Err(AppError::Database("database is locked".to_string())) + } + + async fn redeem( + &self, + _redemption: RemotePairingRedemption, + ) -> AppResult { + Err(AppError::Database("database is locked".to_string())) + } + + async fn list_outstanding(&self, _now: &str) -> AppResult> { + Err(AppError::Database("database is locked".to_string())) + } + + async fn cancel(&self, _id: &RemotePairingCodeId, _now: &str) -> AppResult { + Err(AppError::Database("database is locked".to_string())) + } +} + +// --------------------------------------------------------------------------------------- +// Credential shape +// --------------------------------------------------------------------------------------- + +#[test] +fn remote_credentials_use_distinct_greppable_prefixes() { + let token = generate_device_token(); + let code = generate_pairing_code(); + let ticket = generate_ws_ticket(); + + assert!(token.starts_with(REMOTE_DEVICE_TOKEN_PREFIX)); + assert!(code.starts_with(REMOTE_PAIRING_CODE_PREFIX)); + assert!(ticket.starts_with(REMOTE_WS_TICKET_PREFIX)); + assert_eq!(token.len(), REMOTE_DEVICE_TOKEN_PREFIX.len() + 32); + assert_ne!(token, generate_device_token(), "tokens must not repeat"); + assert!( + !token.starts_with("rxk_live_"), + "remote device tokens must be distinguishable from :3848 api keys" + ); + assert_eq!(device_token_prefix(&token), token[..13].to_string()); +} + +// --------------------------------------------------------------------------------------- +// P-8: fail-closed middleware +// --------------------------------------------------------------------------------------- + +/// Absent, malformed, unknown, and revoked are all 401 — and all say the same thing, so the +/// endpoint is not an oracle for which tokens exist. +#[tokio::test] +async fn every_bad_bearer_shape_is_refused_with_401() { + let context = in_memory_auth_context(); + let (token, device_id) = pair_device(&context, "laptop").await; + context + .devices + .revoke(&device_id, &now_timestamp()) + .await + .expect("revoke should succeed"); + + let mut observed = Vec::new(); + for request in [ + get_with_bearer(SESSION_PATH, None), + Request::builder() + .method(Method::GET) + .uri(SESSION_PATH) + .header(header::AUTHORIZATION, "Basic abc123") + .body(Body::empty()) + .expect("request should build"), + Request::builder() + .method(Method::GET) + .uri(SESSION_PATH) + .header(header::AUTHORIZATION, "Bearer ") + .body(Body::empty()) + .expect("request should build"), + get_with_bearer(SESSION_PATH, Some("rxd_live_notarealtokenatallnotarealto")), + get_with_bearer(SESSION_PATH, Some(&token)), + ] { + let response = router_for(&context) + .oneshot(request) + .await + .expect("request should complete"); + let status = response.status(); + let body = body_json(response).await; + observed.push((status, body["code"].clone(), body["message"].clone())); + } + + for (status, code, message) in &observed { + assert_eq!(*status, StatusCode::UNAUTHORIZED); + assert_eq!(*code, Value::from("REMOTE_UNAUTHORIZED")); + assert_eq!(*message, observed[0].2, "messages must not leak which case"); + } +} + +/// The heart of the fail-closed contract: a store failure is 500, never a 401 that would let +/// an outage look like "this token is not paired". +#[tokio::test] +async fn a_device_store_failure_answers_500_not_401() { + let mut context = in_memory_auth_context(); + context.devices = Arc::new(FailingDeviceRepository); + + let response = router_for(&context) + .oneshot(get_with_bearer(SESSION_PATH, Some("rxd_live_anything"))) + .await + .expect("request should complete"); + + let status = response.status(); + let body = body_json(response).await; + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert_ne!( + status, + StatusCode::UNAUTHORIZED, + "a store outage must never be reported as an auth failure" + ); + assert_eq!(body["code"], Value::from("REMOTE_UNREACHABLE")); +} + +/// The three rejection classes are distinct values, not one collapsed variant. +#[test] +fn absent_invalid_and_store_error_are_separate_typed_rejections() { + let absent = RemoteAuthRejection::MissingBearer; + let invalid = RemoteAuthRejection::UnknownToken; + let store = RemoteAuthRejection::StoreUnavailable("locked".to_string()); + + assert_ne!(absent, invalid); + assert_ne!(invalid, store); + assert_eq!(absent.status(), StatusCode::UNAUTHORIZED); + assert_eq!(invalid.status(), StatusCode::UNAUTHORIZED); + assert_eq!(store.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!(invalid.counts_as_auth_failure()); + assert!( + !store.counts_as_auth_failure(), + "a flaky store must not lock the owner out" + ); + assert!(!absent.counts_as_auth_failure()); +} + +/// A-2: a host with zero paired devices answers only the descriptor and `/pair`. +#[tokio::test] +async fn a_host_with_no_paired_devices_still_refuses_every_other_route() { + let context = in_memory_auth_context(); + let router = router_for(&context); + + let descriptor = router + .clone() + .oneshot(get_with_bearer(DESCRIPTOR_PATH, None)) + .await + .expect("descriptor request should complete"); + let health = router + .clone() + .oneshot(get_with_bearer(HEALTH_PATH, None)) + .await + .expect("health request should complete"); + let session = router + .oneshot(get_with_bearer(SESSION_PATH, None)) + .await + .expect("session request should complete"); + + assert_eq!(descriptor.status(), StatusCode::OK); + assert_eq!(health.status(), StatusCode::UNAUTHORIZED); + assert_eq!(session.status(), StatusCode::UNAUTHORIZED); +} + +// --------------------------------------------------------------------------------------- +// Trust-header stripping +// --------------------------------------------------------------------------------------- + +async fn echo_header_names(headers: HeaderMap) -> Json> { + Json(headers.keys().map(|name| name.to_string()).collect()) +} + +#[tokio::test] +async fn every_ralphx_trust_header_is_stripped_before_any_handler_runs() { + let router = Router::new() + .route("/echo", get(echo_header_names)) + .layer(middleware::from_fn(strip_trust_headers)); + let mut request = Request::builder().method(Method::GET).uri("/echo"); + for name in STRIPPED_TRUST_HEADERS { + request = request.header(*name, "1"); + } + let request = request + .header("x-ralphx-some-future-header", "1") + .header("x-forwarded-for", "100.64.0.9") + .body(Body::empty()) + .expect("request should build"); + + let response = router + .oneshot(request) + .await + .expect("echo request should complete"); + + let seen: Vec = + serde_json::from_value(body_json(response).await).expect("header names should parse"); + for name in STRIPPED_TRUST_HEADERS { + assert!(!seen.iter().any(|header| header == name), "{name} leaked"); + } + assert!( + !seen + .iter() + .any(|header| header.starts_with(RALPHX_HEADER_NAMESPACE)), + "the whole vendor header namespace must be dropped: {seen:?}" + ); + assert!( + seen.iter().any(|header| header == "x-forwarded-for"), + "unrelated headers must survive" + ); +} + +/// Acceptance: `X-RalphX-Tauri-MCP: 1` buys nothing on :3849 — there is no `tauri-local` +/// identity path to reach (feeds P-1). +#[tokio::test] +async fn a_forged_tauri_local_trust_header_still_gets_401() { + let context = in_memory_auth_context(); + let request = Request::builder() + .method(Method::GET) + .uri(SESSION_PATH) + .header("x-ralphx-tauri-mcp", "1") + .header("x-ralphx-external-mcp", "1") + .header("x-ralphx-key-id", "any-key") + .body(Body::empty()) + .expect("request should build"); + + let response = router_for(&context) + .oneshot(request) + .await + .expect("request should complete"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +// --------------------------------------------------------------------------------------- +// Pairing +// --------------------------------------------------------------------------------------- + +#[tokio::test] +async fn pairing_returns_the_token_once_and_grants_read_plus_operate_without_agent_control() { + let context = in_memory_auth_context(); + + let (token, device_id) = pair_device(&context, "laptop").await; + + let device = context + .devices + .get(&device_id) + .await + .expect("device should read") + .expect("device should exist"); + assert!(token.starts_with(REMOTE_DEVICE_TOKEN_PREFIX)); + assert_eq!(device.scopes, RemoteScopeSet::default_pairing_grant()); + assert!(!device.agent_control_granted()); + assert_eq!(device.token_hash, hash_key(&token)); + assert_ne!(device.token_hash, token); + assert!(audit_actions(&context) + .await + .contains(&"pairing_succeeded".to_string())); +} + +#[tokio::test] +async fn a_replayed_pairing_code_is_refused_and_mints_no_second_device() { + let context = in_memory_auth_context(); + let code = mint_pairing_code(&context, RemoteScopeSet::default_pairing_grant()).await; + let request = + || json!({"pairingCode": code, "deviceName": "laptop", "clientVersion": "0.81.0"}); + + let first = router_for(&context) + .oneshot(post_json(PAIR_PATH, None, request())) + .await + .expect("first pair should complete"); + let replay = router_for(&context) + .oneshot(post_json(PAIR_PATH, None, request())) + .await + .expect("replay should complete"); + + assert_eq!(first.status(), StatusCode::OK); + assert_eq!(replay.status(), StatusCode::UNAUTHORIZED); + assert_eq!( + context + .devices + .list() + .await + .expect("devices should read") + .len(), + 1 + ); +} + +#[tokio::test] +async fn a_pairing_request_may_not_ask_for_more_than_the_code_grants() { + let context = in_memory_auth_context(); + let code = mint_pairing_code(&context, RemoteScopeSet::from_scopes([Scope::UiRead])).await; + + let response = router_for(&context) + .oneshot(post_json( + PAIR_PATH, + None, + json!({ + "pairingCode": code, + "deviceName": "laptop", + "requestedScopes": ["ui:read", "ui:agent"], + }), + )) + .await + .expect("pair request should complete"); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert!(context + .devices + .list() + .await + .expect("devices should read") + .is_empty()); +} + +#[tokio::test] +async fn a_pairing_request_may_narrow_its_own_grant() { + let context = in_memory_auth_context(); + let code = mint_pairing_code(&context, RemoteScopeSet::default_pairing_grant()).await; + + let response = router_for(&context) + .oneshot(post_json( + PAIR_PATH, + None, + json!({ + "pairingCode": code, + "deviceName": "read-only tablet", + "requestedScopes": ["ui:read"], + }), + )) + .await + .expect("pair request should complete"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(body_json(response).await["scopes"], json!(["ui:read"])); +} + +/// Acceptance: the 6th failed pair attempt inside the window is rate limited. +#[tokio::test] +async fn the_sixth_failed_pair_attempt_is_rate_limited() { + let context = in_memory_auth_context(); + let bad_code = "rxp_thiscodewasnevermintedbyanyhost"; + + let mut statuses = Vec::new(); + for _ in 0..6 { + let response = router_for(&context) + .oneshot(post_json( + PAIR_PATH, + None, + json!({"pairingCode": bad_code, "deviceName": "attacker"}), + )) + .await + .expect("pair attempt should complete"); + statuses.push(response.status()); + } + + assert_eq!( + statuses[..5], + [StatusCode::UNAUTHORIZED; 5], + "the first five attempts are ordinary refusals: {statuses:?}" + ); + assert_eq!(statuses[5], StatusCode::TOO_MANY_REQUESTS); +} + +/// P-8: under Serve one peer's lockout must not reach another device's pairing attempt. +#[tokio::test] +async fn a_locked_out_pairing_code_does_not_lock_out_a_different_code() { + let context = in_memory_auth_context(); + let attacked = "rxp_thiscodewasnevermintedbyanyhost"; + for _ in 0..6 { + router_for(&context) + .oneshot(post_json( + PAIR_PATH, + None, + json!({"pairingCode": attacked, "deviceName": "attacker"}), + )) + .await + .expect("pair attempt should complete"); + } + let owner_code = mint_pairing_code(&context, RemoteScopeSet::default_pairing_grant()).await; + + let response = router_for(&context) + .oneshot(post_json( + PAIR_PATH, + None, + json!({"pairingCode": owner_code, "deviceName": "owner phone"}), + )) + .await + .expect("owner pair should complete"); + + assert_eq!( + response.status(), + StatusCode::OK, + "the owner's own device must still be able to pair" + ); +} + +/// A pairing attempt that fails because the *store* failed must not read as a bad code, and +/// must leave the code redeemable. +#[tokio::test] +async fn a_pairing_store_failure_is_500_and_leaves_the_code_redeemable() { + let context = in_memory_auth_context(); + let code = mint_pairing_code(&context, RemoteScopeSet::default_pairing_grant()).await; + let mut broken = context.clone(); + broken.pairing_codes = Arc::new(FailingPairingCodeRepository); + + let failed = router_for(&broken) + .oneshot(post_json( + PAIR_PATH, + None, + json!({"pairingCode": code, "deviceName": "laptop"}), + )) + .await + .expect("pair request should complete"); + let recovered = router_for(&context) + .oneshot(post_json( + PAIR_PATH, + None, + json!({"pairingCode": code, "deviceName": "laptop"}), + )) + .await + .expect("pair request should complete"); + + assert_eq!(failed.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(recovered.status(), StatusCode::OK); +} + +// --------------------------------------------------------------------------------------- +// WS tickets +// --------------------------------------------------------------------------------------- + +#[tokio::test] +async fn ws_tickets_are_issued_to_authenticated_devices_and_consumed_once() { + let context = in_memory_auth_context(); + let (token, device_id) = pair_device(&context, "laptop").await; + + let response = router_for(&context) + .oneshot(post_json(WS_TICKET_PATH, Some(&token), json!({}))) + .await + .expect("ws-ticket request should complete"); + + assert_eq!(response.status(), StatusCode::OK); + let body = body_json(response).await; + let ticket = body["ticket"] + .as_str() + .expect("a ticket is returned") + .to_string(); + assert!(ticket.starts_with(REMOTE_WS_TICKET_PREFIX)); + assert_eq!(body["expiresInSecs"], json!(WS_TICKET_TTL_SECS)); + + let first = context + .tickets + .consume(&hash_key(&ticket), &now_timestamp()) + .await + .expect("consume should complete"); + let replay = context + .tickets + .consume(&hash_key(&ticket), &now_timestamp()) + .await + .expect("replay should complete"); + assert_eq!(first, RemoteWsTicketOutcome::Consumed(device_id)); + assert_eq!(replay, RemoteWsTicketOutcome::AlreadyConsumed); +} + +#[tokio::test] +async fn an_expired_or_unknown_ws_ticket_is_never_consumable() { + let context = in_memory_auth_context(); + let (_, device_id) = pair_device(&context, "laptop").await; + let stale = generate_ws_ticket(); + context + .tickets + .issue( + &hash_key(&stale), + &device_id, + &expiry_timestamp(chrono::Utc::now() - chrono::Duration::seconds(120), 60), + ) + .await + .expect("ticket should issue"); + + let expired = context + .tickets + .consume(&hash_key(&stale), &now_timestamp()) + .await + .expect("consume should complete"); + let unknown = context + .tickets + .consume(&hash_key("rxt_neverissued"), &now_timestamp()) + .await + .expect("consume should complete"); + + assert_eq!(expired, RemoteWsTicketOutcome::Expired); + assert_eq!(unknown, RemoteWsTicketOutcome::Unknown); +} + +#[tokio::test] +async fn an_unauthenticated_caller_cannot_mint_a_ws_ticket() { + let context = in_memory_auth_context(); + + let response = router_for(&context) + .oneshot(post_json(WS_TICKET_PATH, None, json!({}))) + .await + .expect("ws-ticket request should complete"); + + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + +// --------------------------------------------------------------------------------------- +// P-28 host half: agent control +// --------------------------------------------------------------------------------------- + +async fn session_introspection(context: &RemoteAuthContext, token: &str) -> Value { + let response = router_for(context) + .oneshot(get_with_bearer(SESSION_PATH, Some(token))) + .await + .expect("session request should complete"); + assert_eq!(response.status(), StatusCode::OK); + body_json(response).await +} + +#[tokio::test] +async fn agent_control_is_off_by_default_and_toggles_without_re_pairing() { + let context = in_memory_auth_context(); + let (token, device_id) = pair_device(&context, "laptop").await; + + let paired = session_introspection(&context, &token).await; + let device = context + .devices + .get(&device_id) + .await + .expect("device should read") + .expect("device should exist"); + context + .devices + .set_scopes(&device_id, &device.scopes.with(Scope::UiAgent)) + .await + .expect("grant should apply"); + let granted = session_introspection(&context, &token).await; + context + .devices + .set_scopes(&device_id, &device.scopes.without(Scope::UiAgent)) + .await + .expect("narrow should apply"); + let narrowed = session_introspection(&context, &token).await; + + assert_eq!(paired["agentControlGranted"], json!(false)); + assert_eq!(paired["scopes"], json!(["ui:read", "ui:operate"])); + assert_eq!(granted["agentControlGranted"], json!(true)); + assert_eq!( + granted["scopes"], + json!(["ui:read", "ui:operate", "ui:agent"]) + ); + assert_eq!(narrowed["agentControlGranted"], json!(false)); + assert_eq!(narrowed["scopes"], json!(["ui:read", "ui:operate"])); +} + +/// Narrowing the grant must also fire the device's kill channels — introspection alone is +/// not teardown. +#[tokio::test] +async fn withdrawing_agent_control_tears_the_devices_live_sessions_down() { + let context = in_memory_auth_context(); + let (_, device_id) = pair_device(&context, "laptop").await; + let session_id = RemoteSessionId::new(); + let RemoteSessionAdmission::Admitted(mut kill) = + context.registry.register(&device_id, &session_id) + else { + panic!("the first session should be admitted"); + }; + context + .sessions + .open(RemoteSession { + id: session_id.clone(), + device_id: device_id.clone(), + connected_at: now_timestamp(), + last_active_at: now_timestamp(), + remote_addr: "127.0.0.1:51000".to_string(), + closed_at: None, + }) + .await + .expect("session row should open"); + + let device = context + .devices + .get(&device_id) + .await + .expect("device should read") + .expect("device should exist"); + context + .devices + .set_scopes(&device_id, &device.scopes.without(Scope::UiAgent)) + .await + .expect("narrow should apply"); + let torn_down = context + .tear_down_device_sessions(&device_id, ResetReason::Revoked) + .await; + + assert_eq!(torn_down, 1); + assert_eq!(kill.try_recv(), Some(ResetReason::Revoked)); + assert_eq!(context.registry.device_session_count(&device_id), 0); + assert!(context + .sessions + .list_open() + .await + .expect("sessions should read") + .is_empty()); +} + +#[tokio::test] +async fn revoking_a_device_refuses_its_next_request_and_kills_its_sessions() { + let context = in_memory_auth_context(); + let (token, device_id) = pair_device(&context, "laptop").await; + let RemoteSessionAdmission::Admitted(mut kill) = context + .registry + .register(&device_id, &RemoteSessionId::new()) + else { + panic!("the first session should be admitted"); + }; + + context + .devices + .revoke(&device_id, &now_timestamp()) + .await + .expect("revoke should succeed"); + let torn_down = context + .tear_down_device_sessions(&device_id, ResetReason::Revoked) + .await; + let after = router_for(&context) + .oneshot(get_with_bearer(SESSION_PATH, Some(&token))) + .await + .expect("request should complete"); + + assert_eq!(torn_down, 1); + assert_eq!(kill.try_recv(), Some(ResetReason::Revoked)); + assert_eq!(after.status(), StatusCode::UNAUTHORIZED); +} + +/// Revocation must also burn the device's outstanding upgrade tickets, or a ticket minted +/// seconds earlier would still buy a socket. +#[tokio::test] +async fn revocation_invalidates_outstanding_ws_tickets() { + let context = in_memory_auth_context(); + let (token, device_id) = pair_device(&context, "laptop").await; + let ticket_response = router_for(&context) + .oneshot(post_json(WS_TICKET_PATH, Some(&token), json!({}))) + .await + .expect("ws-ticket request should complete"); + let ticket = body_json(ticket_response).await["ticket"] + .as_str() + .expect("a ticket is returned") + .to_string(); + + context + .devices + .revoke(&device_id, &now_timestamp()) + .await + .expect("revoke should succeed"); + context + .tear_down_device_sessions(&device_id, ResetReason::Revoked) + .await; + + assert_eq!( + context + .tickets + .consume(&hash_key(&ticket), &now_timestamp()) + .await + .expect("consume should complete"), + RemoteWsTicketOutcome::AlreadyConsumed + ); +} + +#[tokio::test] +async fn deleting_the_session_closes_the_callers_own_sessions_only() { + let context = in_memory_auth_context(); + let (token, device_id) = pair_device(&context, "laptop").await; + let (_, bystander_id) = pair_device(&context, "phone").await; + let RemoteSessionAdmission::Admitted(mut kill) = context + .registry + .register(&device_id, &RemoteSessionId::new()) + else { + panic!("the session should be admitted"); + }; + let RemoteSessionAdmission::Admitted(mut bystander_kill) = context + .registry + .register(&bystander_id, &RemoteSessionId::new()) + else { + panic!("the bystander session should be admitted"); + }; + + let response = router_for(&context) + .oneshot( + Request::builder() + .method(Method::DELETE) + .uri(SESSION_PATH) + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .body(Body::empty()) + .expect("request should build"), + ) + .await + .expect("delete should complete"); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(body_json(response).await["closedSessions"], json!(1)); + assert_eq!(kill.try_recv(), Some(ResetReason::Revoked)); + assert_eq!( + bystander_kill.try_recv(), + None, + "another device's session must survive" + ); +} + +// --------------------------------------------------------------------------------------- +// Audit trail +// --------------------------------------------------------------------------------------- + +#[tokio::test] +async fn every_auth_decision_leaves_an_audit_row() { + let context = in_memory_auth_context(); + let (token, _) = pair_device(&context, "laptop").await; + + router_for(&context) + .oneshot(get_with_bearer(SESSION_PATH, Some(&token))) + .await + .expect("authenticated request should complete"); + router_for(&context) + .oneshot(get_with_bearer(SESSION_PATH, Some("rxd_live_wrongtoken"))) + .await + .expect("rejected request should complete"); + router_for(&context) + .oneshot(post_json( + PAIR_PATH, + None, + json!({"pairingCode": "rxp_nope", "deviceName": "attacker"}), + )) + .await + .expect("rejected pairing should complete"); + + let actions = audit_actions(&context).await; + for expected in [ + "pairing_succeeded", + "auth_accepted", + "auth_rejected", + "pairing_rejected", + ] { + assert!( + actions.contains(&expected.to_string()), + "{expected} should be audited: {actions:?}" + ); + } +} + +#[tokio::test] +async fn an_authenticated_request_updates_last_seen_at() { + let context = in_memory_auth_context(); + let (token, device_id) = pair_device(&context, "laptop").await; + let before = context + .devices + .get(&device_id) + .await + .expect("device should read") + .expect("device should exist"); + + router_for(&context) + .oneshot(get_with_bearer(SESSION_PATH, Some(&token))) + .await + .expect("authenticated request should complete"); + + let after = context + .devices + .get(&device_id) + .await + .expect("device should read") + .expect("device should exist"); + assert!(before.last_seen_at.is_none()); + assert!(after.last_seen_at.is_some()); +} diff --git a/src-tauri/src/remote_server/endpoints.rs b/src-tauri/src/remote_server/endpoints.rs index b30ff77495..0063d7f969 100644 --- a/src-tauri/src/remote_server/endpoints.rs +++ b/src-tauri/src/remote_server/endpoints.rs @@ -9,6 +9,8 @@ use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; use ralphx_remote_protocol::{EnvironmentDescriptor, PROTOCOL_VERSION}; use serde::Serialize; +use crate::remote_server::auth::RemoteAuthContext; + /// Oldest client protocol this host will negotiate with. /// /// Host acceptance policy, not protocol shape — it lives here rather than in the protocol @@ -16,21 +18,30 @@ use serde::Serialize; pub(crate) const MIN_CLIENT_PROTOCOL: u32 = PROTOCOL_VERSION; /// Shared state for the remote router. +/// +/// The auth context is **not** optional: there is no router shape that can serve a +/// non-allowlisted route without a device store to check against (A-2). #[derive(Clone)] pub(crate) struct RemoteRouterState { environment_id: Arc, + auth: Arc, } impl RemoteRouterState { - pub(crate) fn new(environment_id: impl Into>) -> Self { + pub(crate) fn new(environment_id: impl Into>, auth: RemoteAuthContext) -> Self { Self { environment_id: environment_id.into(), + auth: Arc::new(auth), } } pub(crate) fn environment_id(&self) -> &str { &self.environment_id } + + pub(crate) fn auth(&self) -> &RemoteAuthContext { + &self.auth + } } #[derive(Debug, Clone, Serialize)] diff --git a/src-tauri/src/remote_server/listener_tests.rs b/src-tauri/src/remote_server/listener_tests.rs index f252a2c9af..ee776aa309 100644 --- a/src-tauri/src/remote_server/listener_tests.rs +++ b/src-tauri/src/remote_server/listener_tests.rs @@ -35,7 +35,10 @@ async fn response_body(response: axum::response::Response) -> Value { } fn descriptor_state() -> RemoteRouterState { - RemoteRouterState::new("11111111-2222-3333-4444-555555555555") + RemoteRouterState::new( + "11111111-2222-3333-4444-555555555555", + super::auth_tests::in_memory_auth_context(), + ) } fn preflight_request(path: &str, origin: &str) -> Request { diff --git a/src-tauri/src/remote_server/mod.rs b/src-tauri/src/remote_server/mod.rs index 253c51bc35..833f37d126 100644 --- a/src-tauri/src/remote_server/mod.rs +++ b/src-tauri/src/remote_server/mod.rs @@ -5,10 +5,20 @@ //! pre-auth allowlist, binds only loopback or a validated tailnet address, and never mounts a //! :3847 trust-header handler (§2.3, §4.4). +pub mod auth; +pub mod auth_endpoints; +#[cfg(test)] +mod auth_tests; pub mod capture; pub mod endpoints; #[cfg(test)] mod listener_tests; +pub mod rate_limit; +#[cfg(test)] +mod rate_limit_tests; +pub mod session_registry; +#[cfg(test)] +mod session_registry_tests; pub mod settings; #[cfg(test)] mod settings_tests; @@ -21,14 +31,14 @@ use std::net::SocketAddr; use std::sync::Arc; use axum::{ - extract::Request, + extract::DefaultBodyLimit, http::{header, HeaderValue, Method, StatusCode}, - middleware::{self, Next}, + middleware, response::{IntoResponse, Response}, - routing::get, + routing::{get, post}, Json, Router, }; -use ralphx_remote_protocol::ErrorCode; +use ralphx_remote_protocol::{ErrorCode, ResetReason}; use serde::Serialize; use tauri::Manager; use tokio::net::TcpListener; @@ -37,9 +47,18 @@ use tokio_util::sync::CancellationToken; use tower_http::cors::{AllowOrigin, CorsLayer}; use crate::error::AppError; +use crate::remote_server::auth::{ + authenticate_remote_request, enforce_auth_endpoint_rate_limit, strip_trust_headers, + RemoteAuthContext, +}; +use crate::remote_server::auth_endpoints::{ + pair_handler, session_introspection_handler, session_teardown_handler, ws_ticket_handler, + REMOTE_AUTH_BODY_LIMIT_BYTES, +}; use crate::remote_server::endpoints::{ environment_descriptor_handler, health_handler, RemoteRouterState, }; +use crate::remote_server::session_registry::RemoteSessionRegistry; use crate::remote_server::settings::{ effective_remote_port, resolve_bind_address, RemoteBindError, RemoteExposureMode, RemoteHostSettings, RemoteHostSettingsStore, TailnetSelfAddressProvider, @@ -48,12 +67,14 @@ use crate::remote_server::settings::{ pub(crate) const DESCRIPTOR_PATH: &str = "/.well-known/ralphx/environment"; pub(crate) const PAIR_PATH: &str = "/remote/v1/auth/pair"; +pub(crate) const WS_TICKET_PATH: &str = "/remote/v1/auth/ws-ticket"; +pub(crate) const SESSION_PATH: &str = "/remote/v1/session"; pub(crate) const HEALTH_PATH: &str = "/health"; /// Routes reachable before the bearer check. /// -/// Exactly two: discovery and pairing. PR 1.2 replaces [`remote_auth_slot`]'s body with real -/// bearer verification but must keep this allowlist unchanged (§4.4, A-2). +/// Exactly two: discovery and pairing. Everything else — including `/health` — runs behind +/// [`authenticate_remote_request`]; there is no zero-devices bootstrap pass (§4.4, A-2). pub(crate) const PRE_AUTH_ALLOWLIST: &[&str] = &[DESCRIPTOR_PATH, PAIR_PATH]; /// Origins the shipped app itself uses. @@ -113,15 +134,24 @@ struct ActiveRemoteListener { #[derive(Clone)] pub(crate) struct RemoteListenerHandle { active: Arc>>, + /// Lives on the handle, not inside the router, so it survives listener restarts and is + /// reachable from the host-local revoke commands (§4.4). + sessions: RemoteSessionRegistry, } impl RemoteListenerHandle { pub(crate) fn new() -> Self { Self { active: Arc::new(Mutex::new(None)), + sessions: RemoteSessionRegistry::new(), } } + /// The process-wide live-session registry. + pub(crate) fn sessions(&self) -> &RemoteSessionRegistry { + &self.sessions + } + pub(crate) async fn bound_address(&self) -> Option { self.active .lock() @@ -169,12 +199,40 @@ pub(crate) fn authenticated_remote_routes(state: RemoteRouterState) -> Router { DESCRIPTOR_PATH, get(environment_descriptor_handler).options(remote_preflight_handler), ) + .route( + PAIR_PATH, + post(pair_handler) + .options(remote_preflight_handler) + .layer(DefaultBodyLimit::max(REMOTE_AUTH_BODY_LIMIT_BYTES)), + ) + .route( + WS_TICKET_PATH, + post(ws_ticket_handler) + .options(remote_preflight_handler) + .layer(DefaultBodyLimit::max(REMOTE_AUTH_BODY_LIMIT_BYTES)), + ) + .route( + SESSION_PATH, + get(session_introspection_handler) + .delete(session_teardown_handler) + .options(remote_preflight_handler), + ) .route( HEALTH_PATH, get(health_handler).options(remote_preflight_handler), ) .fallback(remote_fallback_handler) - .layer(middleware::from_fn(remote_auth_slot)) + // Layers apply outermost-last: trust headers are stripped before anything else + // runs, then pre-auth flood control, then the bearer check. + .layer(middleware::from_fn_with_state( + state.clone(), + authenticate_remote_request, + )) + .layer(middleware::from_fn_with_state( + state.clone(), + enforce_auth_endpoint_rate_limit, + )) + .layer(middleware::from_fn(strip_trust_headers)) .with_state(state) } @@ -190,24 +248,6 @@ fn remote_cors_layer() -> CorsLayer { .allow_headers([header::AUTHORIZATION, header::CONTENT_TYPE]) } -/// Global fail-closed middleware slot. -/// -/// PR 1.2 lands bearer extraction, hashing, device lookup, and header stripping here. Until -/// then every non-allowlisted route is refused, so no route can accidentally ship unauthenticated. -async fn remote_auth_slot(request: Request, next: Next) -> Response { - if request.method() == Method::OPTIONS { - return next.run(request).await; - } - if PRE_AUTH_ALLOWLIST.contains(&request.uri().path()) { - return next.run(request).await; - } - remote_error_response( - StatusCode::UNAUTHORIZED, - ErrorCode::RemoteUnauthorized, - "Remote authentication is required.", - ) -} - async fn remote_preflight_handler() -> Response { StatusCode::NO_CONTENT.into_response() } @@ -223,12 +263,16 @@ async fn remote_fallback_handler(method: Method) -> Response { ) } -fn remote_error_response(status: StatusCode, code: ErrorCode, message: &'static str) -> Response { +pub(crate) fn remote_error_response( + status: StatusCode, + code: ErrorCode, + message: impl Into, +) -> Response { ( status, Json(RemoteErrorBody { code, - message: message.to_string(), + message: message.into(), }), ) .into_response() @@ -288,12 +332,22 @@ pub(crate) async fn start_listener( let shutdown = CancellationToken::new(); let serve_shutdown = shutdown.clone(); let (stopped_tx, stopped) = oneshot::channel(); - let router = remote_router(RemoteRouterState::new(settings.environment_id.as_str())); + let auth = + RemoteAuthContext::from_db(store.db(), handle.sessions.clone(), settings.exposure_mode); + let router = remote_router(RemoteRouterState::new( + settings.environment_id.as_str(), + auth, + )); tauri::async_runtime::spawn(async move { - match axum::serve(listener, router) - .with_graceful_shutdown(serve_shutdown.cancelled_owned()) - .await + // Connect info is what lets direct-tailnet mode key rate limiting on the real peer; + // under Serve the address is loopback and deliberately ignored (§4.4). + match axum::serve( + listener, + router.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(serve_shutdown.cancelled_owned()) + .await { Ok(()) => tracing::info!("Remote listener shut down cleanly"), Err(error) => tracing::error!(%error, "Remote listener stopped unexpectedly"), @@ -322,7 +376,16 @@ pub(crate) async fn stop_listener( store: &RemoteHostSettingsStore, ) -> Result { let mut active = handle.active.lock().await; + // Durable intent first, then the teardown effect: a caller that observes `enabled = + // false` must never find a live session still attached (§4.4 teardown order). store.set_enabled(false).await?; + let torn_down = handle.sessions.kill_all(ResetReason::HostDisabled); + if torn_down > 0 { + tracing::info!( + sessions = torn_down, + "Remote listener disable tore down live sessions" + ); + } let Some(listener) = active.take() else { tracing::debug!("Remote listener stop requested while it was not running"); return Ok(false); diff --git a/src-tauri/src/remote_server/settings.rs b/src-tauri/src/remote_server/settings.rs index 8e2f7add15..373027aafc 100644 --- a/src-tauri/src/remote_server/settings.rs +++ b/src-tauri/src/remote_server/settings.rs @@ -57,6 +57,11 @@ impl RemoteHostSettingsStore { Self { db } } + /// The connection the remote-access repositories share with this store. + pub(crate) fn db(&self) -> DbConnection { + self.db.clone() + } + /// Returns the singleton settings, creating the disabled default on first access. pub(crate) async fn get_or_create(&self) -> AppResult { self.db From d571520da2514ba603b0d2773cbddc7b7cc5220c Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:27:09 +0300 Subject: [PATCH 052/416] feat: add host-local remote pairing, device, and session commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pairing-code mint/list/cancel, device list, the agent-control toggle, device revocation, and session list/disconnect — none of them mounted on :3849, since there is no remote admin scope. Withdrawing agent control and revoking a device both write the durable authority first and fire the kill channels second, so a live session can never outlive the grant it was admitted under; revocation also burns the device's outstanding WS tickets. The commands share the listener handle's registry rather than a private copy, so a revoke cannot report success while the real sessions stay live. Disconnecting a session takes the owning device from the durable row, never from the caller, so a stale id cannot signal another device's sessions. Registry entries are kept in one contiguous block for the parallel lanes. --- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/registry.rs | 10 + .../src/commands/remote_device_commands.rs | 386 ++++++++++++++++++ 3 files changed, 397 insertions(+) create mode 100644 src-tauri/src/commands/remote_device_commands.rs diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 66614a775d..dadd664d70 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -81,6 +81,7 @@ pub mod release_notes_commands; pub mod repository_settings_commands; #[cfg(test)] mod repository_settings_commands_tests; +pub mod remote_device_commands; pub mod remote_host_commands; #[cfg(debug_assertions)] pub mod remote_transport_spike_commands; diff --git a/src-tauri/src/commands/registry.rs b/src-tauri/src/commands/registry.rs index 6d3b1aac9c..12ddca8714 100644 --- a/src-tauri/src/commands/registry.rs +++ b/src-tauri/src/commands/registry.rs @@ -21,10 +21,20 @@ macro_rules! register_tauri_commands { commands::notification_commands::get_unread_notification_count, #[cfg(debug_assertions)] commands::notification_commands::debug_send_test_notification, + // remote auth (PR 1.2) commands::remote_host_commands::start_remote_listener, commands::remote_host_commands::stop_remote_listener, commands::remote_host_commands::set_remote_exposure_mode, commands::remote_host_commands::get_remote_listener_status, + commands::remote_device_commands::generate_remote_pairing_code, + commands::remote_device_commands::list_remote_pairing_codes, + commands::remote_device_commands::revoke_remote_pairing_code, + commands::remote_device_commands::list_remote_devices, + commands::remote_device_commands::set_remote_device_agent_control, + commands::remote_device_commands::revoke_remote_device, + commands::remote_device_commands::list_remote_sessions, + commands::remote_device_commands::disconnect_remote_session, + // end remote auth (PR 1.2) #[cfg(debug_assertions)] commands::remote_transport_spike_commands::debug_start_remote_transport_cors_probe, #[cfg(debug_assertions)] diff --git a/src-tauri/src/commands/remote_device_commands.rs b/src-tauri/src/commands/remote_device_commands.rs new file mode 100644 index 0000000000..3903521357 --- /dev/null +++ b/src-tauri/src/commands/remote_device_commands.rs @@ -0,0 +1,386 @@ +//! Host-local Tauri commands for remote pairing, devices, and sessions (§5.4). +//! +//! None of these is reachable on :3849. There is no remote `admin` scope: device management +//! and pairing-code minting are Tauri-command-only on the host and compile-denied from the +//! invoke facade (§3.1, §4.4). The raw pairing code exists only in the mint response the +//! host's own UI renders as a QR/URL — it is stored hashed (A-9). + +use chrono::Utc; +use ralphx_remote_protocol::{ResetReason, Scope}; +use serde::{Deserialize, Serialize}; +use tauri::State; + +use crate::domain::entities::{ + validate_pairing_grant, RemoteAuditAction, RemoteDevice, RemoteDeviceId, RemotePairingCode, + RemotePairingCodeId, RemoteScopeSet, RemoteSessionId, +}; +use crate::domain::services::key_crypto::hash_key; +use crate::remote_server::auth::{ + expiry_timestamp, generate_pairing_code, now_timestamp, RemoteAuthContext, + PAIRING_CODE_TTL_SECS, +}; +use crate::remote_server::remote_listener_handle; +use crate::AppState; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MintedRemotePairingCode { + pub id: String, + /// Shown once by the host UI as a QR/`ralphx://pair` URL. Never persisted in the clear. + pub code: String, + pub scopes: Vec, + pub created_at: String, + pub expires_at: String, + pub expires_in_secs: i64, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemotePairingCodeView { + pub id: String, + pub scopes: Vec, + pub created_at: String, + pub expires_at: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteDeviceView { + pub id: String, + pub name: String, + pub token_prefix: String, + pub scopes: Vec, + pub agent_control_granted: bool, + pub created_at: String, + pub last_seen_at: Option, + pub revoked_at: Option, + pub live_session_count: usize, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteSessionView { + pub id: String, + pub device_id: String, + pub connected_at: String, + pub last_active_at: String, + pub remote_addr: String, + /// Whether the in-memory registry still holds a kill channel for this session. + pub live: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteDeviceIdInput { + pub device_id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetRemoteDeviceAgentControlInput { + pub device_id: String, + /// Off by default; this is the only way `ui:agent` is ever granted (§5.4). + pub enabled: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemotePairingCodeIdInput { + pub id: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteSessionIdInput { + pub session_id: String, +} + +/// Builds the host-local view of the remote-access stores, sharing the process-wide registry. +fn remote_context(state: &State<'_, AppState>, app: &tauri::AppHandle) -> RemoteAuthContext { + let handle = remote_listener_handle(app); + RemoteAuthContext::host_local(state.db.clone(), handle.sessions().clone()) +} + +fn device_view(device: &RemoteDevice, live_session_count: usize) -> RemoteDeviceView { + RemoteDeviceView { + id: device.id.to_string(), + name: device.name.clone(), + token_prefix: device.token_prefix.clone(), + scopes: device.scopes.to_vec(), + agent_control_granted: device.agent_control_granted(), + created_at: device.created_at.clone(), + last_seen_at: device.last_seen_at.clone(), + revoked_at: device.revoked_at.clone(), + live_session_count, + } +} + +/// Mints a single-use pairing code with a 10-minute TTL. +/// +/// The grant is validated first: a pairing code can never carry `ui:agent` or `ui:elevated`, +/// so agent control cannot be smuggled in through pairing (§4.3). +#[tauri::command] +pub async fn generate_remote_pairing_code( + state: State<'_, AppState>, + app: tauri::AppHandle, +) -> Result { + let context = remote_context(&state, &app); + let scopes = RemoteScopeSet::default_pairing_grant(); + validate_pairing_grant(&scopes).map_err(|error| error.to_string())?; + + let raw_code = generate_pairing_code(); + let minted_at = Utc::now(); + let created_at = now_timestamp(); + let expires_at = expiry_timestamp(minted_at, PAIRING_CODE_TTL_SECS); + let stored = context + .pairing_codes + .create(RemotePairingCode { + id: RemotePairingCodeId::new(), + code_hash: hash_key(&raw_code), + scopes: scopes.clone(), + created_at: created_at.clone(), + expires_at: expires_at.clone(), + consumed_at: None, + }) + .await + .map_err(|error| error.to_string())?; + context + .record_audit(None, RemoteAuditAction::PairingCodeCreated, None) + .await; + + Ok(MintedRemotePairingCode { + id: stored.id.to_string(), + code: raw_code, + scopes: scopes.to_vec(), + created_at, + expires_at, + expires_in_secs: PAIRING_CODE_TTL_SECS, + }) +} + +/// Outstanding (unconsumed, unexpired) codes, without their raw values. +#[tauri::command] +pub async fn list_remote_pairing_codes( + state: State<'_, AppState>, + app: tauri::AppHandle, +) -> Result, String> { + let context = remote_context(&state, &app); + let codes = context + .pairing_codes + .list_outstanding(&now_timestamp()) + .await + .map_err(|error| error.to_string())?; + Ok(codes + .into_iter() + .map(|code| RemotePairingCodeView { + id: code.id.to_string(), + scopes: code.scopes.to_vec(), + created_at: code.created_at, + expires_at: code.expires_at, + }) + .collect()) +} + +/// Cancels an outstanding pairing code before anyone redeems it. +#[tauri::command] +pub async fn revoke_remote_pairing_code( + input: RemotePairingCodeIdInput, + state: State<'_, AppState>, + app: tauri::AppHandle, +) -> Result { + let context = remote_context(&state, &app); + let cancelled = context + .pairing_codes + .cancel( + &RemotePairingCodeId::from_string(input.id), + &now_timestamp(), + ) + .await + .map_err(|error| error.to_string())?; + if cancelled { + context + .record_audit(None, RemoteAuditAction::PairingCodeRevoked, None) + .await; + } + Ok(cancelled) +} + +/// Every paired device, revoked ones included, with its live session count. +#[tauri::command] +pub async fn list_remote_devices( + state: State<'_, AppState>, + app: tauri::AppHandle, +) -> Result, String> { + let context = remote_context(&state, &app); + let devices = context + .devices + .list() + .await + .map_err(|error| error.to_string())?; + Ok(devices + .iter() + .map(|device| device_view(device, context.registry.device_session_count(&device.id))) + .collect()) +} + +/// Grants or withdraws remote agent control for one device. +/// +/// Withdrawing narrows the durable grant **first**, then fires the device's kill channels, so +/// a live session can never outlive the authority it was admitted under (§4.4, §5.4). +#[tauri::command] +pub async fn set_remote_device_agent_control( + input: SetRemoteDeviceAgentControlInput, + state: State<'_, AppState>, + app: tauri::AppHandle, +) -> Result { + let context = remote_context(&state, &app); + let device_id = RemoteDeviceId::from_string(input.device_id); + let device = context + .devices + .get(&device_id) + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "This remote device no longer exists.".to_string())?; + + let scopes = if input.enabled { + device.scopes.with(Scope::UiAgent) + } else { + device.scopes.without(Scope::UiAgent) + }; + let updated = context + .devices + .set_scopes(&device_id, &scopes) + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "This remote device no longer exists.".to_string())?; + + context + .record_audit( + Some(&device_id), + if input.enabled { + RemoteAuditAction::AgentControlGranted + } else { + RemoteAuditAction::AgentControlRevoked + }, + None, + ) + .await; + + // Narrowing is the only direction that needs teardown: a session admitted while agent + // control was on must not keep running under the old grant. + if !input.enabled { + let torn_down = context + .tear_down_device_sessions(&device_id, ResetReason::Revoked) + .await; + tracing::info!( + device_id = %device_id, + sessions = torn_down, + "Remote agent control withdrawn" + ); + } + + let live = context.registry.device_session_count(&device_id); + Ok(device_view(&updated, live)) +} + +/// Revokes a device: writes `revoked_at`, then tears its live sessions down immediately. +#[tauri::command] +pub async fn revoke_remote_device( + input: RemoteDeviceIdInput, + state: State<'_, AppState>, + app: tauri::AppHandle, +) -> Result { + let context = remote_context(&state, &app); + let device_id = RemoteDeviceId::from_string(input.device_id); + let revoked = context + .devices + .revoke(&device_id, &now_timestamp()) + .await + .map_err(|error| error.to_string())? + .ok_or_else(|| "This remote device no longer exists.".to_string())?; + + context + .record_audit(Some(&device_id), RemoteAuditAction::DeviceRevoked, None) + .await; + let torn_down = context + .tear_down_device_sessions(&device_id, ResetReason::Revoked) + .await; + tracing::info!(device_id = %device_id, sessions = torn_down, "Remote device revoked"); + + Ok(device_view( + &revoked, + context.registry.device_session_count(&device_id), + )) +} + +/// Open session rows, flagged with whether the registry still holds them. +#[tauri::command] +pub async fn list_remote_sessions( + state: State<'_, AppState>, + app: tauri::AppHandle, +) -> Result, String> { + let context = remote_context(&state, &app); + let sessions = context + .sessions + .list_open() + .await + .map_err(|error| error.to_string())?; + Ok(sessions + .into_iter() + .map(|session| { + let live = context + .registry + .live_sessions(&session.device_id) + .contains(&session.id); + RemoteSessionView { + id: session.id.to_string(), + device_id: session.device_id.to_string(), + connected_at: session.connected_at, + last_active_at: session.last_active_at, + remote_addr: session.remote_addr, + live, + } + }) + .collect()) +} + +/// Closes one live session without revoking its device. +#[tauri::command] +pub async fn disconnect_remote_session( + input: RemoteSessionIdInput, + state: State<'_, AppState>, + app: tauri::AppHandle, +) -> Result { + let context = remote_context(&state, &app); + let session_id = RemoteSessionId::from_string(input.session_id); + // The owning device comes from the durable row, never from the caller, so a stale id + // cannot be used to signal a different device's sessions. + let session = context + .sessions + .list_open() + .await + .map_err(|error| error.to_string())? + .into_iter() + .find(|session| session.id == session_id); + let Some(session) = session else { + return Ok(false); + }; + + let now = now_timestamp(); + context + .sessions + .close(&session_id, &now) + .await + .map_err(|error| error.to_string())?; + context + .registry + .kill_session(&session.device_id, &session_id, ResetReason::HostDisabled); + context + .record_audit( + Some(&session.device_id), + RemoteAuditAction::SessionClosed, + Some("disconnected by the host owner"), + ) + .await; + Ok(true) +} From 3e9c043685f7ebf112e32f06a3490a8c3e217e6d Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:28:42 +0300 Subject: [PATCH 053/416] feat: wire tailscale Serve acquire/release into remote listener lifecycle Serve-mode listener starts now acquire the tailnet Serve forwarding mapping after a successful loopback bind, and release it on stop or a failed start after acquire. A missing tailscale binary or failed acquire degrades to loopback-only instead of failing the listener start, surfaced via new serve_active/serve_degraded_reason fields on RemoteListenerStatus. --- .../src/commands/remote_host_commands.rs | 21 ++++- src-tauri/src/infrastructure/tailscale.rs | 12 --- src-tauri/src/remote_server/mod.rs | 91 +++++++++++++++++-- 3 files changed, 101 insertions(+), 23 deletions(-) diff --git a/src-tauri/src/commands/remote_host_commands.rs b/src-tauri/src/commands/remote_host_commands.rs index 013bbb1a68..f7c411137e 100644 --- a/src-tauri/src/commands/remote_host_commands.rs +++ b/src-tauri/src/commands/remote_host_commands.rs @@ -5,7 +5,7 @@ use serde::{Deserialize, Serialize}; use tauri::State; -use crate::infrastructure::tailscale::TailscaleSelfAddressProvider; +use crate::infrastructure::tailscale::{RealTailscaleCommandRunner, TailscaleSelfAddressProvider}; use crate::remote_server::settings::{ RemoteExposureMode, RemoteHostSettings, RemoteHostSettingsStore, }; @@ -24,6 +24,8 @@ pub struct RemoteListenerStatus { pub environment_id: String, pub running: bool, pub bind_address: Option, + pub serve_active: bool, + pub serve_degraded_reason: Option, } #[derive(Debug, Deserialize)] @@ -41,6 +43,7 @@ async fn listener_status( handle: &RemoteListenerHandle, ) -> RemoteListenerStatus { let bind_address = handle.bound_address().await; + let serve = handle.serve_status().await; RemoteListenerStatus { enabled: settings.enabled, exposure_mode: settings.exposure_mode, @@ -48,6 +51,8 @@ async fn listener_status( environment_id: settings.environment_id, running: bind_address.is_some(), bind_address: bind_address.map(|address| address.to_string()), + serve_active: serve.active, + serve_degraded_reason: serve.degraded_reason, } } @@ -59,9 +64,14 @@ pub async fn start_remote_listener( ) -> Result { let store = settings_store(&state); let handle = remote_listener_handle(&app); - start_listener(&handle, &store, &TailscaleSelfAddressProvider) - .await - .map_err(|error| error.to_string())?; + start_listener( + &handle, + &store, + &TailscaleSelfAddressProvider, + &RealTailscaleCommandRunner, + ) + .await + .map_err(|error| error.to_string())?; let settings = store.get_or_create().await.map_err(|e| e.to_string())?; Ok(listener_status(settings, &handle).await) } @@ -74,7 +84,7 @@ pub async fn stop_remote_listener( ) -> Result { let store = settings_store(&state); let handle = remote_listener_handle(&app); - stop_listener(&handle, &store) + stop_listener(&handle, &store, &RealTailscaleCommandRunner) .await .map_err(|error| error.to_string())?; let settings = store.get_or_create().await.map_err(|e| e.to_string())?; @@ -94,6 +104,7 @@ pub async fn set_remote_exposure_mode( &handle, &store, &TailscaleSelfAddressProvider, + &RealTailscaleCommandRunner, input.exposure_mode, ) .await diff --git a/src-tauri/src/infrastructure/tailscale.rs b/src-tauri/src/infrastructure/tailscale.rs index 631e89e054..291c7245b5 100644 --- a/src-tauri/src/infrastructure/tailscale.rs +++ b/src-tauri/src/infrastructure/tailscale.rs @@ -81,18 +81,6 @@ impl TailnetSelfAddressProvider for TailscaleSelfAddressProvider { } } -/// Acquires the process-independent Tailscale Serve mapping for a loopback listener. -#[allow(dead_code)] -pub(crate) async fn acquire_serve(port: u16) -> Result<(), TailscaleServeError> { - RealTailscaleCommandRunner.run_serve_acquire(port).await -} - -/// Releases the Tailscale Serve mapping. -#[allow(dead_code)] -pub(crate) async fn release_serve() -> Result<(), TailscaleServeError> { - RealTailscaleCommandRunner.run_serve_release().await -} - #[derive(Debug, Deserialize)] pub(crate) struct TailscaleStatus { #[serde(rename = "Version")] diff --git a/src-tauri/src/remote_server/mod.rs b/src-tauri/src/remote_server/mod.rs index dc7dfb9cd5..61838b4c55 100644 --- a/src-tauri/src/remote_server/mod.rs +++ b/src-tauri/src/remote_server/mod.rs @@ -39,7 +39,10 @@ use tokio_util::sync::CancellationToken; use tower_http::cors::{AllowOrigin, CorsLayer}; use crate::error::AppError; -use crate::infrastructure::tailscale::TailscaleSelfAddressProvider; +use crate::infrastructure::tailscale::{ + RealTailscaleCommandRunner, TailscaleCommandRunner, TailscaleSelfAddressProvider, + TailscaleServeError, +}; use crate::remote_server::endpoints::{ environment_descriptor_handler, health_handler, RemoteRouterState, }; @@ -106,6 +109,13 @@ struct ActiveRemoteListener { shutdown: CancellationToken, stopped: oneshot::Receiver<()>, bind_address: SocketAddr, + serve: RemoteServeStatus, +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct RemoteServeStatus { + pub(crate) active: bool, + pub(crate) degraded_reason: Option, } /// Process-owned handle for the single remote listener. @@ -135,6 +145,15 @@ impl RemoteListenerHandle { pub(crate) async fn is_running(&self) -> bool { self.bound_address().await.is_some() } + + pub(crate) async fn serve_status(&self) -> RemoteServeStatus { + self.active + .lock() + .await + .as_ref() + .map(|listener| listener.serve.clone()) + .unwrap_or_default() + } } impl Default for RemoteListenerHandle { @@ -244,6 +263,7 @@ pub(crate) async fn start_listener( handle: &RemoteListenerHandle, store: &RemoteHostSettingsStore, provider: &dyn TailnetSelfAddressProvider, + tailscale: &dyn TailscaleCommandRunner, ) -> Result { let mut active = handle.active.lock().await; if let Some(listener) = active.as_ref() { @@ -285,7 +305,31 @@ pub(crate) async fn start_listener( } }; - store.set_enabled(true).await?; + let serve = if settings.exposure_mode == RemoteExposureMode::Serve { + match tailscale.run_serve_acquire(bound_address.port()).await { + Ok(()) => RemoteServeStatus { + active: true, + degraded_reason: None, + }, + Err(error) => { + let degraded_reason = tailscale_serve_degraded_reason(&error); + tracing::warn!(%error, address = %bound_address, "Tailscale Serve unavailable; remote listener remains loopback-only"); + RemoteServeStatus { + active: false, + degraded_reason: Some(degraded_reason), + } + } + } + } else { + RemoteServeStatus::default() + }; + + if let Err(error) = store.set_enabled(true).await { + if serve.active { + release_serve_best_effort(tailscale, bound_address).await; + } + return Err(error.into()); + } let shutdown = CancellationToken::new(); let serve_shutdown = shutdown.clone(); @@ -307,6 +351,7 @@ pub(crate) async fn start_listener( shutdown, stopped, bind_address: bound_address, + serve, }); tracing::info!( address = %bound_address, @@ -322,6 +367,7 @@ pub(crate) async fn start_listener( pub(crate) async fn stop_listener( handle: &RemoteListenerHandle, store: &RemoteHostSettingsStore, + tailscale: &dyn TailscaleCommandRunner, ) -> Result { let mut active = handle.active.lock().await; store.set_enabled(false).await?; @@ -335,6 +381,9 @@ pub(crate) async fn stop_listener( // Waiting for the serve task guarantees the port is released before the lock is released, // so a subsequent enable can re-acquire it. let _ = listener.stopped.await; + if listener.serve.active { + release_serve_best_effort(tailscale, listener.bind_address).await; + } tracing::info!(address = %listener.bind_address, "Remote listener stopped"); Ok(true) } @@ -347,11 +396,12 @@ pub(crate) async fn apply_exposure_mode( handle: &RemoteListenerHandle, store: &RemoteHostSettingsStore, provider: &dyn TailnetSelfAddressProvider, + tailscale: &dyn TailscaleCommandRunner, exposure_mode: RemoteExposureMode, ) -> Result { let was_running = handle.is_running().await; if was_running { - stop_listener(handle, store).await?; + stop_listener(handle, store, tailscale).await?; } let settings = store.set_exposure_mode(exposure_mode).await?; @@ -359,7 +409,7 @@ pub(crate) async fn apply_exposure_mode( return Ok(settings); } - match start_listener(handle, store, provider).await { + match start_listener(handle, store, provider, tailscale).await { Ok(_) => Ok(store.get_or_create().await?), Err(error) => { tracing::error!( @@ -377,6 +427,7 @@ pub(crate) async fn auto_start_if_enabled( handle: &RemoteListenerHandle, store: &RemoteHostSettingsStore, provider: &dyn TailnetSelfAddressProvider, + tailscale: &dyn TailscaleCommandRunner, ) -> Result, RemoteListenerError> { let Some(settings) = store.get().await? else { tracing::debug!("Remote host settings are absent; remote listener stays off"); @@ -386,7 +437,9 @@ pub(crate) async fn auto_start_if_enabled( tracing::debug!("Remote host mode is disabled; remote listener stays off"); return Ok(None); } - start_listener(handle, store, provider).await.map(Some) + start_listener(handle, store, provider, tailscale) + .await + .map(Some) } /// Startup hook, invoked from the same setup phase that calls `start_server_boot`. @@ -401,7 +454,14 @@ pub(crate) async fn auto_start_remote_listener_from_handle(app_handle: &tauri::A let store = RemoteHostSettingsStore::from_db(state.db.clone()); let handle = remote_listener_handle(app_handle); - match auto_start_if_enabled(&handle, &store, &TailscaleSelfAddressProvider).await { + match auto_start_if_enabled( + &handle, + &store, + &TailscaleSelfAddressProvider, + &RealTailscaleCommandRunner, + ) + .await + { Ok(Some(address)) => { tracing::info!(%address, "Remote listener auto-started from persisted settings"); } @@ -409,3 +469,22 @@ pub(crate) async fn auto_start_remote_listener_from_handle(app_handle: &tauri::A Err(error) => tracing::error!(%error, "Remote listener auto-start failed"), } } + +fn tailscale_serve_degraded_reason(error: &TailscaleServeError) -> String { + match error { + TailscaleServeError::CliUnavailable + | TailscaleServeError::Launch(_) + | TailscaleServeError::Timeout + | TailscaleServeError::Output(_) + | TailscaleServeError::Exit(_) => error.to_string(), + } +} + +async fn release_serve_best_effort( + tailscale: &dyn TailscaleCommandRunner, + bind_address: SocketAddr, +) { + if let Err(error) = tailscale.run_serve_release().await { + tracing::warn!(%error, address = %bind_address, "Tailscale Serve release failed"); + } +} From 960a4cc3c13c3a4743637a6a8afd6917f1201e9f Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:28:45 +0300 Subject: [PATCH 054/416] fix: revoke the replaced device token after a dedup re-pair --- .../application/remote_environment_service.rs | 29 ++++++++++++++ .../remote_environment_service_tests.rs | 40 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/src-tauri/src/application/remote_environment_service.rs b/src-tauri/src/application/remote_environment_service.rs index a5f48f62f8..d8ac044220 100644 --- a/src-tauri/src/application/remote_environment_service.rs +++ b/src-tauri/src/application/remote_environment_service.rs @@ -226,6 +226,18 @@ impl RemoteEnvironmentService { }) .await?; + // On a dedup re-pair the same Keychain entry is about to be overwritten; + // remember the replaced bearer so it can be revoked on the host after the + // staged add completes (otherwise the previous device would stay + // valid-but-unreferenced host-side). + let replaced_token = self + .secret_store + .get_secret(&env.token_secret_ref) + .await + .ok() + .flatten() + .filter(|previous| previous != &response.device_token); + // 4. Keychain write. On failure the pending_add row stays behind and the // startup reconciler deletes the husk — never a secret without a row. self.secret_store @@ -237,6 +249,23 @@ impl RemoteEnvironmentService { .set_status(&env.id, RemoteEnvironmentStatus::Active) .await?; + // 6. Best-effort cleanup of the replaced bearer, only after the new one is + // fully installed (never before — a failed re-pair must not kill the + // working credential). + if let Some(previous_token) = replaced_token { + if let Err(error) = self + .host_client + .revoke_token(&env.base_url, &previous_token) + .await + { + tracing::warn!( + environment = env.id.as_str(), + %error, + "Best-effort revoke of the replaced device token failed" + ); + } + } + Ok(RemoteEnvironment { status: RemoteEnvironmentStatus::Active, ..env diff --git a/src-tauri/src/application/remote_environment_service_tests.rs b/src-tauri/src/application/remote_environment_service_tests.rs index 1723743aff..431cfc6daa 100644 --- a/src-tauri/src/application/remote_environment_service_tests.rs +++ b/src-tauri/src/application/remote_environment_service_tests.rs @@ -220,6 +220,46 @@ async fn pairing_the_same_host_via_a_second_url_merges_into_one_environment() { ); } +#[tokio::test] +async fn re_pairing_revokes_the_replaced_token_after_the_new_one_is_installed() { + let f = fixture(); + f.service + .pair(HOST_URL, "rxp_code", "Mac Studio") + .await + .expect("first pairing should succeed"); + + // The host mints a fresh token for the re-pair. + let second_token = "rxd_live_fedcba9876543210"; + { + let mut pair_slot = f.host.pair_response.lock().expect("mock"); + let mut refreshed = pair_response("env-1"); + refreshed.device_token = second_token.to_string(); + *pair_slot = Ok(refreshed); + } + + let env = f + .service + .pair(HOST_URL_DIRECT, "rxp_code2", "Mac Studio") + .await + .expect("re-pair should succeed"); + + // The Keychain now holds the fresh bearer… + assert_eq!( + f.secrets + .get_secret(&env.token_secret_ref) + .await + .expect("secret read") + .as_deref(), + Some(second_token) + ); + // …and the replaced bearer was revoked host-side (best effort), so the old + // device does not linger valid-but-unreferenced. + assert!(f.host.recorded_calls().iter().any(|call| matches!( + call, + RecordedHostCall::Revoke { token, .. } if token == TOKEN + ))); +} + // ============================================================================ // P-18 — the token never reaches JS-serializable surfaces // ============================================================================ From f91608bf65e6247d23fc57e0de1589bb0d382d3e Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:28:46 +0300 Subject: [PATCH 055/416] test: cover tailscale Serve acquire/release wiring in listener lifecycle Adds a recording TailscaleCommandRunner double to prove: Serve-mode start acquires exactly once and reports a healthy status; a failed acquire keeps the listener up in a degraded state; stop releases exactly once and a second stop is a no-op; enable/disable/enable cycles acquire and release once per cycle; and a successful TailnetDirect start never touches Serve. --- src-tauri/src/remote_server/listener_tests.rs | 228 ++++++++++++++++-- 1 file changed, 212 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/remote_server/listener_tests.rs b/src-tauri/src/remote_server/listener_tests.rs index f252a2c9af..f487d4c0ed 100644 --- a/src-tauri/src/remote_server/listener_tests.rs +++ b/src-tauri/src/remote_server/listener_tests.rs @@ -1,6 +1,8 @@ use std::collections::BTreeSet; -use std::net::SocketAddr; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::sync::{Arc, Mutex}; +use async_trait::async_trait; use axum::{ body::Body, http::{header, Method, Request, StatusCode}, @@ -12,7 +14,8 @@ use tower::ServiceExt; use super::endpoints::{environment_descriptor, RemoteRouterState, MIN_CLIENT_PROTOCOL}; use super::settings::{ - RemoteExposureMode, RemoteHostSettingsStore, UnconfiguredTailnetProvider, REMOTE_PORT_ENV, + RemoteExposureMode, RemoteHostSettingsStore, TailnetProviderError, UnconfiguredTailnetProvider, + REMOTE_PORT_ENV, }; use super::{ allowed_app_origins, apply_exposure_mode, authenticated_remote_routes, auto_start_if_enabled, @@ -20,6 +23,7 @@ use super::{ DESCRIPTOR_PATH, HEALTH_PATH, PAIR_PATH, PRE_AUTH_ALLOWLIST, }; use crate::infrastructure::sqlite::DbConnection; +use crate::infrastructure::tailscale::{TailscaleCommandRunner, TailscaleServeError}; use crate::testing::SqliteTestDb; use crate::utils::backend_endpoint::{ backend_http_base_url, backend_http_bind_addr, backend_http_port, PRODUCTION_BACKEND_PORT, @@ -27,6 +31,66 @@ use crate::utils::backend_endpoint::{ const TEST_APP_ORIGIN: &str = "tauri://localhost"; +struct ConfiguredTailnetProvider; + +#[async_trait] +impl super::settings::TailnetSelfAddressProvider for ConfiguredTailnetProvider { + async fn self_addresses(&self) -> Result, TailnetProviderError> { + Ok(vec![IpAddr::V4(Ipv4Addr::new(100, 101, 102, 103))]) + } +} + +#[derive(Clone, Default)] +struct RecordingTailscaleCommandRunner { + calls: Arc>>, + acquire_error: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum TailscaleCall { + Acquire(u16), + Release, +} + +impl RecordingTailscaleCommandRunner { + fn failing_acquire(error: TailscaleServeError) -> Self { + Self { + calls: Arc::default(), + acquire_error: Some(error), + } + } + + fn calls(&self) -> Vec { + self.calls.lock().expect("command recorder mutex").clone() + } +} + +#[async_trait] +impl TailscaleCommandRunner for RecordingTailscaleCommandRunner { + async fn run_status(&self) -> Result { + Ok(String::new()) + } + + async fn run_serve_acquire(&self, port: u16) -> Result<(), TailscaleServeError> { + self.calls + .lock() + .expect("command recorder mutex") + .push(TailscaleCall::Acquire(port)); + match self.acquire_error.as_ref() { + Some(error) => Err(error.clone()), + None => Ok(()), + } + } + + async fn run_serve_release(&self) -> Result<(), TailscaleServeError> { + self.calls + .lock() + .expect("command recorder mutex") + .push(TailscaleCall::Release); + Ok(()) + } +} + async fn response_body(response: axum::response::Response) -> Value { let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) .await @@ -283,8 +347,9 @@ async fn enable_disable_enable_releases_and_reacquires_the_port() { let port = reserve_loopback_port().await; set_configured_port(&db, port); let handle = RemoteListenerHandle::new(); + let runner = RecordingTailscaleCommandRunner::default(); - let first = start_listener(&handle, &store, &UnconfiguredTailnetProvider) + let first = start_listener(&handle, &store, &UnconfiguredTailnetProvider, &runner) .await .expect("serve mode should start"); let (first_status, _) = http_get_over_socket(first, DESCRIPTOR_PATH) @@ -295,7 +360,7 @@ async fn enable_disable_enable_releases_and_reacquires_the_port() { .await .expect("settings should read") .expect("settings row should exist"); - let stopped = stop_listener(&handle, &store) + let stopped = stop_listener(&handle, &store, &runner) .await .expect("stop should succeed"); let disabled_after_stop = store @@ -304,13 +369,13 @@ async fn enable_disable_enable_releases_and_reacquires_the_port() { .expect("settings should read") .expect("settings row should exist"); let closed = http_get_over_socket(first, DESCRIPTOR_PATH).await; - let second = start_listener(&handle, &store, &UnconfiguredTailnetProvider) + let second = start_listener(&handle, &store, &UnconfiguredTailnetProvider, &runner) .await .expect("serve mode should start again on the released port"); let (second_status, _) = http_get_over_socket(second, DESCRIPTOR_PATH) .await .expect("descriptor should answer after the restart"); - stop_listener(&handle, &store) + stop_listener(&handle, &store, &runner) .await .expect("final stop should succeed"); @@ -323,6 +388,15 @@ async fn enable_disable_enable_releases_and_reacquires_the_port() { assert_eq!(second, first); assert_eq!(second_status, 200); assert!(!handle.is_running().await); + assert_eq!( + runner.calls(), + vec![ + TailscaleCall::Acquire(port), + TailscaleCall::Release, + TailscaleCall::Acquire(port), + TailscaleCall::Release, + ] + ); } #[tokio::test] @@ -332,18 +406,101 @@ async fn starting_an_already_running_listener_is_idempotent() { store.get_or_create().await.expect("settings should mint"); set_configured_port(&db, reserve_loopback_port().await); let handle = RemoteListenerHandle::new(); + let runner = RecordingTailscaleCommandRunner::default(); - let first = start_listener(&handle, &store, &UnconfiguredTailnetProvider) + let first = start_listener(&handle, &store, &UnconfiguredTailnetProvider, &runner) .await .expect("first start should succeed"); - let second = start_listener(&handle, &store, &UnconfiguredTailnetProvider) + let second = start_listener(&handle, &store, &UnconfiguredTailnetProvider, &runner) .await .expect("second start should reuse the running listener"); - stop_listener(&handle, &store) + stop_listener(&handle, &store, &runner) .await .expect("stop should succeed"); assert_eq!(first, second); + assert_eq!( + runner.calls(), + vec![TailscaleCall::Acquire(first.port()), TailscaleCall::Release] + ); +} + +#[tokio::test] +async fn serve_start_acquires_bound_port_and_reports_healthy_status() { + let db = SqliteTestDb::new("remote-listener-serve-healthy"); + let store = RemoteHostSettingsStore::from_db(DbConnection::from_shared(db.shared_conn())); + store.get_or_create().await.expect("settings should mint"); + let port = reserve_loopback_port().await; + set_configured_port(&db, port); + let handle = RemoteListenerHandle::new(); + let runner = RecordingTailscaleCommandRunner::default(); + + let address = start_listener(&handle, &store, &UnconfiguredTailnetProvider, &runner) + .await + .expect("serve mode should start"); + let status = handle.serve_status().await; + stop_listener(&handle, &store, &runner) + .await + .expect("stop should succeed"); + + assert_eq!(address, SocketAddr::from(([127, 0, 0, 1], port))); + assert!(status.active); + assert!(status.degraded_reason.is_none()); + assert_eq!( + runner.calls(), + vec![TailscaleCall::Acquire(port), TailscaleCall::Release] + ); +} + +#[tokio::test] +async fn failed_serve_acquire_keeps_loopback_listener_running_with_degraded_status() { + let db = SqliteTestDb::new("remote-listener-serve-degraded"); + let store = RemoteHostSettingsStore::from_db(DbConnection::from_shared(db.shared_conn())); + store.get_or_create().await.expect("settings should mint"); + let port = reserve_loopback_port().await; + set_configured_port(&db, port); + let handle = RemoteListenerHandle::new(); + let runner = + RecordingTailscaleCommandRunner::failing_acquire(TailscaleServeError::CliUnavailable); + + let address = start_listener(&handle, &store, &UnconfiguredTailnetProvider, &runner) + .await + .expect("serve degradation must not fail listener start"); + let status = handle.serve_status().await; + stop_listener(&handle, &store, &runner) + .await + .expect("degraded listener should stop"); + + assert_eq!(address, SocketAddr::from(([127, 0, 0, 1], port))); + assert!(!status.active); + assert!(status.degraded_reason.is_some()); + assert_eq!(runner.calls(), vec![TailscaleCall::Acquire(port)]); +} + +#[tokio::test] +async fn stopping_twice_releases_a_serve_mapping_only_once() { + let db = SqliteTestDb::new("remote-listener-double-stop"); + let store = RemoteHostSettingsStore::from_db(DbConnection::from_shared(db.shared_conn())); + store.get_or_create().await.expect("settings should mint"); + let port = reserve_loopback_port().await; + set_configured_port(&db, port); + let handle = RemoteListenerHandle::new(); + let runner = RecordingTailscaleCommandRunner::default(); + + start_listener(&handle, &store, &UnconfiguredTailnetProvider, &runner) + .await + .expect("serve listener should start"); + assert!(stop_listener(&handle, &store, &runner) + .await + .expect("first stop should succeed")); + assert!(!stop_listener(&handle, &store, &runner) + .await + .expect("second stop should be a no-op")); + + assert_eq!( + runner.calls(), + vec![TailscaleCall::Acquire(port), TailscaleCall::Release] + ); } #[tokio::test] @@ -355,8 +512,9 @@ async fn tailnet_direct_start_is_refused_while_the_provider_reports_no_tailnet() .await .expect("exposure mode should persist"); let handle = RemoteListenerHandle::new(); + let runner = RecordingTailscaleCommandRunner::default(); - let error = start_listener(&handle, &store, &UnconfiguredTailnetProvider) + let error = start_listener(&handle, &store, &UnconfiguredTailnetProvider, &runner) .await .expect_err("direct exposure must be refused without a validated tailnet address"); let settings = store @@ -371,6 +529,32 @@ async fn tailnet_direct_start_is_refused_while_the_provider_reports_no_tailnet() "a refused bind must never persist an enabled listener" ); assert!(!handle.is_running().await); + assert!(runner.calls().is_empty()); +} + +#[tokio::test] +async fn successful_tailnet_direct_start_never_changes_serve_configuration() { + let db = SqliteTestDb::new("remote-listener-tailnet-direct"); + let store = RemoteHostSettingsStore::from_db(DbConnection::from_shared(db.shared_conn())); + store + .set_exposure_mode(RemoteExposureMode::TailnetDirect) + .await + .expect("exposure mode should persist"); + let handle = RemoteListenerHandle::new(); + let runner = RecordingTailscaleCommandRunner::default(); + + let address = start_listener(&handle, &store, &ConfiguredTailnetProvider, &runner) + .await + .expect("direct exposure should bind a validated tailnet address"); + + assert_eq!(address.ip(), IpAddr::V4(Ipv4Addr::new(100, 101, 102, 103))); + assert!(runner.calls().is_empty()); + + stop_listener(&handle, &store, &runner) + .await + .expect("direct listener should stop"); + + assert!(runner.calls().is_empty()); } #[tokio::test] @@ -387,14 +571,21 @@ async fn auto_start_does_nothing_without_an_enabling_settings_row() { .await .expect("settings should mint disabled"); let disabled_handle = RemoteListenerHandle::new(); + let runner = RecordingTailscaleCommandRunner::default(); - let absent = auto_start_if_enabled(&absent_handle, &absent_store, &UnconfiguredTailnetProvider) - .await - .expect("an absent row is not an error"); + let absent = auto_start_if_enabled( + &absent_handle, + &absent_store, + &UnconfiguredTailnetProvider, + &runner, + ) + .await + .expect("an absent row is not an error"); let disabled = auto_start_if_enabled( &disabled_handle, &disabled_store, &UnconfiguredTailnetProvider, + &runner, ) .await .expect("a disabled row is not an error"); @@ -420,11 +611,12 @@ async fn auto_start_binds_when_the_persisted_row_enables_the_listener() { store.set_enabled(true).await.expect("settings should mint"); set_configured_port(&db, reserve_loopback_port().await); let handle = RemoteListenerHandle::new(); + let runner = RecordingTailscaleCommandRunner::default(); - let started = auto_start_if_enabled(&handle, &store, &UnconfiguredTailnetProvider) + let started = auto_start_if_enabled(&handle, &store, &UnconfiguredTailnetProvider, &runner) .await .expect("an enabled row should auto-start"); - stop_listener(&handle, &store) + stop_listener(&handle, &store, &runner) .await .expect("stop should succeed"); @@ -436,11 +628,13 @@ async fn changing_exposure_mode_persists_while_the_listener_is_stopped() { let db = SqliteTestDb::new("remote-listener-exposure-mode"); let store = RemoteHostSettingsStore::from_db(DbConnection::from_shared(db.shared_conn())); let handle = RemoteListenerHandle::new(); + let runner = RecordingTailscaleCommandRunner::default(); let settings = apply_exposure_mode( &handle, &store, &UnconfiguredTailnetProvider, + &runner, RemoteExposureMode::TailnetDirect, ) .await @@ -457,7 +651,8 @@ async fn a_refused_exposure_mode_change_leaves_remote_access_disabled() { store.get_or_create().await.expect("settings should mint"); set_configured_port(&db, reserve_loopback_port().await); let handle = RemoteListenerHandle::new(); - start_listener(&handle, &store, &UnconfiguredTailnetProvider) + let runner = RecordingTailscaleCommandRunner::default(); + start_listener(&handle, &store, &UnconfiguredTailnetProvider, &runner) .await .expect("serve mode should start"); @@ -465,6 +660,7 @@ async fn a_refused_exposure_mode_change_leaves_remote_access_disabled() { &handle, &store, &UnconfiguredTailnetProvider, + &runner, RemoteExposureMode::TailnetDirect, ) .await From 6dbd96b66f5d41560a975751f67c7ae02a9e2d86 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:41:01 +0300 Subject: [PATCH 056/416] fix(remote): keep local-only events out of the capture bank The install filter admitted Backend-origin Local-only rows and the handler answered them with unreachable!, so such a row would register a listener and panic inside Tauri's event dispatch. That made truthful classification of backend-emitted chrome impossible, and PR 1.4 is specified to add exactly that shape (remote:session_connected/closed). Filter on delivery instead, and drop the LocalOnly arm from the handler entirely. Also: - Classify the three real backend-emitted local-only names (native-menu update chrome, gh device-code login prompt) with recorded rationale; the gh prompt is local-only because its command surface is module-denied remotely. - Delete four phantom rows (my:event was a JSDoc example; window:focus, dock:updated, updater:status have no emit site and no consumer anywhere) and lock them out with a stale-name assertion. - Stop parsing payload JSON inside the handler: Tauri invokes listen_any callbacks inline on the emitting thread, so the parse ran on the emit hot path of every classified event, contradicting the "off the emit hot path" and "channel-send-only" contracts. Raw JSON text goes through the channels; the drain side (PR 1.4's sequencer) parses, and remote_event_log.payload is TEXT. - Record the gate-transience decision, the Full-channel epoch-roll obligation, and the placeholder-drain ownership as comments. - Cover the permitting direction of class_permits and record the compile-fail equivalence argument. --- frontend/src/providers/EventProvider.tsx | 2 +- .../crates/ralphx-remote-protocol/src/lib.rs | 41 +++++++++- .../tests/protocol_contract.rs | 59 ++++++++++++++ .../snapshots/event-classifications.json | 18 ++--- src-tauri/src/remote_server/capture.rs | 76 ++++++++++++++----- src-tauri/src/remote_server/capture_tests.rs | 55 ++++++++++---- 6 files changed, 201 insertions(+), 50 deletions(-) diff --git a/frontend/src/providers/EventProvider.tsx b/frontend/src/providers/EventProvider.tsx index d5a594f04b..338cccf0ce 100644 --- a/frontend/src/providers/EventProvider.tsx +++ b/frontend/src/providers/EventProvider.tsx @@ -55,7 +55,7 @@ const EventBusContext = createContext(null); * const bus = useEventBus(); * * useEffect(() => { - * return bus.subscribe('my:event', (payload) => { + * return bus.subscribe('notification:created', (payload) => { * console.log('Received:', payload); * }); * }, [bus]); diff --git a/src-tauri/crates/ralphx-remote-protocol/src/lib.rs b/src-tauri/crates/ralphx-remote-protocol/src/lib.rs index 8df929510c..a11f4897a4 100644 --- a/src-tauri/crates/ralphx-remote-protocol/src/lib.rs +++ b/src-tauri/crates/ralphx-remote-protocol/src/lib.rs @@ -84,6 +84,15 @@ pub const CAPABILITIES: &[Capability] = &[ /// Compile-time class/capability consistency gate used by the remote registry. /// +/// Proof shape for "every forbidden capability under `Read`/`Operate` is compile-rejected" +/// (phase-0 PR 0.2 acceptance #3 / P-17(b)): the doctest below proves the `const`-assert +/// MECHANISM rejects a forbidden pair at compile time, and +/// `class_permits_rejects_every_capability_for_read_and_operate` in `tests/protocol_contract.rs` +/// exhaustively proves the `const fn` returns `false` for all 11 capabilities under both +/// classes. Because the assert is `const`, `false` for a pair is exactly a compile rejection +/// for that pair — the two together are equivalent to 22 individual `compile_fail` fixtures +/// without paying 22 rustc invocations. +/// /// ```compile_fail /// use ralphx_remote_protocol::{class_permits, Capability, RiskClass}; /// const _: () = assert!(class_permits( @@ -269,10 +278,35 @@ const fn webview(name: &'static str) -> EventClassification { excluded_from_v1: false, } } +/// Backend-emitted host chrome that must never fan out to remote clients (§3.4 Local-only: +/// "window/dock/updater chrome"). `origin` stays truthful — these really are Rust emits — and +/// the capture bank drops every `LocalOnly` row structurally, so a truthful origin costs nothing. +const fn local_backend(name: &'static str) -> EventClassification { + EventClassification { + name, + delivery: EventDelivery::LocalOnly, + origin: EventOrigin::Backend, + excluded_from_v1: false, + } +} // Exact names only. PR 0.1 mechanically audits this table against live emit and subscribe sites. // PR 0.2 decision: render deltas (tool_call/message/hook) are transient; queue and recovery // lifecycle invalidations are durable because clients must refetch authoritative state after replay. +// PR 0.2 decision (gates): `permission:request`/`permission:resolved`/`permission:expired` and +// `agent:ask_user_question` are wire-Transient, which is NOT a contradiction of §3.4's +// "permission / question gates are NOT transient" line. That line rejects treating gates as +// fire-and-forget: §3.4's resolution hydrates pending-gate truth via +// `list_pending_permission_gates` / `list_pending_question_gates` on every connect/reset, +// "without a durable event log for it". So the gate lifecycle is durable-gate-hydrated while the +// live nudge stays a transient frame — these names must never enter `remote_event_log`. +// PR 1.8 decision (local-only chrome): the three backend-emitted names below are host-owned UI +// chrome with no remote meaning. `ralphx://check-for-updates` / `ralphx://show-release-notes` +// come from the native menu (`application/native_menu.rs`) and act on the host binary. +// `gh-auth:login_prompt` (`commands/project_commands.rs`) surfaces the host owner's `gh` device +// -code prompt; relaying it would be worse than useless because its command surface +// (`login_gh_with_browser`, the whole `project_commands` git/gh module) is Denied remotely +// (§3.3 module-deny list) — a remote client could see the prompt but never complete it. pub const EVENT_CLASSIFICATIONS: &[EventClassification] = &[ backend("task:created", EventDelivery::Durable), backend("task:status_changed", EventDelivery::Durable), @@ -373,8 +407,7 @@ pub const EVENT_CLASSIFICATIONS: &[EventClassification] = &[ excluded_from_v1: true, }, webview("task:updated"), - webview("my:event"), - webview("window:focus"), - webview("dock:updated"), - webview("updater:status"), + local_backend("ralphx://check-for-updates"), + local_backend("ralphx://show-release-notes"), + local_backend("gh-auth:login_prompt"), ]; diff --git a/src-tauri/crates/ralphx-remote-protocol/tests/protocol_contract.rs b/src-tauri/crates/ralphx-remote-protocol/tests/protocol_contract.rs index 2ced8c4288..dec40a390e 100644 --- a/src-tauri/crates/ralphx-remote-protocol/tests/protocol_contract.rs +++ b/src-tauri/crates/ralphx-remote-protocol/tests/protocol_contract.rs @@ -97,6 +97,47 @@ fn class_permits_rejects_every_capability_for_read_and_operate() { )); } +/// The negation loop above proves nothing about the classes that DO permit capabilities: a +/// refactor making `PathScoped` reject its own capability, or `Denied` accept an empty set, +/// would pass it. PR 1.3's ledger and compile gates build directly on these answers. +#[test] +fn class_permits_accepts_exactly_the_capabilities_each_class_owns() { + assert!(ralphx_remote_protocol::class_permits( + RiskClass::PathScoped, + &[Capability::WritesArbitraryPath] + )); + assert!(!ralphx_remote_protocol::class_permits( + RiskClass::PathScoped, + &[Capability::WritesArbitraryPath, Capability::SpawnsProcess] + )); + assert!(ralphx_remote_protocol::class_permits( + RiskClass::AgentControl, + &[ + Capability::AgentControl, + Capability::SeedsSpawnTriggeringState, + Capability::MutatesAgentConsumedContent + ] + )); + assert!(!ralphx_remote_protocol::class_permits( + RiskClass::AgentControl, + &[Capability::TouchesCredentials] + )); + assert!(ralphx_remote_protocol::class_permits( + RiskClass::Elevated, + CAPABILITIES + )); + assert!(ralphx_remote_protocol::class_permits(RiskClass::Read, &[])); + assert!(ralphx_remote_protocol::class_permits( + RiskClass::Operate, + &[] + )); + // `Denied` registers nothing, not even a capability-free command. + assert!(!ralphx_remote_protocol::class_permits( + RiskClass::Denied, + &[] + )); +} + #[test] fn event_classification_is_exact_and_snapshotted() { assert_eq!( @@ -119,6 +160,17 @@ fn event_classification_is_exact_and_snapshotted() { EventClassification::find("task:updated").unwrap().origin, EventOrigin::Webview ); + // Backend-emitted host chrome is Local-only with a truthful backend origin; the capture + // bank drops it on delivery, so nothing has to lie about where it came from. + for chrome in [ + "ralphx://check-for-updates", + "ralphx://show-release-notes", + "gh-auth:login_prompt", + ] { + let entry = EventClassification::find(chrome).unwrap(); + assert_eq!(entry.delivery, EventDelivery::LocalOnly, "{chrome}"); + assert_eq!(entry.origin, EventOrigin::Backend, "{chrome}"); + } assert_eq!( EventClassification::find("notification:created") .unwrap() @@ -142,6 +194,13 @@ fn event_classification_is_exact_and_snapshotted() { "team:message", "team:status_changed", "automation:run_updated", + // Invented chrome names with no emit site and no consumer anywhere in the repo, plus a + // JSDoc `@example` string. Local-only rows are exempt from the emit-site assertion, so + // only this test keeps the allowlist from accumulating phantoms. + "my:event", + "window:focus", + "dock:updated", + "updater:status", ] { assert!( EventClassification::find(stale_name).is_none(), diff --git a/src-tauri/crates/ralphx-remote-protocol/tests/snapshots/event-classifications.json b/src-tauri/crates/ralphx-remote-protocol/tests/snapshots/event-classifications.json index 21e2d0ba21..b0a3aab6b3 100644 --- a/src-tauri/crates/ralphx-remote-protocol/tests/snapshots/event-classifications.json +++ b/src-tauri/crates/ralphx-remote-protocol/tests/snapshots/event-classifications.json @@ -546,27 +546,21 @@ "excludedFromV1": false }, { - "name": "my:event", + "name": "ralphx://check-for-updates", "delivery": "localOnly", - "origin": "webview", - "excludedFromV1": false - }, - { - "name": "window:focus", - "delivery": "localOnly", - "origin": "webview", + "origin": "backend", "excludedFromV1": false }, { - "name": "dock:updated", + "name": "ralphx://show-release-notes", "delivery": "localOnly", - "origin": "webview", + "origin": "backend", "excludedFromV1": false }, { - "name": "updater:status", + "name": "gh-auth:login_prompt", "delivery": "localOnly", - "origin": "webview", + "origin": "backend", "excludedFromV1": false } ] diff --git a/src-tauri/src/remote_server/capture.rs b/src-tauri/src/remote_server/capture.rs index 7cf9012d75..75eb1920bf 100644 --- a/src-tauri/src/remote_server/capture.rs +++ b/src-tauri/src/remote_server/capture.rs @@ -1,5 +1,4 @@ use ralphx_remote_protocol::{EventDelivery, EventOrigin, EVENT_CLASSIFICATIONS}; -use serde_json::Value; use std::sync::mpsc::{self, Receiver, SyncSender, TrySendError}; use tauri::{Listener, Runtime}; @@ -7,10 +6,19 @@ use tauri::{Listener, Runtime}; #[path = "capture_tests.rs"] mod tests; -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub struct CapturedEvent { pub name: &'static str, - pub payload: Value, + /// Raw JSON payload text, exactly as Tauri delivered it. + /// + /// Deliberately unparsed. Tauri invokes `listen_any` callbacks INLINE on the emitting + /// thread (`tauri::event::listener::emit_filter` → `(callback)(Event::new(…))`), so a + /// `serde_json` parse here would run on the emit hot path of every classified event — + /// including `agent:chunk` streaming. §3.4 pins the opposite contract ("parse cost … off + /// the emit hot path"; "capture handlers stay sync and do channel-send"), so parsing + /// belongs to the drain side: PR 1.4's sequencer/broadcast actor. `remote_event_log.payload` + /// is TEXT, so the durable path stores this string without re-serializing it. + pub payload: String, } #[derive(Clone)] @@ -38,7 +46,17 @@ impl CaptureFeed { } } -pub trait EventRegistrar: Clone + Send + Sync + 'static { +/// Which of the two capture seams a classified event feeds. +/// +/// Deliberately narrower than [`EventDelivery`]: `LocalOnly` is filtered out at registration, +/// so the handler cannot be handed a delivery class it has no seam for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CaptureSink { + Durable, + Transient, +} + +pub trait EventRegistrar { fn listen(&self, name: &'static str, handler: Box); } @@ -65,27 +83,40 @@ impl RemoteEventCapture { } pub fn install_with_registrar(registrar: R, feed: CaptureFeed) { - for entry in EVENT_CLASSIFICATIONS - .iter() - .filter(|entry| entry.origin == EventOrigin::Backend && !entry.excluded_from_v1) - { + for entry in EVENT_CLASSIFICATIONS.iter().filter(|entry| { + // Local-only rows never get a handler, whatever their origin. The webview-origin + // filter alone is not enough: §3.4's Local-only category explicitly includes + // backend chrome ("window/dock/updater chrome"), and PR 1.4 adds backend-emitted + // `remote:session_connected`/`remote:session_closed` as Local-only rows + // (02-phase-1-host-mode.md). Filtering on delivery makes the `LocalOnly` arm of the + // handler's match structurally unreachable instead of test-enforced. + entry.origin == EventOrigin::Backend + && entry.delivery != EventDelivery::LocalOnly + && !entry.excluded_from_v1 + }) { let name = entry.name; - let delivery = entry.delivery; + let sink = match entry.delivery { + EventDelivery::Durable => CaptureSink::Durable, + EventDelivery::Transient => CaptureSink::Transient, + // Filtered out above; `continue` rather than `unreachable!` keeps a table edit + // from turning into a panic inside a Tauri event-dispatch callback. + EventDelivery::LocalOnly => continue, + }; let feed = feed.clone(); registrar.listen( name, Box::new(move |raw_payload| { - let Ok(payload) = serde_json::from_str(raw_payload) else { - tracing::warn!( - event_name = name, - "Remote event capture dropped malformed JSON payload" - ); - return; + let event = CapturedEvent { + name, + payload: raw_payload.to_owned(), }; - let event = CapturedEvent { name, payload }; - match delivery { - EventDelivery::Durable => match feed.durable.try_send(event) { + match sink { + CaptureSink::Durable => match feed.durable.try_send(event) { Ok(()) => {} + // PR 1.4: a full durable channel must mark the stream unhealthy and + // signal an epoch roll over the unbounded control channel (§3.4 #3) + // so no dropped event is ever silently spliced over. This warn is a + // pre-sequencer placeholder — it must not ship as the final behavior. Err(TrySendError::Full(_)) => tracing::warn!( event_name = name, "Remote durable capture feed is full" @@ -95,7 +126,7 @@ impl RemoteEventCapture { "Remote durable capture feed is disconnected" ), }, - EventDelivery::Transient => { + CaptureSink::Transient => { if feed.transient.send(event).is_err() { tracing::warn!( event_name = name, @@ -103,7 +134,6 @@ impl RemoteEventCapture { ); } } - EventDelivery::LocalOnly => unreachable!("local events are not registered"), } }), ); @@ -118,8 +148,14 @@ pub fn install_if_host_mode_configured( if !configured { return; } + // Bounded so a wedged consumer can never block the emit thread; overflow means an epoch + // roll, not backpressure (§3.4 #3). let (feed, receivers) = CaptureFeed::channels(1_024); RemoteEventCapture::install(app_handle, feed); + // Placeholder drains: PR 1.4 replaces both receivers with the durable sequencer actor + // (allocate → commit → publish) and the transient live-broadcast channel. Pre-1.4 there is + // no sequencer, listener, or client, so discarded events are unobservable + // (01-phase-0-foundations.md, Open question 2). std::thread::spawn(move || for _ in receivers.durable {}); std::thread::spawn(move || for _ in receivers.transient {}); } diff --git a/src-tauri/src/remote_server/capture_tests.rs b/src-tauri/src/remote_server/capture_tests.rs index bc59716cf5..4654b0de9e 100644 --- a/src-tauri/src/remote_server/capture_tests.rs +++ b/src-tauri/src/remote_server/capture_tests.rs @@ -1,6 +1,5 @@ use super::{CaptureFeed, CapturedEvent, EventRegistrar, RemoteEventCapture}; use ralphx_remote_protocol::{EventClassification, EventDelivery, EVENT_CLASSIFICATIONS}; -use serde_json::json; use std::collections::HashMap; use std::sync::{Arc, Mutex}; @@ -34,14 +33,16 @@ impl RecordingRegistrar { } #[test] -fn installs_once_for_each_backend_non_excluded_event_only() { +fn installs_once_for_each_backend_non_excluded_non_local_event_only() { let registrar = RecordingRegistrar::default(); let (feed, _receivers) = CaptureFeed::channels(16); RemoteEventCapture::install_with_registrar(registrar.clone(), feed); for entry in EVENT_CLASSIFICATIONS { let expected = usize::from( - entry.origin == ralphx_remote_protocol::EventOrigin::Backend && !entry.excluded_from_v1, + entry.origin == ralphx_remote_protocol::EventOrigin::Backend + && entry.delivery != EventDelivery::LocalOnly + && !entry.excluded_from_v1, ); assert_eq!(registrar.count(entry.name), expected, "{}", entry.name); } @@ -49,8 +50,38 @@ fn installs_once_for_each_backend_non_excluded_event_only() { assert_eq!(registrar.count("task:updated"), 0); } +/// Backend-origin Local-only rows exist today (native-menu/gh chrome) and PR 1.4 adds more +/// (`remote:session_connected`/`remote:session_closed`). None may reach a capture seam. #[test] -fn routes_parsed_payloads_to_the_classified_sync_channel() { +fn backend_origin_local_only_entries_register_no_handler() { + let registrar = RecordingRegistrar::default(); + let (feed, receivers) = CaptureFeed::channels(16); + RemoteEventCapture::install_with_registrar(registrar.clone(), feed); + + let backend_local_names = EVENT_CLASSIFICATIONS + .iter() + .filter(|entry| { + entry.delivery == EventDelivery::LocalOnly + && entry.origin == ralphx_remote_protocol::EventOrigin::Backend + }) + .map(|entry| entry.name) + .collect::>(); + assert!( + backend_local_names.contains(&"ralphx://check-for-updates"), + "expected the native-menu chrome events to be classified backend + local-only" + ); + + for name in backend_local_names { + assert_eq!(registrar.count(name), 0, "{name}"); + // Emitting is a no-op precisely because nothing registered a handler. + registrar.emit(name, "{}"); + } + assert!(receivers.durable.try_recv().is_err()); + assert!(receivers.transient.try_recv().is_err()); +} + +#[test] +fn routes_raw_payloads_to_the_classified_sync_channel() { let registrar = RecordingRegistrar::default(); let (feed, receivers) = CaptureFeed::channels(16); RemoteEventCapture::install_with_registrar(registrar.clone(), feed); @@ -62,25 +93,26 @@ fn routes_parsed_payloads_to_the_classified_sync_channel() { receivers.durable.try_recv().unwrap(), CapturedEvent { name: "notification:created", - payload: json!({"id":"n-1"}) + payload: r#"{"id":"n-1"}"#.to_string() } ); assert_eq!( receivers.transient.try_recv().unwrap(), CapturedEvent { name: "agent:chunk", - payload: json!({"text":"hi"}) + payload: r#"{"text":"hi"}"#.to_string() } ); } +/// The handler is channel-send-only (§3.4): it never parses on the emitting thread, so payload +/// validation is the drain side's job (PR 1.4's sequencer). What it must still guarantee is that +/// a full durable channel drops instead of blocking the emit thread. #[test] -fn malformed_payload_and_full_durable_channel_fail_closed_without_blocking() { +fn full_durable_channel_drops_without_blocking_the_emit_thread() { let registrar = RecordingRegistrar::default(); let (feed, receivers) = CaptureFeed::channels(1); RemoteEventCapture::install_with_registrar(registrar.clone(), feed); - registrar.emit("notification:created", "not-json"); - assert!(receivers.durable.try_recv().is_err()); registrar.emit("notification:created", "{}"); registrar.emit("notification:created", "{}"); assert!(receivers.durable.try_recv().is_ok()); @@ -88,13 +120,10 @@ fn malformed_payload_and_full_durable_channel_fail_closed_without_blocking() { } #[test] -fn table_has_no_duplicate_names_and_local_entries_are_not_backend_origin() { +fn table_has_no_duplicate_names() { let mut names = std::collections::HashSet::new(); for entry in EVENT_CLASSIFICATIONS { assert!(names.insert(entry.name), "duplicate {}", entry.name); - if entry.delivery == EventDelivery::LocalOnly { - assert_ne!(entry.origin, ralphx_remote_protocol::EventOrigin::Backend); - } } assert!(EventClassification::find("notification:created").is_some()); } From 30cfb1191e276d63d486204c5faf142a1a8f8d3a Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:41:13 +0300 Subject: [PATCH 057/416] fix(scripts): unstick and harden the emitted-to-consumed manifest gate The manifest --check was red at the integration tip: the bus migration moved AgentTerminalDrawer onto eventBus.subscribe(AGENT_TERMINAL_EVENT, ...) but the const lives in api/terminal.ts, and the consumed scan resolved constants only from lib/events.ts. Follow one level of import instead, so a legal migration cannot strand the manifest. Also: - Fail closed on unrecognised `.subscribe(` receivers. The scan only accepted literal `bus`/`eventBus`, so a renamed or hook-inlined bus silently escaped the census -- the exact hole this gate exists to close. Unknown receivers now hard-fail with a reviewed allowlist (one entry: the TanStack query cache). - Fail closed on cross-object wrapper-method calls: an emit_event whose receiver is not `self` cannot be attributed to a type, so it is unresolved rather than silently skipped. - Publish unclassified_backend_emits in the manifest. verify_manifest only proved consumed-subset-of-classified and classified-implies-emitted, so the narrowing away from section 3.4's glob expansion was invisible; the regenerate-and-diff check now turns any new unclassified backend emit into a CI failure that forces a recorded decision. - Add tests for the consumed-unclassified failure path (acceptance criterion 5, previously unexercised), import resolution, and both subscribe-receiver paths. --- scripts/event-manifest-scanner/src/lib.rs | 308 ++++++++++++++++-- .../event-manifest-scanner/tests/scanner.rs | 87 ++++- scripts/event-manifest.json | 61 +++- 3 files changed, 415 insertions(+), 41 deletions(-) diff --git a/scripts/event-manifest-scanner/src/lib.rs b/scripts/event-manifest-scanner/src/lib.rs index 0f8297dbb0..1307e5917d 100644 --- a/scripts/event-manifest-scanner/src/lib.rs +++ b/scripts/event-manifest-scanner/src/lib.rs @@ -213,6 +213,15 @@ pub struct Manifest { pub false_positive_allowlist: Vec, pub static_event_functions: Vec, pub unmatched_classified_events: Vec, + /// Backend emit names the classification table deliberately does not carry into v1. + /// + /// `verify_manifest` only proves consumed ⊆ classified and classified ⇒ emitted, so without + /// this section the emitted→classified direction is invisible: §3.4 mandates glob expansion + /// (`task:*`, `team:*`, `ticketing:*` … Durable) while the seeded table narrows to + /// UI-consumed ∪ explicitly enumerated names. Publishing the residue makes the narrowing + /// auditable and diffable — the regenerate-and-diff staleness check turns any NEW + /// unclassified backend emit into a CI failure that forces a recorded decision. + pub unclassified_backend_emits: Vec, } #[derive(Debug, thiserror::Error, PartialEq, Eq)] @@ -280,6 +289,18 @@ pub fn build_manifest(root: &Path) -> Result { .map(|entry| entry.name.to_owned()) .collect::>(); verify_manifest(&emitted, &consumed, EVENT_CLASSIFICATIONS)?; + let classified_set = classified + .iter() + .map(String::as_str) + .collect::>(); + let unclassified_backend_emits = emitted + .iter() + .map(|site| site.name.as_str()) + .filter(|name| !classified_set.contains(name)) + .map(ToOwned::to_owned) + .collect::>() + .into_iter() + .collect::>(); Ok(Manifest { schema_version: 1, emitted, @@ -288,6 +309,7 @@ pub fn build_manifest(root: &Path) -> Result { false_positive_allowlist: manifest_false_positives(), static_event_functions: STATIC_EVENT_FUNCTIONS.to_vec(), unmatched_classified_events: reviewed_unmatched_events(), + unclassified_backend_emits, }) } @@ -331,8 +353,12 @@ pub fn scan_production_rust_tree(root: &Path) -> Result> { Ok(emitted) } -fn verify_manifest( - emitted: &[EmitSite], +/// Fails when a UI-consumed event name is absent from the classification table. +/// +/// Exposed (rather than inlined into `verify_manifest`) so the failure direction PR 0.1's +/// acceptance criterion #5 claims — "removing any UI-consumed name from the classification table +/// makes the manifest test fail" — is unit-testable with injected slices. +pub fn verify_consumed_classification( consumed: &[String], classifications: &[EventClassification], ) -> Result<()> { @@ -351,6 +377,15 @@ fn verify_manifest( missing_classifications.join(", ") ); } + Ok(()) +} + +fn verify_manifest( + emitted: &[EmitSite], + consumed: &[String], + classifications: &[EventClassification], +) -> Result<()> { + verify_consumed_classification(consumed, classifications)?; let emitted_names = emitted .iter() @@ -733,18 +768,35 @@ fn collect_call_sites(syntax: &File, file: &str) -> Vec { calls } -fn wrapper_for_method(method: &str, current_function: Option<&str>) -> Option<&'static Wrapper> { +/// Any registered method wrapper whose method name matches, ignoring the owning type. +/// +/// `syn` cannot resolve a receiver's type, so the wrapper match is by method name plus the +/// enclosing-impl guard below. This helper exposes the name-only half so a call that matches the +/// name but fails the guard can be treated as ambiguous instead of silently skipped. +fn method_wrapper_by_name(method: &str) -> Option<&'static Wrapper> { WRAPPERS.iter().find(|wrapper| { wrapper .name .rsplit_once("::") .is_some_and(|(_, name)| name == method) - && (method != "emit_event" - || current_function - .is_some_and(|function| function.starts_with("AppChatService::"))) }) } +fn wrapper_for_method(method: &str, current_function: Option<&str>) -> Option<&'static Wrapper> { + method_wrapper_by_name(method).filter(|_| { + // `emit_event` is not unique: `ExternalMcpSupervisor` and the rule-ingestion service + // both define one whose first argument is a status string, not an event name. Only + // `AppChatService`'s forwards an event name, so the wrapper applies inside that impl. + method != "emit_event" + || current_function.is_some_and(|function| function.starts_with("AppChatService::")) + }) +} + +/// True for a `self.…` / `Self::…` receiver. +fn is_self_receiver(receiver: &Expr) -> bool { + matches!(peel(receiver), Expr::Path(path) if path.path.is_ident("self")) +} + fn wrapper_for_function(function: &FunctionInfo) -> Option<&'static Wrapper> { WRAPPERS .iter() @@ -892,6 +944,26 @@ impl<'ast> Visit<'ast> for EmitVisitor<'_> { }); } } + } else if let Some(wrapper) = method_wrapper_by_name(&node.method.to_string()) { + // The method name matches a registered event-forwarding wrapper but the + // enclosing-impl guard rejected it. On a `self` receiver that is the intended + // same-name-different-type case (`ExternalMcpSupervisor::emit_event`). On any other + // receiver the callee's type is unknown, so a genuine cross-object wrapper call + // would silently vanish from the census — fail closed instead, matching the + // over-approximate-or-fail posture of every other emit shape. + if wrapper.event_arg.is_some() + && !is_self_receiver(&node.receiver) + && self.error.is_none() + { + self.error = Some(ScanError::UnresolvedEmit { + file: self.file.clone(), + line: node.span().start().line, + function: format!( + "cross-object `{}` call: wrapper receiver type is unresolvable", + node.method + ), + }); + } } visit::visit_expr_method_call(self, node); } @@ -1238,9 +1310,18 @@ fn module_path_for_file(root: &Path, path: &Path) -> String { parts.join("::") } +/// Scans a frontend source root (`frontend/src`) for UI-consumed event names. +/// +/// Exposed so the import-resolution and fail-closed behaviours are testable against a real +/// directory layout rather than only through the full manifest build. +pub fn scan_consumed_tree(frontend_src: &Path) -> Result> { + consumed_names(frontend_src) +} + fn consumed_names(root: &Path) -> Result> { let mut names = BTreeSet::new(); let shared_constants = frontend_event_constants(root)?; + let mut module_cache = BTreeMap::new(); for extension in ["ts", "tsx"] { for path in files_with_extension(root, extension)? { if path @@ -1253,11 +1334,16 @@ fn consumed_names(root: &Path) -> Result> { if !source.contains(".subscribe") { continue; } + let mut constants = shared_constants.clone(); + constants.extend( + imported_event_constants(root, &path, &source, &mut module_cache) + .with_context(|| format!("resolve imports of {}", path.display()))?, + ); names.extend( scan_consumed_source_with_constants( &path.display().to_string(), &source, - &shared_constants, + &constants, ) .with_context(|| format!("scan {}", path.display()))?, ); @@ -1266,25 +1352,132 @@ fn consumed_names(root: &Path) -> Result> { Ok(names.into_iter().collect()) } -pub fn scan_consumed_source(file: &str, source: &str) -> Result> { - scan_consumed_source_with_constants(file, source, &BTreeMap::new()) +/// Resolves event-name constants a consumer imports from another frontend module. +/// +/// The consumed scan is fail-closed: an unresolvable `subscribe()` argument is a hard +/// error, not a skip. Before the bus migration every consumed name was a string literal, a +/// file-local const, or a `lib/events.ts` const; migrated consumers now import them from feature +/// modules (`AgentTerminalDrawer.tsx` takes `AGENT_TERMINAL_EVENT` from `api/terminal.ts`), so +/// the scan follows one level of import rather than turning a legal migration into a build break. +fn imported_event_constants( + root: &Path, + file: &Path, + source: &str, + cache: &mut BTreeMap>>, +) -> Result>> { + let mut resolved = BTreeMap::new(); + let tree = parse_tsx(source, &file.display().to_string())?; + let mut imports = Vec::new(); + collect_import_bindings(tree.root_node(), source, &mut imports); + for (specifier, bindings) in imports { + let Some(module_path) = resolve_frontend_module(root, file, &specifier) else { + continue; + }; + if !cache.contains_key(&module_path) { + let module_source = fs::read_to_string(&module_path) + .with_context(|| format!("read {}", module_path.display()))?; + let module_tree = parse_tsx(&module_source, &module_path.display().to_string())?; + let constants = collect_ts_constants(module_tree.root_node(), &module_source); + cache.insert(module_path.clone(), constants); + } + let constants = &cache[&module_path]; + for (exported, local) in bindings { + if let Some(values) = constants.get(&exported) { + resolved.insert(local, values.clone()); + } + } + } + Ok(resolved) +} + +/// Maps a TS module specifier to a file under the frontend source root. +/// +/// Handles the `@/` alias and relative specifiers only; bare package specifiers resolve to +/// `node_modules` and can never define a RalphX event name. +fn resolve_frontend_module(root: &Path, file: &Path, specifier: &str) -> Option { + let base = if let Some(rest) = specifier.strip_prefix("@/") { + root.join(rest) + } else if specifier.starts_with("./") || specifier.starts_with("../") { + file.parent()?.join(specifier) + } else { + return None; + }; + ["ts", "tsx"] + .into_iter() + .map(|extension| base.with_extension(extension)) + .chain( + ["index.ts", "index.tsx"] + .into_iter() + .map(|entry| base.join(entry)), + ) + .find(|candidate| candidate.is_file()) } -fn scan_consumed_source_with_constants( - file: &str, - source: &str, - shared_constants: &BTreeMap>, -) -> Result> { +/// Collects `(module specifier, [(exported name, local binding)])` for every named import. +fn collect_import_bindings(node: Node<'_>, source: &str, output: &mut Vec<(String, Vec)>) { + if node.kind() == "import_statement" { + if let Some(specifier) = node + .child_by_field_name("source") + .and_then(|child| string_value(child, source)) + { + let mut bindings = Vec::new(); + collect_import_specifiers(node, source, &mut bindings); + if !bindings.is_empty() { + output.push((specifier, bindings)); + } + } + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + collect_import_bindings(child, source, output); + } +} + +/// `(exported name, local binding)` — they differ under `import { A as B }`. +type Names = (String, String); + +fn collect_import_specifiers(node: Node<'_>, source: &str, output: &mut Vec) { + if node.kind() == "import_specifier" { + if let Some(name) = node.child_by_field_name("name") { + let exported = node_text(name, source).to_owned(); + let local = node.child_by_field_name("alias").map_or_else( + || exported.clone(), + |alias| node_text(alias, source).to_owned(), + ); + output.push((exported, local)); + } + return; + } + let mut cursor = node.walk(); + for child in node.children(&mut cursor) { + collect_import_specifiers(child, source, output); + } +} + +fn parse_tsx(source: &str, label: &str) -> Result { let mut parser = Parser::new(); parser .set_language(&LANGUAGE_TSX.into()) - .map_err(|error| anyhow::anyhow!("{file}: configure TSX parser: {error}"))?; + .map_err(|error| anyhow::anyhow!("{label}: configure TSX parser: {error}"))?; let tree = parser .parse(source, None) - .ok_or_else(|| anyhow::anyhow!("{file}: TSX parser returned no tree"))?; + .ok_or_else(|| anyhow::anyhow!("{label}: TSX parser returned no tree"))?; if tree.root_node().has_error() { - bail!("{file}: invalid TS/TSX source") + bail!("{label}: invalid TS/TSX source") } + Ok(tree) +} + +pub fn scan_consumed_source(file: &str, source: &str) -> Result> { + scan_consumed_source_with_constants(file, source, &BTreeMap::new()) +} + +fn scan_consumed_source_with_constants( + file: &str, + source: &str, + shared_constants: &BTreeMap>, +) -> Result> { + let tree = parse_tsx(source, file)?; let mut constants = shared_constants.clone(); constants.extend(collect_ts_constants(tree.root_node(), source)); let mut values = BTreeSet::new(); @@ -1302,16 +1495,7 @@ fn scan_consumed_source_with_constants( fn frontend_event_constants(root: &Path) -> Result>> { let path = root.join("lib/events.ts"); let source = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?; - let mut parser = Parser::new(); - parser - .set_language(&LANGUAGE_TSX.into()) - .map_err(|error| anyhow::anyhow!("configure TSX parser: {error}"))?; - let tree = parser - .parse(&source, None) - .ok_or_else(|| anyhow::anyhow!("parse {} returned no tree", path.display()))?; - if tree.root_node().has_error() { - bail!("{}: invalid TypeScript event constants", path.display()); - } + let tree = parse_tsx(&source, &path.display().to_string())?; Ok(collect_ts_constants(tree.root_node(), &source)) } @@ -1384,6 +1568,19 @@ fn collect_subscriptions( } return Ok(()); } + if subscribe_receiver(node, source) == Some(SubscribeReceiver::Unknown) { + let receiver = node + .child_by_field_name("function") + .and_then(|function| function.child_by_field_name("object")) + .map_or_else(String::new, |object| node_text(object, source).to_owned()); + bail!( + "{file}:{} unrecognised `.subscribe(` receiver `{receiver}`: name event-bus \ + receivers `bus`/`eventBus` (or call `useEventBus()` inline) so consumed event \ + names stay auditable, or add the receiver to FOREIGN_SUBSCRIBE_ALLOWLIST with \ + a reason", + node.start_position().row + 1 + ); + } if is_event_bus_subscribe(node, source) { let argument = first_argument(node).ok_or_else(|| { anyhow::anyhow!( @@ -1448,19 +1645,58 @@ fn callback_parameter(callback: Node<'_>, source: &str) -> Option { (pattern.kind() == "identifier").then(|| node_text(pattern, source).to_owned()) } -fn is_event_bus_subscribe(node: Node<'_>, source: &str) -> bool { - let Some(function) = node.child_by_field_name("function") else { - return false; - }; +/// Receiver expressions that are known NOT to be the app event bus. +/// +/// Reviewed entries only. A genuine non-event-bus `subscribe` (a store, an observable) belongs +/// here with a reason; anything else hard-fails so a renamed or destructured bus cannot silently +/// escape the consumed-name census. +const FOREIGN_SUBSCRIBE_ALLOWLIST: &[(&str, &str)] = &[( + "queryClient.getQueryCache()", + "TanStack Query cache subscription (useSyncExternalStore), carries no event name", +)]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SubscribeReceiver { + /// The app event bus (`bus`/`eventBus`, or a direct `useEventBus()`/`getEventBus()` call). + EventBus, + /// A reviewed non-event-bus `subscribe` receiver; deliberately ignored. + Foreign, + /// A `.subscribe(` whose receiver the scan cannot prove is not the event bus. + Unknown, +} + +/// Classifies a `.subscribe(` call site. +/// +/// Fail-closed by design (mirrors the emit side's over-approximate-or-fail contract): an +/// unrecognised receiver is reported as [`SubscribeReceiver::Unknown`] and hard-fails the scan, +/// because a renamed/destructured bus that silently escaped the census is exactly the +/// "unclassified UI-consumed event name = undefined behaviour" hole this gate exists to close. +fn subscribe_receiver(node: Node<'_>, source: &str) -> Option { + let function = node.child_by_field_name("function")?; if function.kind() != "member_expression" || member_property(function, source) != Some("subscribe") { - return false; + return None; } - let Some(object) = function.child_by_field_name("object") else { - return false; - }; - matches!(node_text(object, source), "bus" | "eventBus") + let object = function.child_by_field_name("object")?; + let text = node_text(object, source); + if matches!(text, "bus" | "eventBus" | "useEventBus()" | "getEventBus()") { + return Some(SubscribeReceiver::EventBus); + } + if FOREIGN_SUBSCRIBE_ALLOWLIST + .iter() + .any(|(receiver, reason)| { + debug_assert!(!reason.is_empty()); + *receiver == text + }) + { + return Some(SubscribeReceiver::Foreign); + } + Some(SubscribeReceiver::Unknown) +} + +fn is_event_bus_subscribe(node: Node<'_>, source: &str) -> bool { + subscribe_receiver(node, source) == Some(SubscribeReceiver::EventBus) } fn member_property<'a>(node: Node<'a>, source: &'a str) -> Option<&'a str> { diff --git a/scripts/event-manifest-scanner/tests/scanner.rs b/scripts/event-manifest-scanner/tests/scanner.rs index 55c033260b..7a486be906 100644 --- a/scripts/event-manifest-scanner/tests/scanner.rs +++ b/scripts/event-manifest-scanner/tests/scanner.rs @@ -1,7 +1,8 @@ use event_manifest_scanner::{ reviewed_unmatched_events, scan_consumed_source, scan_production_rust_tree, scan_rust_source, - verify_unmatched_event_coverage, ScanError, + verify_consumed_classification, verify_unmatched_event_coverage, ScanError, }; +use ralphx_remote_protocol::EVENT_CLASSIFICATIONS; fn names(source: &str) -> Vec { scan_rust_source("fixture.rs", source) @@ -218,3 +219,87 @@ fn rejects_new_unreviewed_unmatched_classification() { .expect_err("unknown unmatched event must fail CI"); assert!(error.to_string().contains("no reviewed gap entry")); } + +/// PR 0.1 acceptance #5: removing a UI-consumed name from the classification table must fail. +#[test] +fn rejects_consumed_names_absent_from_the_classification_table() { + verify_consumed_classification(&["notification:created".to_owned()], EVENT_CLASSIFICATIONS) + .expect("a classified consumed name passes"); + + let stripped = EVENT_CLASSIFICATIONS + .iter() + .filter(|entry| entry.name != "notification:created") + .copied() + .collect::>(); + let error = verify_consumed_classification(&["notification:created".to_owned()], &stripped) + .expect_err("an unclassified consumed name must fail the manifest"); + let message = error.to_string(); + assert!( + message.contains("UI-consumed event names are unclassified"), + "{message}" + ); + assert!(message.contains("notification:created"), "{message}"); +} + +/// Regression: PR 1.8 moved consumers onto the bus with the event-name const living in a feature +/// module (`api/terminal.ts`), which the lib/events.ts-only resolver could not follow. +#[test] +fn resolves_event_name_constants_imported_from_other_frontend_modules() { + let root = tempfile::tempdir().expect("temp frontend root"); + let src = root.path(); + std::fs::create_dir_all(src.join("lib")).expect("lib dir"); + std::fs::create_dir_all(src.join("api")).expect("api dir"); + std::fs::create_dir_all(src.join("components")).expect("components dir"); + std::fs::write( + src.join("lib/events.ts"), + "export const SHARED_EVENT = \"notification:created\";\n", + ) + .expect("shared constants"); + std::fs::write( + src.join("api/terminal.ts"), + "export const AGENT_TERMINAL_EVENT = \"agent_terminal:event\";\n", + ) + .expect("feature module constants"); + std::fs::write( + src.join("components/Drawer.tsx"), + "import { AGENT_TERMINAL_EVENT as TERMINAL } from \"@/api/terminal\";\n\ + import { SHARED_EVENT } from \"@/lib/events\";\n\ + bus.subscribe(TERMINAL, () => undefined);\n\ + bus.subscribe(SHARED_EVENT, () => undefined);\n", + ) + .expect("consumer"); + + let names = event_manifest_scanner::scan_consumed_tree(src).expect("consumed scan resolves"); + assert_eq!( + names, + vec![ + "agent_terminal:event".to_owned(), + "notification:created".to_owned() + ] + ); +} + +#[test] +fn rejects_unrecognised_subscribe_receivers() { + let error = scan_consumed_source( + "renamed_bus.ts", + "const events = useEventBus();\nevents.subscribe(\"task:created\", () => undefined);\n", + ) + .expect_err("an unrecognised subscribe receiver must fail closed"); + let message = error.to_string(); + assert!( + message.contains("unrecognised `.subscribe(` receiver"), + "{message}" + ); + assert!(message.contains("events"), "{message}"); +} + +#[test] +fn accepts_the_reviewed_foreign_subscribe_receiver() { + let names = scan_consumed_source( + "query_cache.ts", + "const unsubscribe = queryClient.getQueryCache().subscribe(onStoreChange);\n", + ) + .expect("the reviewed non-event-bus receiver is ignored"); + assert!(names.is_empty()); +} diff --git a/scripts/event-manifest.json b/scripts/event-manifest.json index 79ea780472..ea75c99c6d 100644 --- a/scripts/event-manifest.json +++ b/scripts/event-manifest.json @@ -1720,6 +1720,7 @@ "agent:usage_updated", "agent:workflow_progress", "agent:workspace_changed", + "agent_terminal:event", "automation:deleted", "automation:run:updated", "automation:updated", @@ -1730,6 +1731,7 @@ "execution:status_changed", "execution:stderr", "file:change", + "gh-auth:login_prompt", "ideation:child_session_created", "ideation:finalize_pending_confirmation", "ideation:session_accepted", @@ -1762,6 +1764,8 @@ "proposals:reordered", "qa:prep", "qa:test", + "ralphx://check-for-updates", + "ralphx://show-release-notes", "recovery:prompt", "review:update", "session:priorities_assessed", @@ -1877,10 +1881,9 @@ "permission:resolved", "agent_terminal:event", "task:updated", - "my:event", - "window:focus", - "dock:updated", - "updater:status" + "ralphx://check-for-updates", + "ralphx://show-release-notes", + "gh-auth:login_prompt" ], "false_positive_allowlist": [ { @@ -1975,5 +1978,55 @@ "reason_code": "no-tauri-emitter", "reason": "frontend consumer exists; no current Tauri event producer" } + ], + "unclassified_backend_emits": [ + "artifact:archived", + "execution:active_project_changed", + "execution:completed", + "execution:spawn_blocked", + "external-mcp:status", + "git-auth:startup_preflight", + "ideation:plan_created", + "ideation:session_imported", + "ideation:session_reopened", + "issue:updated", + "merge:completed", + "merge:conflict", + "merge:incomplete", + "persona:draft_applied", + "plan:proposals_may_need_update", + "plan_complexity:assessed", + "project:archived", + "project:created", + "proposal:archived", + "qa_failed", + "qa_passed", + "review:action_failed", + "review:ai_approved", + "review:completed", + "review:escalated", + "review:human_approved", + "review:human_changes_requested", + "review:re_review_requested", + "review:state_exited", + "settings:execution:updated", + "settings:global_execution:updated", + "task:cancelled", + "task:custom", + "task:list_changed", + "task:merged", + "task:on_enter_error", + "task:paused", + "task:provider_error_resuming", + "task:recovery_failed", + "task:restarted", + "task:resumed", + "task:stopped", + "task:unblocked", + "task_completed", + "task_failed", + "team:artifact_created", + "ticketing:operation_updated", + "verification:pending_confirmation" ] } From 1d1a9e9e1aed8efb7febae5121546771af430c4f Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:41:22 +0300 Subject: [PATCH 058/416] fix(remote): parse real logged-out tailscale status and surface CLI failures tailscale_ips was Vec with serde(default), which covers an absent key but not an explicit null. Go marshals the nil TailscaleIPs slice with no omitempty, so a NeedsLogin/Stopped daemon emits "TailscaleIPs": null and parse_status returned Unavailable instead of an empty address list -- breaking the "logged out yields empty endpoints, never an error" rule. The checked-in logged-out fixture omitted Self entirely, so tests passed while production misbehaved; add the real shape. Also: - run_status ignored the exit status and discarded stderr, so a down daemon surfaced as "invalid tailscale status JSON: EOF" instead of its actionable stderr. Check success first and fold a bounded stderr snippet into the error; do the same for failed serve commands, which otherwise report only "exit status: 1". - self_addresses returned Ok(empty) when the CLI was missing, conflating "not installed" with "owns no tailnet address" -- the exact conflation TailnetProviderError exists to prevent. Return the typed provider error. --- src-tauri/src/infrastructure/tailscale.rs | 55 ++++++++++++++++--- .../src/infrastructure/tailscale_tests.rs | 29 ++++++++++ 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/infrastructure/tailscale.rs b/src-tauri/src/infrastructure/tailscale.rs index 631e89e054..2f310565fa 100644 --- a/src-tauri/src/infrastructure/tailscale.rs +++ b/src-tauri/src/infrastructure/tailscale.rs @@ -51,11 +51,21 @@ pub(crate) struct RealTailscaleCommandRunner; impl TailscaleCommandRunner for RealTailscaleCommandRunner { async fn run_status(&self) -> Result { let path = find_tailscale_cli_path().ok_or_else(|| { - TailnetProviderError::Unavailable("tailscale CLI disappeared after resolution".into()) + TailnetProviderError::Unavailable("tailscale CLI is unavailable".into()) })?; let output = run_command(path, status_args()) .await .map_err(TailscaleProcessError::into_provider_error)?; + if !output.success { + // A down daemon exits non-zero with an empty stdout and an actionable stderr + // ("failed to connect to local tailscaled..."). Parsing that as JSON would surface + // the useless "invalid tailscale status JSON: EOF" instead. + return Err(TailnetProviderError::Unavailable(format!( + "tailscale status failed with {}{}", + output.status, + stderr_suffix(&output.stderr) + ))); + } Ok(output.stdout) } @@ -73,9 +83,9 @@ pub(crate) struct TailscaleSelfAddressProvider; #[async_trait] impl TailnetSelfAddressProvider for TailscaleSelfAddressProvider { async fn self_addresses(&self) -> Result, TailnetProviderError> { - if find_tailscale_cli_path().is_none() { - return Ok(Vec::new()); - } + // "Tailscale is not installed" is a provider failure, not "this host owns no tailnet + // address" — the whole point of `TailnetProviderError`. Both refuse the bind, but only + // the typed error lets PR 1.7's pane say "install Tailscale" instead of "log in". let stdout = RealTailscaleCommandRunner.run_status().await?; Ok(parse_status(&stdout)?.self_addresses()) } @@ -109,16 +119,23 @@ struct TailscaleSelfStatus { #[allow(dead_code)] #[serde(rename = "DNSName", default)] dns_name: String, + /// `Option` rather than a bare `Vec`: Go marshals `ipnstate.PeerStatus.TailscaleIPs` with no + /// `omitempty`, so a logged-out/stopped daemon emits `"TailscaleIPs": null` — and + /// `#[serde(default)]` only covers an ABSENT key, not an explicit null. Deserializing that + /// as a hard error would turn "logged out" into `Unavailable`, breaking §5.3's + /// "logged-out ⇒ empty endpoint list, never an error". #[serde(rename = "TailscaleIPs", default)] - tailscale_ips: Vec, + tailscale_ips: Option>, } impl TailscaleStatus { pub(crate) fn self_addresses(&self) -> Vec { self.self_status .as_ref() + .and_then(|status| status.tailscale_ips.as_ref()) .into_iter() - .flat_map(|status| status.tailscale_ips.iter().copied()) + .flatten() + .copied() .filter(|address| matches!(address, IpAddr::V4(ip) if is_tailnet_cgnat_ipv4(*ip))) .collect() } @@ -209,12 +226,33 @@ async fn run_serve_command(args: Vec) -> Result<(), TailscaleServeError> if output.success { Ok(()) } else { - Err(TailscaleServeError::Exit(output.status)) + // Serve failures print the actionable reason on stderr ("Serve is not enabled on your + // tailnet", HTTPS not enabled, …). Without it the user sees only "exit status: 1". + Err(TailscaleServeError::Exit(format!( + "{}{}", + output.status, + stderr_suffix(&output.stderr) + ))) + } +} + +/// Renders a bounded stderr snippet for an error message, or nothing when stderr was silent. +fn stderr_suffix(stderr: &str) -> String { + const MAX_STDERR: usize = 400; + let trimmed = stderr.trim(); + if trimmed.is_empty() { + return String::new(); } + let snippet = trimmed + .char_indices() + .nth(MAX_STDERR) + .map_or(trimmed, |(index, _)| &trimmed[..index]); + format!(": {snippet}") } struct CommandOutput { stdout: String, + stderr: String, success: bool, status: String, } @@ -262,10 +300,11 @@ async fn run_command( let (stdout, stderr, status) = tokio::join!(read_stream(stdout), read_stream(stderr), child.wait()); let stdout = stdout.map_err(TailscaleProcessError::Output)?; - stderr.map_err(TailscaleProcessError::Output)?; + let stderr = stderr.map_err(TailscaleProcessError::Output)?; let status = status.map_err(|error| TailscaleProcessError::Output(error.to_string()))?; Ok(CommandOutput { stdout, + stderr, success: status.success(), status: status.to_string(), }) diff --git a/src-tauri/src/infrastructure/tailscale_tests.rs b/src-tauri/src/infrastructure/tailscale_tests.rs index 2be55f4a4d..2f81c10f25 100644 --- a/src-tauri/src/infrastructure/tailscale_tests.rs +++ b/src-tauri/src/infrastructure/tailscale_tests.rs @@ -39,6 +39,25 @@ const LOGGED_OUT_STATUS: &str = r#"{ "CurrentTailnet": null }"#; +/// The shape a real logged-out/stopped daemon actually emits: `Self` is present and its +/// `TailscaleIPs` is an explicit `null`, because Go marshals the nil slice with no `omitempty`. +/// `#[serde(default)]` does not cover an explicit null, so this is the fixture that catches a +/// regression back to `Vec`. +const LOGGED_OUT_STATUS_WITH_NULL_IPS: &str = r#"{ + "Version": "1.66.1", + "BackendState": "Stopped", + "Self": { + "ID": "n1234567890CNTRL", + "HostName": "mac-studio", + "DNSName": "", + "OS": "macOS", + "TailscaleIPs": null, + "Online": false + }, + "MagicDNSSuffix": "", + "CurrentTailnet": null +}"#; + #[derive(Clone, Default)] struct RecordingTailscaleCommandRunner { calls: Arc>>>, @@ -86,6 +105,16 @@ fn logged_out_status_is_valid_and_has_no_self_addresses() { assert!(status.self_addresses().is_empty()); } +/// §5.3: a logged-out host degrades to an empty endpoint list, never a provider error. +#[test] +fn logged_out_status_with_null_tailscale_ips_parses_to_no_self_addresses() { + let status = + parse_status(LOGGED_OUT_STATUS_WITH_NULL_IPS).expect("explicit null TailscaleIPs parses"); + + assert_eq!(status.magicdns_name(), None); + assert!(status.self_addresses().is_empty()); +} + #[test] fn malformed_or_unexpected_status_is_unavailable() { assert!(matches!( From 47f8cdac157844db1cc0c8bc54903cbc9c76d30b Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:41:32 +0300 Subject: [PATCH 059/416] fix(remote): advertise plain HTTP for tailnet-direct endpoints advertised_endpoints built https://{ip}:{port} for direct exposure, but the listener is plain-HTTP axum with no TLS acceptor and section 4.4 assigns direct-mode confidentiality to WireGuard, not app TLS. A client dialling the advertised URL would always fail its handshake against a plaintext socket, and the test enshrined the wrong scheme so PR 1.7 would have wired up an unusable URL. Serve keeps https, since only Serve terminates TLS at the tailnet edge. Also cover the Serve "configured but unreachable" branch (available: false), which no test exercised, and drop a redundant ToString import. --- src-tauri/src/remote_server/endpoints.rs | 15 +++++++++-- .../src/remote_server/endpoints_tests.rs | 25 +++++++++++++++++-- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/remote_server/endpoints.rs b/src-tauri/src/remote_server/endpoints.rs index ab086b77ce..3d405487ae 100644 --- a/src-tauri/src/remote_server/endpoints.rs +++ b/src-tauri/src/remote_server/endpoints.rs @@ -3,8 +3,8 @@ //! The environment descriptor is deliberately minimal: it is the one pre-auth response a //! stranger can read, so it publishes identity and version negotiation data only (§3.1, §4.6). +use std::net::Ipv4Addr; use std::sync::Arc; -use std::{net::Ipv4Addr, string::ToString}; use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; use ralphx_remote_protocol::{EnvironmentDescriptor, PROTOCOL_VERSION}; @@ -62,6 +62,15 @@ pub(crate) struct AdvertisedEndpoint { } /// Describes remote URLs from already-observed reachability facts without granting access. +/// +/// Scheme per mode is a transport fact, not a preference: only Serve terminates TLS at the +/// tailnet edge, so it advertises `https://`. The listener itself is plain-HTTP axum +/// with no TLS acceptor, and §4.4 assigns direct-mode confidentiality to WireGuard, not to app +/// TLS ("the Rust backend terminates plain HTTP"), so direct exposure advertises `http://` — +/// an `https://` direct URL would always fail its handshake against the plaintext socket. +/// +/// `port` is the port the listener is actually bound on; callers must pass the bound port, not +/// the persisted setting, because `RALPHX_REMOTE_PORT` can override it. // Consumed by PR 1.7's Remote Access pane (endpoint list). #[allow(dead_code)] pub(crate) fn advertised_endpoints( @@ -72,6 +81,8 @@ pub(crate) fn advertised_endpoints( tailnet_self_ip: Option, ) -> Vec { match exposure_mode { + // Defensive re-normalization: `TailscaleStatus::magicdns_name()` already trims the + // trailing dot, but this function also takes names from callers/settings. RemoteExposureMode::Serve => magicdns_name .map(str::trim) .map(|name| name.trim_end_matches('.')) @@ -86,7 +97,7 @@ pub(crate) fn advertised_endpoints( RemoteExposureMode::TailnetDirect => tailnet_self_ip .map(|address| AdvertisedEndpoint { kind: AdvertisedEndpointKind::TailnetDirect, - url: format!("https://{address}:{port}"), + url: format!("http://{address}:{port}"), available: true, }) .into_iter() diff --git a/src-tauri/src/remote_server/endpoints_tests.rs b/src-tauri/src/remote_server/endpoints_tests.rs index 8afb74b2bd..519d1d6c28 100644 --- a/src-tauri/src/remote_server/endpoints_tests.rs +++ b/src-tauri/src/remote_server/endpoints_tests.rs @@ -21,13 +21,34 @@ fn serve_mode_advertises_resolved_magicdns_reachability() { ); } +/// The branch PR 1.7's pane renders as "configured but unreachable". +#[test] +fn serve_mode_with_unreachable_magicdns_advertises_an_unavailable_endpoint() { + assert_eq!( + advertised_endpoints( + RemoteExposureMode::Serve, + 3849, + Some("mac-studio.tail1234.ts.net."), + false, + None, + ), + vec![AdvertisedEndpoint { + kind: AdvertisedEndpointKind::LoopbackServe, + url: "https://mac-studio.tail1234.ts.net".to_string(), + available: false, + }] + ); +} + #[test] fn serve_mode_without_magicdns_degrades_to_no_endpoint() { assert!(advertised_endpoints(RemoteExposureMode::Serve, 3849, None, false, None).is_empty()); } +/// Direct exposure is plain HTTP over WireGuard (§4.4): the listener has no TLS acceptor, so an +/// `https://` direct URL would always fail its handshake. #[test] -fn tailnet_direct_mode_advertises_the_self_ip_and_listener_port() { +fn tailnet_direct_mode_advertises_a_plain_http_self_ip_and_listener_port() { assert_eq!( advertised_endpoints( RemoteExposureMode::TailnetDirect, @@ -38,7 +59,7 @@ fn tailnet_direct_mode_advertises_the_self_ip_and_listener_port() { ), vec![AdvertisedEndpoint { kind: AdvertisedEndpointKind::TailnetDirect, - url: "https://100.101.102.103:3849".to_string(), + url: "http://100.101.102.103:3849".to_string(), available: true, }] ); From 2d5d8087cbbf9fb86fa185c9382eb1ce97c33210 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:41:32 +0300 Subject: [PATCH 060/416] fix(remote): reject port 0 at the settings read and stamp updated_at read_settings accepted port 0 from the DB and effective_remote_port passed it through, so a zero port reached TcpListener::bind and silently bound an OS-assigned ephemeral port -- on the tailnet CGNAT address in direct mode. The migration CHECK blocks it, but path/bind sinks need sink-local proof rather than schema provenance, and the env-override path already rejects zero. Also: set_enabled and set_exposure_mode never touched updated_at, leaving it permanently equal to created_at, and refresh the stale provider docs plus the environment-id permanence and PR 1.4 high-water contracts. --- src-tauri/src/remote_server/settings.rs | 46 ++++++++++++++++++------- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/src-tauri/src/remote_server/settings.rs b/src-tauri/src/remote_server/settings.rs index 452170c83b..375bbc8041 100644 --- a/src-tauri/src/remote_server/settings.rs +++ b/src-tauri/src/remote_server/settings.rs @@ -48,6 +48,10 @@ pub(crate) struct RemoteHostSettings { } /// SQLite-backed singleton settings and stable host identity for remote access. +/// +/// PR 1.4 adds the durable seq high-water to this same singleton row and must write it inside +/// the SAME `run_transaction` as each `remote_event_log` batch commit (§3.4 table note), so a +/// committed seq and its persisted high-water can never disagree. pub(crate) struct RemoteHostSettingsStore { db: DbConnection, } @@ -78,7 +82,10 @@ impl RemoteHostSettingsStore { .run_transaction(move |conn| { ensure_settings_row(conn)?; conn.execute( - "UPDATE remote_host_settings SET enabled = ?1 WHERE id = ?2", + "UPDATE remote_host_settings + SET enabled = ?1, + updated_at = strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now') + WHERE id = ?2", rusqlite::params![i64::from(enabled), SETTINGS_ROW_ID], ) .map_err(|error| AppError::Database(error.to_string()))?; @@ -96,7 +103,10 @@ impl RemoteHostSettingsStore { .run_transaction(move |conn| { ensure_settings_row(conn)?; conn.execute( - "UPDATE remote_host_settings SET exposure_mode = ?1 WHERE id = ?2", + "UPDATE remote_host_settings + SET exposure_mode = ?1, + updated_at = strftime('%Y-%m-%dT%H:%M:%S+00:00', 'now') + WHERE id = ?2", rusqlite::params![exposure_mode.as_db_value(), SETTINGS_ROW_ID], ) .map_err(|error| AppError::Database(error.to_string()))?; @@ -111,6 +121,10 @@ fn ensure_settings_row(conn: &Connection) -> AppResult { return Ok(settings); } + // Permanent host identity. Pairings, per-environment client caches, and stream cursors all + // bind to it (§3.1 descriptor, §3.2 "client discards any cursor whose environmentId ≠ + // hello.environmentId"), so a future "reset remote access" must NOT re-mint it — that would + // silently orphan every paired device. let environment_id = Uuid::new_v4().to_string(); conn.execute( "INSERT INTO remote_host_settings ( @@ -135,12 +149,12 @@ fn read_settings_row(conn: &Connection) -> AppResult { /// Failure to read the host's tailnet membership. /// -/// The real `tailscale status --json` provider arrives in PR 1.6; this type exists so a -/// provider failure can never be confused with "this host owns no tailnet address". +/// This type exists so a provider failure (CLI missing, daemon down, unparseable status) can +/// never be confused with "this host owns no tailnet address" — the logged-out case, which must +/// degrade to an empty address list rather than an error (§5.3). #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] pub(crate) enum TailnetProviderError { - /// Constructed by PR 1.6's `tailscale status --json` provider; the pre-1.6 stub never fails. - #[allow(dead_code)] + /// Constructed by `infrastructure::tailscale`'s `tailscale status --json` provider. #[error("tailnet status is unavailable: {0}")] Unavailable(String), } @@ -165,9 +179,9 @@ pub(crate) enum RemoteBindError { /// Source of this host's own tailnet addresses. /// -/// PR 1.6 replaces the stub implementation with a `tailscale status --json` provider resolved -/// through the shared production CLI resolver; the seam exists now so the bind policy is -/// testable and direct exposure stays refused until that provider lands. +/// Production implementation: `infrastructure::tailscale::TailscaleSelfAddressProvider` (PR 1.6 +/// — `tailscale status --json` through the shared production CLI resolver). The seam stays so +/// the §4.4 bind policy is testable without a live tailnet. #[async_trait::async_trait] pub(crate) trait TailnetSelfAddressProvider: Send + Sync { async fn self_addresses(&self) -> Result, TailnetProviderError>; @@ -315,9 +329,17 @@ fn read_settings(conn: &Connection) -> AppResult> { match result { Ok((enabled, exposure_mode, port, environment_id)) => { let exposure_mode = RemoteExposureMode::from_db_value(&exposure_mode)?; - let port = u16::try_from(port).map_err(|_| { - AppError::Database(format!("invalid remote host settings port: {port}")) - })?; + let port = u16::try_from(port) + .ok() + // Port 0 reaches `TcpListener::bind` as "pick any ephemeral port" — on the + // tailnet CGNAT address in direct mode. The migration CHECK blocks it, but the + // bind sink needs sink-local proof (hand-restored DBs, `PRAGMA + // ignore_check_constraints`, foreign-tool-created files), and the env override + // path already rejects zero (`RemotePortOverrideError::Zero`). + .filter(|port| *port != 0) + .ok_or_else(|| { + AppError::Database(format!("invalid remote host settings port: {port}")) + })?; Uuid::parse_str(&environment_id).map_err(|error| { AppError::Database(format!("invalid remote host environment id: {error}")) })?; From f4a02dd78704d8cd40784033bbfd6518009a59a3 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:41:41 +0300 Subject: [PATCH 061/416] docs(remote): retarget the capture seam owner and record two spec-only rules The capture install seam still said "PR 1.1 replaces this constant-false seam", but PR 1.1 is merged and deliberately did not own it -- the phase doc assigns app-setup capture plus sequencer wiring to PR 1.4. Left as-is the comment reads as forgotten work and invites an early, DB-unsafe wiring at a call site that runs before SQLite open. Also record two constraints that live only in the spec: why OPTIONS must stay pre-auth in the auth slot (preflight carries no Authorization header, and desktop tests can never catch a regression because the desktop client is Rust-proxied), and that RemoteListenerStatus.port is the configured port while RALPHX_REMOTE_PORT surfaces only through bind_address. --- src-tauri/src/application/app_setup.rs | 9 ++++++++- src-tauri/src/commands/remote_host_commands.rs | 4 ++++ src-tauri/src/remote_server/mod.rs | 4 ++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/application/app_setup.rs b/src-tauri/src/application/app_setup.rs index cfe4924972..685435aad9 100644 --- a/src-tauri/src/application/app_setup.rs +++ b/src-tauri/src/application/app_setup.rs @@ -177,7 +177,14 @@ pub(crate) fn run_app_setup( ) -> Result<(), Box> { let app_handle = app.handle().clone(); - // PR 1.1 replaces this constant-false seam with the persisted remote_host setting. + // Constant-false seam, still unwired ON PURPOSE — PR 1.1 (merged) mints the + // `remote_host_settings` row but does NOT own this call site. PR 1.4 owns it: it replaces + // the constant with the persisted host-mode-configured read AND installs the durable + // sequencer behind it (02-phase-1-host-mode.md, "Wire capture (PR 0.1 bank) + sequencer + // installation into app setup, gated on host-mode-configured"; §3.4 capture-at-setup, P-23). + // Note for that PR: this runs BEFORE SQLite open/migration, so the settings read has to move + // into the async setup phase (next to `auto_start_remote_listener_from_handle` below). + // Until then capture never installs, and the durable feed is a no-op drain regardless. crate::remote_server::capture::install_if_host_mode_configured(app_handle.clone(), false); configure_bundled_runtime_env(app); diff --git a/src-tauri/src/commands/remote_host_commands.rs b/src-tauri/src/commands/remote_host_commands.rs index 013bbb1a68..9688677952 100644 --- a/src-tauri/src/commands/remote_host_commands.rs +++ b/src-tauri/src/commands/remote_host_commands.rs @@ -20,6 +20,10 @@ use crate::AppState; pub struct RemoteListenerStatus { pub enabled: bool, pub exposure_mode: RemoteExposureMode, + /// The CONFIGURED port, not necessarily the bound one: `RALPHX_REMOTE_PORT` overrides the + /// bind (`effective_remote_port`, `remote_server/settings.rs`) and that override surfaces + /// only through `bind_address`. PR 1.7 must derive advertised URLs from `bind_address`, not + /// from this field, or a dev-parity host will advertise a port nothing listens on. pub port: u16, pub environment_id: String, pub running: bool, diff --git a/src-tauri/src/remote_server/mod.rs b/src-tauri/src/remote_server/mod.rs index dc7dfb9cd5..a844f92784 100644 --- a/src-tauri/src/remote_server/mod.rs +++ b/src-tauri/src/remote_server/mod.rs @@ -197,6 +197,10 @@ fn remote_cors_layer() -> CorsLayer { /// PR 1.2 lands bearer extraction, hashing, device lookup, and header stripping here. Until /// then every non-allowlisted route is refused, so no route can accidentally ship unauthenticated. async fn remote_auth_slot(request: Request, next: Next) -> Response { + // OPTIONS must stay pre-auth: browser/mobile CORS preflight carries no Authorization header + // (§3.1), so folding it into the bearer check would 401 every cross-origin mobile request at + // preflight — and desktop tests would not catch it, because the desktop client is + // Rust-proxied and never preflights. PR 1.2 keeps this bypass. if request.method() == Method::OPTIONS { return next.run(request).await; } From 2a9d8a251f04129977fa31bbf090b15ceabf0f84 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:43:46 +0300 Subject: [PATCH 062/416] fix: resolve migration registry merge marker from concurrent sync --- src-tauri/src/infrastructure/sqlite/migrations/mod.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src-tauri/src/infrastructure/sqlite/migrations/mod.rs b/src-tauri/src/infrastructure/sqlite/migrations/mod.rs index d84c8e8cc2..509960be73 100644 --- a/src-tauri/src/infrastructure/sqlite/migrations/mod.rs +++ b/src-tauri/src/infrastructure/sqlite/migrations/mod.rs @@ -1795,11 +1795,7 @@ const MIGRATIONS: &[Migration] = &[ name: "remote_auth", migrate: v20260727180000_remote_auth::migrate, }, -<<<<<<< HEAD Migration { -======= - MigrationEntry { ->>>>>>> a5d6fc5c1df7e1dc76fe2dfd5f6b2482caecb598 version: 20260727191500, name: "remote_environments", migrate: v20260727191500_remote_environments::migrate, From d1c5a222dbd9e63fe46a6422027e556b7c8920dc Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:44:14 +0300 Subject: [PATCH 063/416] fix(frontend): pin the EventBus contract a network implementation must copy The interface is now a swap seam for remote environments, but three parts of its contract were unstated or wrong, and each has a concrete failure mode for the implementation that fills the seam: - `ready` was documented in Tauri terms ("native listener registration"). It is a registration barrier only; gating it on transport connectivity would deadlock the two await-ready producers whenever the transport is in backoff. - `emit()` was labelled "primarily for testing/mock mode" while production uses it as the local re-broadcast channel. Say what it is: local fanout only, never a network write, and webview emits must use Local-only names because host capture cannot distinguish emit origin. - MockEventBus stored raw handler references in a Set, so subscribing one function twice collapsed to a single registration and the first unsubscribe killed the survivor -- a web-mode-only divergence from TauriEventBus. Store per-subscription entries and state the independence rule in the interface. Also record why this file is the sole raw-listen importer, and that Local-only chrome names must be routed to a local bus rather than by reaching around useEventBus(). --- frontend/src/lib/event-bus.test.ts | 16 ++++++++ frontend/src/lib/event-bus.ts | 64 ++++++++++++++++++++++++++---- 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/frontend/src/lib/event-bus.test.ts b/frontend/src/lib/event-bus.test.ts index df47caab95..a448703865 100644 --- a/frontend/src/lib/event-bus.test.ts +++ b/frontend/src/lib/event-bus.test.ts @@ -193,6 +193,22 @@ describe("MockEventBus", () => { bus.clear(); expect(bus.getListenerCount("agent:test")).toBe(0); }); + + it("treats repeated subscriptions of one handler reference as independent", () => { + const bus = new MockEventBus(); + const handler = vi.fn(); + + const first = bus.subscribe("agent:test", handler); + bus.subscribe("agent:test", handler); + expect(bus.getListenerCount("agent:test")).toBe(2); + + // Unsubscribing one must not silently remove the sibling subscription. + first(); + expect(bus.getListenerCount("agent:test")).toBe(1); + + bus.emit("agent:test", "payload"); + expect(handler).toHaveBeenCalledTimes(1); + }); }); describe("createEventBus", () => { diff --git a/frontend/src/lib/event-bus.ts b/frontend/src/lib/event-bus.ts index 58dbbfca01..a084fac2c0 100644 --- a/frontend/src/lib/event-bus.ts +++ b/frontend/src/lib/event-bus.ts @@ -7,6 +7,23 @@ * * This abstraction allows the app to run without Tauri for visual testing * and Playwright automation. + * + * TWO CONSTRAINTS THIS FILE NOW CARRIES: + * + * 1. This is the SOLE module allowed to import raw `listen`/`once` from + * @tauri-apps/api/event. Enforced in CI by + * scripts/check-raw-tauri-event-listen.mjs. Every other consumer goes through + * `useEventBus()`. + * 2. `EventBus` is the swap seam for remote environments: a remote-mode app selects a + * network-backed implementation instead of `TauriEventBus`. Keep the interface + * transport-agnostic — no Tauri-specific semantics in the contract — and keep + * `Unsubscribe` non-throwing with a `ready` that always settles. + * + * Note for the network implementation: names classified Local-only (host chrome such as + * the native-menu updater events) have no remote meaning. They must still reach their + * local subscriber when a remote environment is active, so a network bus should route + * Local-only names to a wrapped local bus rather than have chrome consumers reach around + * `useEventBus()` — reintroducing raw `listen` would erode constraint 1. */ import { listen, emit, type UnlistenFn, type Event } from "@tauri-apps/api/event"; @@ -14,7 +31,13 @@ import { isTauriMode } from "./tauri-detection"; /** * Unsubscribe function returned by subscribe(). The function must never throw. - * `ready` resolves once native listener registration has settled, including failure. + * + * `ready` resolves once the handler is registered with the underlying implementation, + * including when registration failed. It is a REGISTRATION barrier only — never a + * connectivity or delivery signal. A one-shot promise cannot describe a reconnecting + * transport's lifecycle, so an implementation backed by one must resolve `ready` on local + * registration (synchronously, like MockEventBus); gating it on "connected" would deadlock + * every `await unsubscribe.ready` producer while the transport sits in backoff. */ export type Unsubscribe = (() => void) & { ready: Promise }; @@ -28,7 +51,13 @@ export type EventHandler = (payload: T) => void; */ export interface EventBus { /** - * Subscribe to an event + * Subscribe to an event. + * + * Every call is an INDEPENDENT subscription, even when passed a handler reference that + * is already subscribed, and the returned Unsubscribe removes only that subscription. + * (Do not de-duplicate by handler identity: the first unsubscribe would then kill a + * sibling's live subscription.) + * * @param event - Event name to listen for * @param handler - Callback function receiving the event payload * @returns Unsubscribe function to stop listening @@ -36,7 +65,21 @@ export interface EventBus { subscribe(event: string, handler: EventHandler): Unsubscribe; /** - * Emit an event (primarily for testing/mock mode) + * Emit an event to LOCAL subscribers only. + * + * Not a test-only affordance: production uses it as the in-app re-broadcast channel + * (for example bridging `task:updated` to graph hooks, and `:local`-suffixed synthetics). + * An implementation must never write an emit to a network transport. + * + * Names emitted from the webview must be classified webview-origin/Local-only — use a + * `:local` suffix for new synthetics. Backend-classified names are reserved for the Rust + * backend, because a host's event capture observes the Tauri bus and cannot tell a + * webview emit from a backend one; emitting a durable backend name here would be + * recorded and fanned out to remote clients as backend truth. + * + * Delivery timing is implementation-defined (Tauri is async over IPC, Mock is + * synchronous). Do not rely on re-entrancy or same-tick delivery. + * * @param event - Event name to emit * @param payload - Event payload data */ @@ -155,19 +198,22 @@ export class TauriEventBus implements EventBus { * Provides the same interface but events stay in-browser. */ export class MockEventBus implements EventBus { - private listeners: Map>> = new Map(); + // Subscriptions, not handlers: storing raw handler references in a Set collapses two + // subscriptions of the same function into one, so the first unsubscribe would kill the + // survivor — a divergence from TauriEventBus that web mode must not have. + private listeners: Map }>> = new Map(); subscribe(event: string, handler: EventHandler): Unsubscribe { if (!this.listeners.has(event)) { this.listeners.set(event, new Set()); } // Cast needed because Map stores EventHandler - const typedHandler = handler as EventHandler; - this.listeners.get(event)!.add(typedHandler); + const subscription = { handler: handler as EventHandler }; + this.listeners.get(event)!.add(subscription); // Return unsubscribe function const unsubscribe = () => { - this.listeners.get(event)?.delete(typedHandler); + this.listeners.get(event)?.delete(subscription); }; unsubscribe.ready = Promise.resolve(); return unsubscribe; @@ -176,7 +222,9 @@ export class MockEventBus implements EventBus { emit(event: string, payload: T): void { const handlers = this.listeners.get(event); if (handlers) { - handlers.forEach((handler) => { + // Snapshot so a handler that subscribes/unsubscribes during dispatch cannot mutate + // the set being iterated. + [...handlers].forEach(({ handler }) => { try { handler(payload); } catch (err) { From f24396135c587c8c7af224a3d61d04ab218ca00f Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:44:14 +0300 Subject: [PATCH 064/416] fix(scripts): close three bypasses in the raw Tauri listen guard The guard matched only static imports binding listen/once, so three ways of reintroducing an out-of-bus subscription passed CI: a dynamic `await import("@tauri-apps/api/event")` (no `from` clause, never matched), a `getCurrentWebview().listen(...)` style handle listener (never touches the event module at all), and a raw `emit` import. The emit case matters in both directions -- in a remote environment subscribers live on the bus's own registry so a raw emit reaches nobody, and on a host the capture bank would treat a webview emit of a backend-classified name as backend truth. The window/webview module check only fires when the file actually subscribes, so onDragDropEvent and window-sizing uses stay clean; a fixture pins that. --- scripts/check-raw-tauri-event-listen.mjs | 35 +++++++++++-- .../test-raw-tauri-event-listen-guard.sh | 51 +++++++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/scripts/check-raw-tauri-event-listen.mjs b/scripts/check-raw-tauri-event-listen.mjs index 9b8dbd731b..c97dcd657a 100644 --- a/scripts/check-raw-tauri-event-listen.mjs +++ b/scripts/check-raw-tauri-event-listen.mjs @@ -42,15 +42,44 @@ function sourceFiles(directory) { }); } +// Raw event subscription/emission acquired through a static import/export. +// `emit` counts too: subscribers live on the bus's own registry in a remote environment, so a +// raw emit reaches nobody there, and on a host it would be captured as backend truth. function rawEventImports(source) { const importOrExportPattern = /(?:import|export)\s+([\s\S]*?)\s+from\s+["']@tauri-apps\/api\/event["']/g; return [...source.matchAll(importOrExportPattern)].filter((match) => { const bindings = match[1] ?? ""; - return /(?:^|[,{\s])(?:listen|once)(?:\s+as\s+[A-Za-z_$][\w$]*)?(?=\s*[,}])/.test(bindings) + return /(?:^|[,{\s])(?:listen|once|emit|emitTo)(?:\s+as\s+[A-Za-z_$][\w$]*)?(?=\s*[,}])/.test(bindings) || /^\s*\*/.test(bindings); }); } +// `const { listen } = await import("@tauri-apps/api/event")` has no `from` clause, so the +// static pattern above never sees it. The module has no legitimate use outside the bus. +function dynamicEventImports(source) { + return [...source.matchAll(/import\s*\(\s*["']@tauri-apps\/api\/event["']\s*\)/g)]; +} + +// Window/webview handles expose their own `.listen()`/`.once()`, which subscribe to the raw +// Tauri event system without ever touching @tauri-apps/api/event. Only flag these modules when +// the file actually subscribes, so legitimate uses (onDragDropEvent, window sizing) stay clean. +const WINDOW_EVENT_MODULES = /["']@tauri-apps\/api\/(?:window|webview|webviewWindow)["']/g; + +function windowScopedListenImports(source) { + if (!/\.\s*(?:listen|once)\s*(?:<[^;\n]*>)?\s*\(/.test(source)) { + return []; + } + return [...source.matchAll(WINDOW_EVENT_MODULES)]; +} + +function violationsIn(source) { + return [ + ...rawEventImports(source), + ...dynamicEventImports(source), + ...windowScopedListenImports(source), + ]; +} + if (!fs.existsSync(sourceRoot)) { console.error(`FAIL: missing frontend source directory: ${toRepoPath(sourceRoot)}`); process.exit(1); @@ -64,14 +93,14 @@ for (const filePath of sourceFiles(sourceRoot)) { } const source = fs.readFileSync(filePath, "utf8"); - for (const match of rawEventImports(source)) { + for (const match of violationsIn(source)) { const line = source.slice(0, match.index).split("\n").length; violations.push(`${repoPath}:${line}`); } } if (violations.length > 0) { - console.error("FAIL: raw Tauri event listen imports must go through useEventBus()."); + console.error("FAIL: raw Tauri event listen/emit must go through useEventBus()."); console.error(`Allowed raw-listen module: ${[...RAW_LISTEN_ALLOWLIST].join(", ")}`); violations.forEach((violation) => console.error(` ${violation}`)); process.exit(1); diff --git a/scripts/tests/test-raw-tauri-event-listen-guard.sh b/scripts/tests/test-raw-tauri-event-listen-guard.sh index 5d0613c30c..6e6fc39e5c 100644 --- a/scripts/tests/test-raw-tauri-event-listen-guard.sh +++ b/scripts/tests/test-raw-tauri-event-listen-guard.sh @@ -51,6 +51,57 @@ if node "${ROOT_DIR}/scripts/check-raw-tauri-event-listen.mjs" "${FIXTURE_ROOT}" fi grep -Fq "frontend/src/components/RawListenerExport.ts:1" "${FIXTURE_ROOT}/guard.out" \ || fail "guard failure did not identify the raw-listen re-export fixture" +rm "${FIXTURE_ROOT}/frontend/src/components/RawListenerExport.ts" + +# A raw emit is as much of a bypass as a raw listen: in a remote environment subscribers live +# on the bus's own registry, so it reaches nobody, and on a host it is captured as backend truth. +printf '%s\n' 'import { emit } from "@tauri-apps/api/event";' \ + 'void emit("drift", {});' \ + >"${FIXTURE_ROOT}/frontend/src/components/RawEmitter.ts" + +if node "${ROOT_DIR}/scripts/check-raw-tauri-event-listen.mjs" "${FIXTURE_ROOT}" \ + >"${FIXTURE_ROOT}/guard.out" 2>&1; then + fail "guard accepted a raw-emit reintroduction" +fi +grep -Fq "frontend/src/components/RawEmitter.ts:1" "${FIXTURE_ROOT}/guard.out" \ + || fail "guard failure did not identify the raw-emit fixture" +rm "${FIXTURE_ROOT}/frontend/src/components/RawEmitter.ts" + +printf '%s\n' 'export async function drift() {' \ + ' const { listen } = await import("@tauri-apps/api/event");' \ + ' return listen("drift", () => undefined);' \ + '}' \ + >"${FIXTURE_ROOT}/frontend/src/components/DynamicListener.ts" + +if node "${ROOT_DIR}/scripts/check-raw-tauri-event-listen.mjs" "${FIXTURE_ROOT}" \ + >"${FIXTURE_ROOT}/guard.out" 2>&1; then + fail "guard accepted a dynamic import of the raw event module" +fi +grep -Fq "frontend/src/components/DynamicListener.ts:2" "${FIXTURE_ROOT}/guard.out" \ + || fail "guard failure did not identify the dynamic-import fixture" +rm "${FIXTURE_ROOT}/frontend/src/components/DynamicListener.ts" + +# Window/webview handles subscribe to the raw event system without importing api/event at all. +printf '%s\n' 'import { getCurrentWebview } from "@tauri-apps/api/webview";' \ + 'void getCurrentWebview().listen("drift", () => undefined);' \ + >"${FIXTURE_ROOT}/frontend/src/components/WebviewListener.ts" + +if node "${ROOT_DIR}/scripts/check-raw-tauri-event-listen.mjs" "${FIXTURE_ROOT}" \ + >"${FIXTURE_ROOT}/guard.out" 2>&1; then + fail "guard accepted a webview-scoped raw listener" +fi +grep -Fq "frontend/src/components/WebviewListener.ts:1" "${FIXTURE_ROOT}/guard.out" \ + || fail "guard failure did not identify the webview-scoped listener fixture" +rm "${FIXTURE_ROOT}/frontend/src/components/WebviewListener.ts" + +# Non-event uses of the same modules stay clean (drag-drop, window sizing). +printf '%s\n' 'import { getCurrentWebview } from "@tauri-apps/api/webview";' \ + 'void getCurrentWebview().onDragDropEvent(() => undefined);' \ + >"${FIXTURE_ROOT}/frontend/src/components/DragDrop.ts" + +node "${ROOT_DIR}/scripts/check-raw-tauri-event-listen.mjs" "${FIXTURE_ROOT}" \ + >/dev/null || fail "guard rejected a non-event webview module use" +rm "${FIXTURE_ROOT}/frontend/src/components/DragDrop.ts" node "${ROOT_DIR}/scripts/check-raw-tauri-event-listen.mjs" "${ROOT_DIR}" \ >/dev/null || fail "guard rejected the current repository tree" From f812aee6e974dfe2ee914432a9bd8868f1da305d Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:53:40 +0300 Subject: [PATCH 065/416] chore(remote): add syn dev-dependency for the authority audit tooling --- src-tauri/Cargo.lock | 1 + src-tauri/Cargo.toml | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5dc656e3b5..1e5d5a8342 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3639,6 +3639,7 @@ dependencies = [ "serde_yaml", "sha2", "statig", + "syn 2.0.114", "tauri", "tauri-build", "tauri-plugin-dialog", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index da76e8e49e..bfc1153212 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -107,6 +107,9 @@ objc2-foundation = { version = "0.3", features = ["NSGeometry"] } security-framework = "3.7" [dev-dependencies] +# Build-time tooling only: the remote authority audit (PR 1.3) parses `src-tauri/src` +# to build the command/loop call graph. Never linked into the shipped binary. +syn = { version = "2", features = ["full", "visit", "extra-traits"] } tokio = { version = "1", features = ["test-util"] } tower = { version = "0.4", features = ["util"] } From 534cca9d8826ece718f279d0a4be71bcbd6f7fb7 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:55:18 +0300 Subject: [PATCH 066/416] feat: remote-host invoke wrappers + pairing UX helpers (PR 1.7) --- frontend/src/api/remote-host.test.ts | 181 +++++++++++++ frontend/src/api/remote-host.ts | 240 ++++++++++++++++++ .../remote-access/remote-access-utils.test.ts | 136 ++++++++++ .../remote-access/remote-access-utils.ts | 78 ++++++ 4 files changed, 635 insertions(+) create mode 100644 frontend/src/api/remote-host.test.ts create mode 100644 frontend/src/api/remote-host.ts create mode 100644 frontend/src/components/settings/remote-access/remote-access-utils.test.ts create mode 100644 frontend/src/components/settings/remote-access/remote-access-utils.ts diff --git a/frontend/src/api/remote-host.test.ts b/frontend/src/api/remote-host.test.ts new file mode 100644 index 0000000000..1cc0a707fc --- /dev/null +++ b/frontend/src/api/remote-host.test.ts @@ -0,0 +1,181 @@ +/** + * remote-host API wrapper tests. + * + * Proves rule-14 binding against the PR 1.2 host-local commands: struct params + * wrap under their Rust param name (`input`) and all fields are camelCase. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { invoke } from "@tauri-apps/api/core"; + +import { + REMOTE_SESSION_CLOSED_EVENT, + REMOTE_SESSION_CONNECTED_EVENT, + remoteHostApi, +} from "./remote-host"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), +})); + +const mockInvoke = vi.mocked(invoke); + +const listenerStatus = { + enabled: true, + exposureMode: "serve", + port: 3849, + environmentId: "env-1", + running: true, + bindAddress: "127.0.0.1:3849", + serveActive: true, + serveDegradedReason: null, +}; + +const mintedCode = { + id: "pc-1", + code: "rxp_ABCDEFGHJKLMNPQRSTUVWXYZ012345aa", + scopes: ["ui:read", "ui:operate"], + createdAt: "2026-07-27T10:00:00Z", + expiresAt: "2026-07-27T10:10:00Z", + expiresInSecs: 600, +}; + +const deviceView = { + id: "dev-1", + name: "Anca's iPhone", + tokenPrefix: "rxd_live_AbCd", + scopes: ["ui:read", "ui:operate"], + agentControlGranted: false, + createdAt: "2026-07-20T10:00:00Z", + lastSeenAt: null, + revokedAt: null, + liveSessionCount: 0, +}; + +const sessionView = { + id: "sess-1", + deviceId: "dev-1", + connectedAt: "2026-07-27T09:00:00Z", + lastActiveAt: "2026-07-27T09:30:00Z", + remoteAddr: "100.64.0.7:52001", + live: true, +}; + +describe("remoteHostApi", () => { + beforeEach(() => { + mockInvoke.mockReset(); + }); + + it("getListenerStatus invokes with no args and parses the status", async () => { + mockInvoke.mockResolvedValue(listenerStatus); + const status = await remoteHostApi.getListenerStatus(); + expect(mockInvoke).toHaveBeenCalledWith("get_remote_listener_status", {}); + expect(status.serveActive).toBe(true); + expect(status.exposureMode).toBe("serve"); + }); + + it("startListener / stopListener invoke the listener lifecycle commands", async () => { + mockInvoke.mockResolvedValue(listenerStatus); + await remoteHostApi.startListener(); + expect(mockInvoke).toHaveBeenCalledWith("start_remote_listener", {}); + await remoteHostApi.stopListener(); + expect(mockInvoke).toHaveBeenCalledWith("stop_remote_listener", {}); + }); + + it("setExposureMode wraps the mode under the input struct param", async () => { + mockInvoke.mockResolvedValue({ + ...listenerStatus, + exposureMode: "tailnetDirect", + }); + await remoteHostApi.setExposureMode("tailnetDirect"); + expect(mockInvoke).toHaveBeenCalledWith("set_remote_exposure_mode", { + input: { exposureMode: "tailnetDirect" }, + }); + }); + + it("generatePairingCode invokes with no args and returns the raw code once", async () => { + mockInvoke.mockResolvedValue(mintedCode); + const minted = await remoteHostApi.generatePairingCode(); + expect(mockInvoke).toHaveBeenCalledWith("generate_remote_pairing_code", {}); + expect(minted.code).toBe(mintedCode.code); + expect(minted.expiresInSecs).toBe(600); + }); + + it("listPairingCodes parses outstanding codes without raw values", async () => { + mockInvoke.mockResolvedValue([ + { + id: "pc-2", + scopes: ["ui:read"], + createdAt: "2026-07-27T10:00:00Z", + expiresAt: "2026-07-27T10:10:00Z", + }, + ]); + const codes = await remoteHostApi.listPairingCodes(); + expect(mockInvoke).toHaveBeenCalledWith("list_remote_pairing_codes", {}); + expect(codes).toHaveLength(1); + expect(codes[0]?.id).toBe("pc-2"); + }); + + it("revokePairingCode wraps the id under input", async () => { + mockInvoke.mockResolvedValue(true); + await expect(remoteHostApi.revokePairingCode("pc-1")).resolves.toBe(true); + expect(mockInvoke).toHaveBeenCalledWith("revoke_remote_pairing_code", { + input: { id: "pc-1" }, + }); + }); + + it("listDevices parses device views", async () => { + mockInvoke.mockResolvedValue([deviceView]); + const devices = await remoteHostApi.listDevices(); + expect(mockInvoke).toHaveBeenCalledWith("list_remote_devices", {}); + expect(devices[0]?.agentControlGranted).toBe(false); + expect(devices[0]?.tokenPrefix).toBe("rxd_live_AbCd"); + }); + + it("setDeviceAgentControl wraps deviceId + enabled in camelCase under input", async () => { + mockInvoke.mockResolvedValue({ ...deviceView, agentControlGranted: true }); + await remoteHostApi.setDeviceAgentControl("dev-1", true); + expect(mockInvoke).toHaveBeenCalledWith("set_remote_device_agent_control", { + input: { deviceId: "dev-1", enabled: true }, + }); + }); + + it("revokeDevice wraps deviceId under input", async () => { + mockInvoke.mockResolvedValue({ + ...deviceView, + revokedAt: "2026-07-27T11:00:00Z", + }); + const revoked = await remoteHostApi.revokeDevice("dev-1"); + expect(mockInvoke).toHaveBeenCalledWith("revoke_remote_device", { + input: { deviceId: "dev-1" }, + }); + expect(revoked.revokedAt).toBe("2026-07-27T11:00:00Z"); + }); + + it("listSessions parses session views", async () => { + mockInvoke.mockResolvedValue([sessionView]); + const sessions = await remoteHostApi.listSessions(); + expect(mockInvoke).toHaveBeenCalledWith("list_remote_sessions", {}); + expect(sessions[0]?.remoteAddr).toBe("100.64.0.7:52001"); + expect(sessions[0]?.live).toBe(true); + }); + + it("disconnectSession wraps sessionId under input", async () => { + mockInvoke.mockResolvedValue(true); + await remoteHostApi.disconnectSession("sess-1"); + expect(mockInvoke).toHaveBeenCalledWith("disconnect_remote_session", { + input: { sessionId: "sess-1" }, + }); + }); + + it("rejects a listener status payload that drops serve fields", async () => { + const { serveActive: _serveActive, ...broken } = listenerStatus; + mockInvoke.mockResolvedValue(broken); + await expect(remoteHostApi.getListenerStatus()).rejects.toThrow(); + }); + + it("exposes the local-only session lifecycle event names as constants", () => { + expect(REMOTE_SESSION_CONNECTED_EVENT).toBe("remote:session_connected"); + expect(REMOTE_SESSION_CLOSED_EVENT).toBe("remote:session_closed"); + }); +}); diff --git a/frontend/src/api/remote-host.ts b/frontend/src/api/remote-host.ts new file mode 100644 index 0000000000..66294f6012 --- /dev/null +++ b/frontend/src/api/remote-host.ts @@ -0,0 +1,240 @@ +// Tauri invoke wrappers for the HOST side of remote access (PR 1.7, §5.4). +// +// Binds the PR 1.1/1.2 host-local commands (src-tauri/src/commands/registry.rs, +// `// remote auth (PR 1.2)` block). None of these commands is reachable on :3849 — +// device management and pairing-code minting are host-local only (§3.1). +// +// Rule 14 / C-11: struct params wrap under the Rust param name (`input`); fields +// are camelCase (`#[serde(rename_all = "camelCase")]` on every input/output type). + +import { z } from "zod"; +import { typedInvoke } from "@/lib/tauri"; + +// --------------------------------------------------------------------------- +// Local-only session lifecycle events (§5.5) +// --------------------------------------------------------------------------- + +/** Local-only event: a remote device session was admitted (§5.5). */ +export const REMOTE_SESSION_CONNECTED_EVENT = "remote:session_connected"; +/** Local-only event: a remote device session ended (§5.5). */ +export const REMOTE_SESSION_CLOSED_EVENT = "remote:session_closed"; + +// --------------------------------------------------------------------------- +// Schemas +// --------------------------------------------------------------------------- + +export const remoteExposureModeSchema = z.enum(["serve", "tailnetDirect"]); +export type RemoteExposureMode = z.infer; + +export const remoteScopeSchema = z.enum([ + "ui:read", + "ui:operate", + "ui:agent", + "ui:elevated", +]); +export type RemoteScope = z.infer; + +export const remoteListenerStatusSchema = z.object({ + enabled: z.boolean(), + exposureMode: remoteExposureModeSchema, + /** + * The CONFIGURED port — `RALPHX_REMOTE_PORT` can override the actual bind and that + * override surfaces only through `bindAddress`. Derive advertised URLs from + * `bindAddress`, never from this field. + */ + port: z.number(), + environmentId: z.string(), + running: z.boolean(), + bindAddress: z.string().nullable(), + serveActive: z.boolean(), + serveDegradedReason: z.string().nullable(), +}); +export type RemoteListenerStatus = z.infer; + +export const mintedRemotePairingCodeSchema = z.object({ + id: z.string(), + /** Shown once — stored hashed at rest, never returned again (A-9). */ + code: z.string(), + scopes: z.array(remoteScopeSchema), + createdAt: z.string(), + expiresAt: z.string(), + expiresInSecs: z.number(), +}); +export type MintedRemotePairingCode = z.infer; + +export const remotePairingCodeViewSchema = z.object({ + id: z.string(), + scopes: z.array(remoteScopeSchema), + createdAt: z.string(), + expiresAt: z.string(), +}); +export type RemotePairingCodeView = z.infer; + +export const remoteDeviceViewSchema = z.object({ + id: z.string(), + name: z.string(), + tokenPrefix: z.string(), + scopes: z.array(remoteScopeSchema), + agentControlGranted: z.boolean(), + createdAt: z.string(), + lastSeenAt: z.string().nullable(), + revokedAt: z.string().nullable(), + liveSessionCount: z.number(), +}); +export type RemoteDeviceView = z.infer; + +export const remoteSessionViewSchema = z.object({ + id: z.string(), + deviceId: z.string(), + connectedAt: z.string(), + lastActiveAt: z.string(), + remoteAddr: z.string(), + /** Whether the in-memory registry still holds a kill channel for this session. */ + live: z.boolean(), +}); +export type RemoteSessionView = z.infer; + +/** + * Shape mirror of `remote_server/endpoints.rs::AdvertisedEndpoint` (serde camelCase). + * Scheme is a transport fact — Serve advertises `https://`, tailnet direct + * advertises `http://:`. Render schemes as given; never normalize. + */ +export const advertisedEndpointSchema = z.object({ + kind: z.enum(["loopbackServe", "tailnetDirect"]), + url: z.string(), + available: z.boolean(), +}); +export type AdvertisedEndpoint = z.infer; + +/** + * Shape mirror of `ralphx-domain/src/entities/remote_access.rs::RemoteAuditEntry` + * (camelCase view; `action` uses the stable `as_db_value` strings, e.g. + * "pairing_code_created", "device_revoked", "agent_control_granted"). + */ +export const remoteAuditEntrySchema = z.object({ + id: z.number(), + deviceId: z.string().nullable(), + action: z.string(), + detail: z.string().nullable(), + createdAt: z.string(), +}); +export type RemoteAuditEntry = z.infer; + +// --------------------------------------------------------------------------- +// API +// --------------------------------------------------------------------------- + +export const remoteHostApi = { + getListenerStatus(): Promise { + return typedInvoke("get_remote_listener_status", {}, remoteListenerStatusSchema); + }, + + /** Enables remote host mode and binds the listener for the persisted exposure mode. */ + startListener(): Promise { + return typedInvoke("start_remote_listener", {}, remoteListenerStatusSchema); + }, + + /** Disables remote host mode and releases the port (immediate session teardown, §4.4). */ + stopListener(): Promise { + return typedInvoke("stop_remote_listener", {}, remoteListenerStatusSchema); + }, + + setExposureMode(exposureMode: RemoteExposureMode): Promise { + return typedInvoke( + "set_remote_exposure_mode", + { input: { exposureMode } }, + remoteListenerStatusSchema, + ); + }, + + /** Mints a single-use pairing code with a 10-minute TTL; the raw code is shown once. */ + generatePairingCode(): Promise { + return typedInvoke( + "generate_remote_pairing_code", + {}, + mintedRemotePairingCodeSchema, + ); + }, + + /** Outstanding (unconsumed, unexpired) codes — without their raw values. */ + listPairingCodes(): Promise { + return typedInvoke( + "list_remote_pairing_codes", + {}, + z.array(remotePairingCodeViewSchema), + ); + }, + + /** Cancels an outstanding pairing code before anyone redeems it (§4.6 stolen-QR row). */ + revokePairingCode(id: string): Promise { + return typedInvoke("revoke_remote_pairing_code", { input: { id } }, z.boolean()); + }, + + /** Every paired device, revoked ones included, with live session counts. */ + listDevices(): Promise { + return typedInvoke("list_remote_devices", {}, z.array(remoteDeviceViewSchema)); + }, + + /** + * Grants or withdraws `ui:agent` for one device (§5.4 — the one deliberate consent). + * Withdrawing narrows the durable grant first, then fires the device's kill channels. + */ + setDeviceAgentControl(deviceId: string, enabled: boolean): Promise { + return typedInvoke( + "set_remote_device_agent_control", + { input: { deviceId, enabled } }, + remoteDeviceViewSchema, + ); + }, + + /** Revokes a device and tears its live sessions down immediately (registry kill channels). */ + revokeDevice(deviceId: string): Promise { + return typedInvoke( + "revoke_remote_device", + { input: { deviceId } }, + remoteDeviceViewSchema, + ); + }, + + listSessions(): Promise { + return typedInvoke("list_remote_sessions", {}, z.array(remoteSessionViewSchema)); + }, + + /** Closes one live session without revoking its device. */ + disconnectSession(sessionId: string): Promise { + return typedInvoke( + "disconnect_remote_session", + { input: { sessionId } }, + z.boolean(), + ); + }, + + /** + * TODO(PR 1.6 blocker): no Tauri command exposes `remote_server/endpoints.rs:: + * advertised_endpoints` yet (it is `pub(crate)` + `#[allow(dead_code)]`, annotated + * "Consumed by PR 1.7"). This wrapper binds the expected command name/shape so the + * pane lights up when that surface lands; until then it rejects and the pane shows + * an explicit degraded state (never silent-empty). + */ + listAdvertisedEndpoints(): Promise { + return typedInvoke( + "list_remote_advertised_endpoints", + {}, + z.array(advertisedEndpointSchema), + ); + }, + + /** + * TODO(PR 1.2 follow-up blocker): `remote_audit_log` rows exist (RemoteAuditEntry + + * repo listing in sqlite_remote_access_repo.rs:649) but no Tauri command exposes + * them. Same typed-TODO strategy as listAdvertisedEndpoints: the pane shows an + * explicit "unavailable" note while this rejects. + */ + listAuditEntries(): Promise { + return typedInvoke( + "list_remote_audit_entries", + {}, + z.array(remoteAuditEntrySchema), + ); + }, +} as const; diff --git a/frontend/src/components/settings/remote-access/remote-access-utils.test.ts b/frontend/src/components/settings/remote-access/remote-access-utils.test.ts new file mode 100644 index 0000000000..f9d9181ee0 --- /dev/null +++ b/frontend/src/components/settings/remote-access/remote-access-utils.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from "vitest"; + +import type { AdvertisedEndpoint, RemoteListenerStatus } from "@/api/remote-host"; + +import { + buildPairingUrl, + formatCountdown, + groupPairingCode, + pickPreferredEndpoint, + remainingSeconds, +} from "./remote-access-utils"; + +const CODE = "rxp_ABCDEFGHJKLMNPQRSTUVWXYZabcdef01"; + +function status(overrides: Partial = {}): RemoteListenerStatus { + return { + enabled: true, + exposureMode: "serve", + port: 3849, + environmentId: "env-1", + running: true, + bindAddress: "127.0.0.1:3849", + serveActive: true, + serveDegradedReason: null, + ...overrides, + }; +} + +describe("groupPairingCode", () => { + it("splits the rxp_ prefix from four-character groups (R-12 manual entry)", () => { + const grouped = groupPairingCode(CODE); + expect(grouped.prefix).toBe("rxp_"); + expect(grouped.groups).toEqual([ + "ABCD", + "EFGH", + "JKLM", + "NPQR", + "STUV", + "WXYZ", + "abcd", + "ef01", + ]); + }); + + it("keeps an unprefixed code intact as grouped chunks", () => { + const grouped = groupPairingCode("ABCDEFGH"); + expect(grouped.prefix).toBe(""); + expect(grouped.groups).toEqual(["ABCD", "EFGH"]); + }); +}); + +describe("buildPairingUrl", () => { + it("puts the code in the hash fragment, never the query (§3.7)", () => { + const url = buildPairingUrl("https://mac-studio.tailnet.ts.net", CODE); + expect(url).toBe( + `ralphx://pair?host=${encodeURIComponent("https://mac-studio.tailnet.ts.net")}#code=${CODE}`, + ); + const beforeHash = url.split("#")[0] ?? ""; + expect(beforeHash).not.toContain(CODE); + }); +}); + +describe("pickPreferredEndpoint", () => { + const serveEndpoint: AdvertisedEndpoint = { + kind: "loopbackServe", + url: "https://mac-studio.tailnet.ts.net", + available: false, + }; + const directEndpoint: AdvertisedEndpoint = { + kind: "tailnetDirect", + url: "http://100.64.0.7:3849", + available: true, + }; + + it("prefers the first available endpoint", () => { + expect(pickPreferredEndpoint([serveEndpoint, directEndpoint], status())).toBe( + "http://100.64.0.7:3849", + ); + }); + + it("falls back to the first endpoint when none is available yet", () => { + expect(pickPreferredEndpoint([serveEndpoint], status())).toBe( + "https://mac-studio.tailnet.ts.net", + ); + }); + + it("falls back to the bound address (plain http) for tailnet-direct mode", () => { + // RALPHX_REMOTE_PORT can override the persisted port, so the fallback must be + // derived from bindAddress — never from status.port. + const result = pickPreferredEndpoint( + null, + status({ + exposureMode: "tailnetDirect", + bindAddress: "100.64.0.7:4001", + port: 3849, + }), + ); + expect(result).toBe("http://100.64.0.7:4001"); + }); + + it("returns null for serve mode without endpoint data (no fake URL)", () => { + expect(pickPreferredEndpoint(null, status())).toBeNull(); + expect(pickPreferredEndpoint([], status())).toBeNull(); + }); + + it("returns null when tailnet-direct is not running", () => { + expect( + pickPreferredEndpoint( + null, + status({ exposureMode: "tailnetDirect", running: false, bindAddress: null }), + ), + ).toBeNull(); + }); +}); + +describe("remainingSeconds", () => { + it("counts down toward the expiry timestamp", () => { + const now = Date.parse("2026-07-27T10:00:00Z"); + expect(remainingSeconds("2026-07-27T10:10:00Z", now)).toBe(600); + expect(remainingSeconds("2026-07-27T10:00:30Z", now)).toBe(30); + }); + + it("clamps at zero after expiry and on unparseable input", () => { + const now = Date.parse("2026-07-27T10:00:00Z"); + expect(remainingSeconds("2026-07-27T09:59:00Z", now)).toBe(0); + expect(remainingSeconds("not-a-date", now)).toBe(0); + }); +}); + +describe("formatCountdown", () => { + it("renders M:SS", () => { + expect(formatCountdown(600)).toBe("10:00"); + expect(formatCountdown(65)).toBe("1:05"); + expect(formatCountdown(0)).toBe("0:00"); + }); +}); diff --git a/frontend/src/components/settings/remote-access/remote-access-utils.ts b/frontend/src/components/settings/remote-access/remote-access-utils.ts new file mode 100644 index 0000000000..dcc8a4c36c --- /dev/null +++ b/frontend/src/components/settings/remote-access/remote-access-utils.ts @@ -0,0 +1,78 @@ +// Pure helpers for the Remote Access pane (PR 1.7). +// +// R-12 decisions live here: +// - The pairing QR/URL encodes ONLY the preferred endpoint (single `host=` param); +// all candidates stay visible in the endpoints list. The client's §6.1 candidate +// upsert merges alternates after pairing, so multi-host QR payloads buy nothing. +// - Manual entry: the code renders as its `rxp_` prefix plus four-character groups. +// Grouping is visual only — the clipboard always carries the canonical raw code. + +import type { AdvertisedEndpoint, RemoteListenerStatus } from "@/api/remote-host"; + +const PAIRING_CODE_PREFIX = "rxp_"; +const MANUAL_ENTRY_GROUP_SIZE = 4; + +export interface GroupedPairingCode { + prefix: string; + groups: string[]; +} + +/** Splits a pairing code into its prefix and 4-char groups for manual entry (R-12). */ +export function groupPairingCode(code: string): GroupedPairingCode { + const prefix = code.startsWith(PAIRING_CODE_PREFIX) ? PAIRING_CODE_PREFIX : ""; + const body = code.slice(prefix.length); + const groups: string[] = []; + for (let index = 0; index < body.length; index += MANUAL_ENTRY_GROUP_SIZE) { + groups.push(body.slice(index, index + MANUAL_ENTRY_GROUP_SIZE)); + } + return { prefix, groups }; +} + +/** + * Builds `ralphx://pair?host=…#code=…` with the code in the HASH FRAGMENT (§3.7): + * fragments never reach intermediary servers, so the code stays out of logs. + */ +export function buildPairingUrl(host: string, code: string): string { + return `ralphx://pair?host=${encodeURIComponent(host)}#code=${code}`; +} + +/** + * Preferred pairing endpoint (R-12): first available advertised endpoint, else the + * first advertised endpoint, else — for tailnet-direct only — the actual bound + * address as plain `http://` (the direct listener terminates plaintext HTTP inside + * WireGuard; an https URL would fail its handshake). Serve mode without endpoint + * data yields null: an honest "enter host manually" beats a fabricated URL. + * + * Always derived from `bindAddress`, never `status.port` — `RALPHX_REMOTE_PORT` + * overrides surface only through the bound address. + */ +export function pickPreferredEndpoint( + endpoints: AdvertisedEndpoint[] | null, + status: RemoteListenerStatus, +): string | null { + if (endpoints && endpoints.length > 0) { + const available = endpoints.find((endpoint) => endpoint.available); + return (available ?? endpoints[0])?.url ?? null; + } + if (status.exposureMode === "tailnetDirect" && status.running && status.bindAddress) { + return `http://${status.bindAddress}`; + } + return null; +} + +/** Seconds until `expiresAt`, clamped at zero; unparseable input counts as expired. */ +export function remainingSeconds(expiresAt: string, nowMs: number): number { + const expiryMs = Date.parse(expiresAt); + if (Number.isNaN(expiryMs)) { + return 0; + } + return Math.max(0, Math.floor((expiryMs - nowMs) / 1000)); +} + +/** Renders a countdown as `M:SS`. */ +export function formatCountdown(totalSeconds: number): string { + const clamped = Math.max(0, totalSeconds); + const minutes = Math.floor(clamped / 60); + const seconds = clamped % 60; + return `${minutes}:${String(seconds).padStart(2, "0")}`; +} From 90c62b63cca14285ad43d8817ec8ea1efef529be Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 21:59:26 +0300 Subject: [PATCH 067/416] feat(remote): add remote_event_log migration + seq high-water column (PR 1.4) --- .../infrastructure/sqlite/migrations/mod.rs | 10 +- .../v20260727213000_remote_event_log.rs | 44 +++++++ .../v20260727213000_remote_event_log_tests.rs | 112 ++++++++++++++++++ 3 files changed, 165 insertions(+), 1 deletion(-) create mode 100644 src-tauri/src/infrastructure/sqlite/migrations/v20260727213000_remote_event_log.rs create mode 100644 src-tauri/src/infrastructure/sqlite/migrations/v20260727213000_remote_event_log_tests.rs diff --git a/src-tauri/src/infrastructure/sqlite/migrations/mod.rs b/src-tauri/src/infrastructure/sqlite/migrations/mod.rs index 509960be73..37e4a58417 100644 --- a/src-tauri/src/infrastructure/sqlite/migrations/mod.rs +++ b/src-tauri/src/infrastructure/sqlite/migrations/mod.rs @@ -560,6 +560,9 @@ mod v20260727180000_remote_auth_tests; mod v20260727191500_remote_environments; #[cfg(test)] mod v20260727191500_remote_environments_tests; +mod v20260727213000_remote_event_log; +#[cfg(test)] +mod v20260727213000_remote_event_log_tests; #[cfg(test)] pub(super) fn migrate_scripted_agent_workflows_for_test(conn: &Connection) -> AppResult<()> { v20260715194617_scripted_agent_workflows::migrate(conn) @@ -654,7 +657,7 @@ mod v8_task_git_fields_tests; mod v9_project_git_fields_tests; /// Current schema version - bump this when adding a new migration -pub const SCHEMA_VERSION: i64 = 20260727191500; +pub const SCHEMA_VERSION: i64 = 20260727213000; /// Migration function signature type MigrationFn = fn(&Connection) -> AppResult<()>; @@ -1800,6 +1803,11 @@ const MIGRATIONS: &[Migration] = &[ name: "remote_environments", migrate: v20260727191500_remote_environments::migrate, }, + Migration { + version: 20260727213000, + name: "remote_event_log", + migrate: v20260727213000_remote_event_log::migrate, + }, ]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/src-tauri/src/infrastructure/sqlite/migrations/v20260727213000_remote_event_log.rs b/src-tauri/src/infrastructure/sqlite/migrations/v20260727213000_remote_event_log.rs new file mode 100644 index 0000000000..de21b22b06 --- /dev/null +++ b/src-tauri/src/infrastructure/sqlite/migrations/v20260727213000_remote_event_log.rs @@ -0,0 +1,44 @@ +// Migration v20260727213000: durable remote event log + seq high-water (§3.4 DDL) +// +// `seq` is `INTEGER PRIMARY KEY` and deliberately **not** AUTOINCREMENT: the sequencer actor +// is the only assigner of `seq`, so letting SQLite pick one would create a second numbering +// authority. `epoch` records the in-memory `streamEpoch` that wrote the row — the epoch is +// never persisted as host state (§3.2 rule A), so rows from a prior epoch are unreplayable by +// construction and prune-eligible immediately. +// +// The high-water lives on `remote_host_settings` rather than being derived from +// `MAX(seq)`: pruning deletes rows, so `MAX(seq)` would let numbering move backwards after a +// prune of the tail. It is written in the same `run_transaction` as the batch commit, so the +// counter can never disagree with the log. + +use rusqlite::Connection; + +use crate::error::{AppError, AppResult}; + +use super::helpers; + +pub fn migrate(conn: &Connection) -> AppResult<()> { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS remote_event_log ( + seq INTEGER PRIMARY KEY, + epoch TEXT NOT NULL, + name TEXT NOT NULL, + payload TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')) + ); + CREATE INDEX IF NOT EXISTS idx_remote_event_log_epoch_seq + ON remote_event_log (epoch, seq); + CREATE INDEX IF NOT EXISTS idx_remote_event_log_created_at + ON remote_event_log (created_at);", + ) + .map_err(|error| AppError::Database(error.to_string()))?; + + // Monotonic and never reused across boots; 0 means "nothing sequenced yet". + helpers::add_column_if_not_exists( + conn, + "remote_host_settings", + "event_seq_high_water", + "INTEGER NOT NULL DEFAULT 0", + )?; + Ok(()) +} diff --git a/src-tauri/src/infrastructure/sqlite/migrations/v20260727213000_remote_event_log_tests.rs b/src-tauri/src/infrastructure/sqlite/migrations/v20260727213000_remote_event_log_tests.rs new file mode 100644 index 0000000000..5afa31b139 --- /dev/null +++ b/src-tauri/src/infrastructure/sqlite/migrations/v20260727213000_remote_event_log_tests.rs @@ -0,0 +1,112 @@ +//! Tests for migration v20260727213000: durable remote event log + seq high-water + +use rusqlite::Connection; + +use super::{helpers, v20260727161131_remote_host_settings, v20260727213000_remote_event_log}; + +fn migrated_db() -> Connection { + let conn = Connection::open_in_memory().expect("in-memory database should open"); + v20260727161131_remote_host_settings::migrate(&conn) + .expect("remote host settings must exist before the event log migration"); + v20260727213000_remote_event_log::migrate(&conn).expect("event log migration should apply"); + conn +} + +#[test] +fn migration_creates_the_event_log_and_high_water_column() { + let conn = migrated_db(); + + assert!(helpers::table_exists(&conn, "remote_event_log")); + for column in ["seq", "epoch", "name", "payload", "created_at"] { + assert!( + helpers::column_exists(&conn, "remote_event_log", column), + "remote_event_log should contain {column}" + ); + } + assert!(helpers::column_exists( + &conn, + "remote_host_settings", + "event_seq_high_water" + )); +} + +#[test] +fn seq_is_not_autoincrement_so_the_sequencer_stays_the_only_assigner() { + let conn = migrated_db(); + + let table_sql: String = conn + .query_row( + "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'remote_event_log'", + [], + |row| row.get(0), + ) + .expect("table DDL should be readable"); + assert!( + !table_sql.to_ascii_uppercase().contains("AUTOINCREMENT"), + "seq must be sequencer-authored, not SQLite-authored: {table_sql}" + ); + + // A sequencer-authored seq is accepted verbatim, including a gap left by a prune. + conn.execute( + "INSERT INTO remote_event_log (seq, epoch, name, payload) VALUES (9, 'e1', 'task:created', '{}')", + [], + ) + .expect("explicit seq should be accepted"); + let stored: i64 = conn + .query_row("SELECT seq FROM remote_event_log", [], |row| row.get(0)) + .expect("row should be readable"); + assert_eq!(stored, 9); +} + +#[test] +fn duplicate_seq_is_rejected_so_a_seq_can_never_be_reused() { + let conn = migrated_db(); + + conn.execute( + "INSERT INTO remote_event_log (seq, epoch, name, payload) VALUES (1, 'e1', 'task:created', '{}')", + [], + ) + .expect("first row should insert"); + assert!( + conn.execute( + "INSERT INTO remote_event_log (seq, epoch, name, payload) VALUES (1, 'e2', 'task:created', '{}')", + [], + ) + .is_err(), + "reusing a seq must be refused by the primary key" + ); +} + +#[test] +fn high_water_defaults_to_zero_on_an_existing_settings_row() { + let conn = Connection::open_in_memory().expect("in-memory database should open"); + v20260727161131_remote_host_settings::migrate(&conn).expect("settings migration should apply"); + conn.execute( + "INSERT INTO remote_host_settings (id, enabled, exposure_mode, port, environment_id) + VALUES (1, 0, 'serve', 3849, '8d3d6a07-8e85-4e91-97ce-915fc038fdb2')", + [], + ) + .expect("pre-existing settings row should insert"); + + v20260727213000_remote_event_log::migrate(&conn).expect("event log migration should apply"); + + let high_water: i64 = conn + .query_row( + "SELECT event_seq_high_water FROM remote_host_settings WHERE id = 1", + [], + |row| row.get(0), + ) + .expect("high water should be readable"); + assert_eq!(high_water, 0); +} + +#[test] +fn migration_is_idempotent() { + let conn = migrated_db(); + v20260727213000_remote_event_log::migrate(&conn).expect("second migration should remain safe"); + assert!(helpers::column_exists( + &conn, + "remote_host_settings", + "event_seq_high_water" + )); +} From 97681252ea76b5d90a58c4d3992f6b330018f63a Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Mon, 27 Jul 2026 22:04:18 +0300 Subject: [PATCH 068/416] =?UTF-8?q?feat:=20Remote=20Access=20settings=20pa?= =?UTF-8?q?ne=20=E2=80=94=20listener,=20pairing,=20devices,=20sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../RemoteAccessSection.test.tsx | 627 +++++++++++++++++ .../remote-access/RemoteAccessSection.tsx | 634 ++++++++++++++++++ .../remote-access/RemoteDeviceList.tsx | 245 +++++++ .../remote-access/RemotePairingCard.tsx | 252 +++++++ .../remote-access/RemoteSessionList.tsx | 146 ++++ 5 files changed, 1904 insertions(+) create mode 100644 frontend/src/components/settings/remote-access/RemoteAccessSection.test.tsx create mode 100644 frontend/src/components/settings/remote-access/RemoteAccessSection.tsx create mode 100644 frontend/src/components/settings/remote-access/RemoteDeviceList.tsx create mode 100644 frontend/src/components/settings/remote-access/RemotePairingCard.tsx create mode 100644 frontend/src/components/settings/remote-access/RemoteSessionList.tsx diff --git a/frontend/src/components/settings/remote-access/RemoteAccessSection.test.tsx b/frontend/src/components/settings/remote-access/RemoteAccessSection.test.tsx new file mode 100644 index 0000000000..552b04ef41 --- /dev/null +++ b/frontend/src/components/settings/remote-access/RemoteAccessSection.test.tsx @@ -0,0 +1,627 @@ +/** + * RemoteAccessSection tests (PR 1.7). + * + * Proof obligations: C-8 first-paint (shell before any invoke), flag inertness, + * optimistic listener toggle with revert, pairing flow + countdown expiry, + * agent-control warning-before-commit, teardown-backed revoke/disconnect, + * local-only session event subscriptions, and explicit degraded states for the + * missing PR 1.6 endpoint / audit surfaces. + */ + +import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + remoteHostApi, + type MintedRemotePairingCode, + type RemoteDeviceView, + type RemoteListenerStatus, + type RemoteSessionView, +} from "@/api/remote-host"; +import { TooltipProvider } from "@/components/ui/tooltip"; + +import { RemoteAccessSection } from "./RemoteAccessSection"; + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- + +const flagsState = vi.hoisted(() => ({ remoteEnvironments: true })); + +vi.mock("@/hooks/useFeatureFlags", () => ({ + useFeatureFlags: () => ({ + data: { remoteEnvironments: flagsState.remoteEnvironments }, + }), +})); + +const busState = vi.hoisted(() => { + const handlers = new Map void>>(); + return { + handlers, + emit(event: string, payload: unknown) { + for (const handler of handlers.get(event) ?? []) { + handler(payload); + } + }, + subscribe: undefined as unknown as ReturnType, + }; +}); + +vi.mock("@/providers/EventProvider", () => { + const subscribe = vi.fn( + (event: string, handler: (payload: unknown) => void) => { + const set = busState.handlers.get(event) ?? new Set(); + set.add(handler); + busState.handlers.set(event, set); + const unsubscribe = () => { + set.delete(handler); + }; + return Object.assign(unsubscribe, { ready: Promise.resolve() }); + }, + ); + busState.subscribe = subscribe; + return { + useEventBus: () => ({ subscribe }), + }; +}); + +vi.mock("@/api/remote-host", () => ({ + REMOTE_SESSION_CONNECTED_EVENT: "remote:session_connected", + REMOTE_SESSION_CLOSED_EVENT: "remote:session_closed", + remoteHostApi: { + getListenerStatus: vi.fn(), + startListener: vi.fn(), + stopListener: vi.fn(), + setExposureMode: vi.fn(), + generatePairingCode: vi.fn(), + listPairingCodes: vi.fn(), + revokePairingCode: vi.fn(), + listDevices: vi.fn(), + setDeviceAgentControl: vi.fn(), + revokeDevice: vi.fn(), + listSessions: vi.fn(), + disconnectSession: vi.fn(), + listAdvertisedEndpoints: vi.fn(), + listAuditEntries: vi.fn(), + }, +})); + +const api = vi.mocked(remoteHostApi); + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const baseStatus: RemoteListenerStatus = { + enabled: true, + exposureMode: "serve", + port: 3849, + environmentId: "env-1", + running: true, + bindAddress: "127.0.0.1:3849", + serveActive: true, + serveDegradedReason: null, +}; + +const deviceOff: RemoteDeviceView = { + id: "dev-1", + name: "Anca's iPhone", + tokenPrefix: "rxd_live_AbCd", + scopes: ["ui:read", "ui:operate"], + agentControlGranted: false, + createdAt: "2026-07-20T10:00:00Z", + lastSeenAt: "2026-07-27T09:00:00Z", + revokedAt: null, + liveSessionCount: 1, +}; + +const deviceOn: RemoteDeviceView = { + ...deviceOff, + id: "dev-2", + name: "Work MacBook", + tokenPrefix: "rxd_live_EfGh", + scopes: ["ui:read", "ui:operate", "ui:agent"], + agentControlGranted: true, + liveSessionCount: 0, +}; + +const session: RemoteSessionView = { + id: "sess-1", + deviceId: "dev-1", + connectedAt: "2026-07-27T09:00:00Z", + lastActiveAt: "2026-07-27T09:30:00Z", + remoteAddr: "100.64.0.7:52001", + live: true, +}; + +function minted(expiresAt: string): MintedRemotePairingCode { + return { + id: "pc-1", + code: "rxp_ABCDEFGHJKLMNPQRSTUVWXYZabcdef01", + scopes: ["ui:read", "ui:operate"], + createdAt: "2026-07-27T10:00:00Z", + expiresAt, + expiresInSecs: 600, + }; +} + +function renderSection() { + return render( + + + , + ); +} + +async function hydrate() { + await waitFor(() => { + expect(api.getListenerStatus).toHaveBeenCalled(); + }); + await waitFor(() => { + expect(screen.getByTestId("remote-enable-toggle")).not.toBeDisabled(); + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + busState.handlers.clear(); + flagsState.remoteEnvironments = true; + api.getListenerStatus.mockResolvedValue(baseStatus); + api.startListener.mockResolvedValue({ ...baseStatus, enabled: true, running: true }); + api.stopListener.mockResolvedValue({ ...baseStatus, enabled: false, running: false }); + api.setExposureMode.mockResolvedValue({ ...baseStatus, exposureMode: "tailnetDirect" }); + api.listAdvertisedEndpoints.mockResolvedValue([ + { kind: "loopbackServe", url: "https://mac-studio.tailnet.ts.net", available: true }, + ]); + api.listDevices.mockResolvedValue([deviceOff, deviceOn]); + api.listSessions.mockResolvedValue([session]); + api.listPairingCodes.mockResolvedValue([]); + api.listAuditEntries.mockResolvedValue([ + { + id: 1, + deviceId: "dev-1", + action: "pairing_succeeded", + detail: null, + createdAt: "2026-07-27T09:00:00Z", + }, + ]); + api.generatePairingCode.mockResolvedValue( + minted(new Date(Date.now() + 600_000).toISOString()), + ); + api.revokePairingCode.mockResolvedValue(true); + api.setDeviceAgentControl.mockImplementation((deviceId, enabled) => + Promise.resolve({ + ...(deviceId === deviceOff.id ? deviceOff : deviceOn), + agentControlGranted: enabled, + }), + ); + api.revokeDevice.mockResolvedValue({ + ...deviceOff, + revokedAt: "2026-07-27T11:00:00Z", + }); + api.disconnectSession.mockResolvedValue(true); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +// --------------------------------------------------------------------------- +// Feature flag +// --------------------------------------------------------------------------- + +describe("feature gating", () => { + it("renders nothing and never invokes while remoteEnvironments is off", async () => { + flagsState.remoteEnvironments = false; + renderSection(); + expect(screen.queryByTestId("remote-access-section")).not.toBeInTheDocument(); + // Give any wrongly scheduled hydration time to fire. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 80)); + }); + expect(api.getListenerStatus).not.toHaveBeenCalled(); + expect(api.listDevices).not.toHaveBeenCalled(); + expect(busState.subscribe).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// First paint (rule 24 / C-8) +// --------------------------------------------------------------------------- + +describe("first paint", () => { + it("paints the shell synchronously, before any backend invoke", async () => { + renderSection(); + // Synchronous assertions — no awaits yet. + expect(screen.getByTestId("remote-access-section")).toBeInTheDocument(); + expect(screen.getByText("Remote Access")).toBeInTheDocument(); + expect(screen.getByText("Pair a device")).toBeInTheDocument(); + expect(screen.getByText("Paired devices")).toBeInTheDocument(); + expect(screen.getByText("Live sessions")).toBeInTheDocument(); + expect(api.getListenerStatus).not.toHaveBeenCalled(); + expect(api.listDevices).not.toHaveBeenCalled(); + expect(api.listSessions).not.toHaveBeenCalled(); + expect(api.listAdvertisedEndpoints).not.toHaveBeenCalled(); + // Hydration happens after the paint boundary. + await hydrate(); + expect(api.listDevices).toHaveBeenCalled(); + expect(api.listSessions).toHaveBeenCalled(); + }); + + it("hydrates status, endpoints, devices, sessions, and audit entries", async () => { + renderSection(); + await hydrate(); + expect( + await screen.findByText("https://mac-studio.tailnet.ts.net"), + ).toBeInTheDocument(); + expect( + within(screen.getByTestId("remote-device-dev-1")).getByText("Anca's iPhone"), + ).toBeInTheDocument(); + expect( + within(screen.getByTestId("remote-device-dev-2")).getByText("Work MacBook"), + ).toBeInTheDocument(); + expect(screen.getByTestId("remote-session-sess-1")).toBeInTheDocument(); + expect(await screen.findByTestId("remote-audit")).toBeInTheDocument(); + expect(screen.getByText(/Pairing succeeded/)).toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// Listener controls +// --------------------------------------------------------------------------- + +describe("listener controls", () => { + it("flips the enable toggle optimistically before the invoke settles", async () => { + api.getListenerStatus.mockResolvedValue({ + ...baseStatus, + enabled: false, + running: false, + }); + let resolveStart: ((status: RemoteListenerStatus) => void) | undefined; + api.startListener.mockImplementation( + () => + new Promise((resolve) => { + resolveStart = resolve; + }), + ); + renderSection(); + await hydrate(); + + const toggle = screen.getByTestId("remote-enable-toggle"); + expect(toggle).toHaveAttribute("aria-checked", "false"); + fireEvent.click(toggle); + // Optimistic: checked before the promise resolves. + expect(toggle).toHaveAttribute("aria-checked", "true"); + expect(api.startListener).toHaveBeenCalledTimes(1); + + await act(async () => { + resolveStart?.({ ...baseStatus, enabled: true, running: true }); + }); + await waitFor(() => { + expect(toggle).toHaveAttribute("aria-checked", "true"); + }); + }); + + it("reverts the toggle and surfaces the error when the invoke fails", async () => { + api.getListenerStatus.mockResolvedValue({ + ...baseStatus, + enabled: false, + running: false, + }); + api.startListener.mockRejectedValue(new Error("port already in use")); + renderSection(); + await hydrate(); + + const toggle = screen.getByTestId("remote-enable-toggle"); + fireEvent.click(toggle); + expect(toggle).toHaveAttribute("aria-checked", "true"); + await waitFor(() => { + expect(toggle).toHaveAttribute("aria-checked", "false"); + }); + expect(screen.getByTestId("remote-access-error")).toHaveTextContent( + "port already in use", + ); + }); + + it("switches exposure mode optimistically via set_remote_exposure_mode", async () => { + renderSection(); + await hydrate(); + + const tailnet = screen.getByTestId("remote-mode-tailnetDirect"); + expect(tailnet).toHaveAttribute("aria-checked", "false"); + fireEvent.click(tailnet); + expect(tailnet).toHaveAttribute("aria-checked", "true"); + expect(api.setExposureMode).toHaveBeenCalledWith("tailnetDirect"); + }); + + it("shows the serve degraded reason from the listener status", async () => { + api.getListenerStatus.mockResolvedValue({ + ...baseStatus, + serveActive: false, + serveDegradedReason: "tailscale is not logged in", + }); + renderSection(); + await hydrate(); + expect(screen.getByTestId("remote-serve-degraded")).toHaveTextContent( + "tailscale is not logged in", + ); + }); + + it("shows an explicit degraded note when endpoint discovery is unavailable", async () => { + api.listAdvertisedEndpoints.mockRejectedValue( + new Error("unknown command list_remote_advertised_endpoints"), + ); + renderSection(); + await hydrate(); + expect( + await screen.findByTestId("remote-endpoints-unavailable"), + ).toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// Pairing flow +// --------------------------------------------------------------------------- + +describe("pairing flow", () => { + it("mints a code and shows the grouped code + hash-fragment URL", async () => { + renderSection(); + await hydrate(); + + fireEvent.click(screen.getByTestId("remote-pair-device")); + expect(api.generatePairingCode).toHaveBeenCalledTimes(1); + + const card = await screen.findByTestId("remote-pairing-card"); + expect(within(card).getByTestId("remote-pairing-code")).toHaveTextContent( + "rxp_ABCD EFGH JKLM NPQR STUV WXYZ abcd ef01", + ); + // Preferred endpoint (R-12): the single advertised endpoint; code in the fragment. + expect( + within(card).getByTestId("remote-pairing-url-value"), + ).toHaveTextContent( + "ralphx://pair?host=https%3A%2F%2Fmac-studio.tailnet.ts.net#code=rxp_ABCDEFGHJKLMNPQRSTUVWXYZabcdef01", + ); + expect( + within(card).getByTestId("remote-pairing-countdown").textContent, + ).toMatch(/Expires in (10:00|9:5\d)/); + }); + + it("cancels the displayed code through revoke_remote_pairing_code", async () => { + renderSection(); + await hydrate(); + fireEvent.click(screen.getByTestId("remote-pair-device")); + const card = await screen.findByTestId("remote-pairing-card"); + + fireEvent.click(within(card).getByTestId("remote-pairing-cancel")); + expect(api.revokePairingCode).toHaveBeenCalledWith("pc-1"); + expect(screen.queryByTestId("remote-pairing-card")).not.toBeInTheDocument(); + }); + + it("cancels an outstanding code from the list", async () => { + api.listPairingCodes.mockResolvedValue([ + { + id: "pc-9", + scopes: ["ui:read"], + createdAt: "2026-07-27T09:55:00Z", + expiresAt: "2026-07-27T10:05:00Z", + }, + ]); + renderSection(); + await hydrate(); + + fireEvent.click(await screen.findByTestId("remote-code-cancel-pc-9")); + expect(api.revokePairingCode).toHaveBeenCalledWith("pc-9"); + }); + + it("expires the code when the countdown reaches zero", async () => { + vi.useFakeTimers({ + toFake: [ + "setTimeout", + "clearTimeout", + "setInterval", + "clearInterval", + "requestAnimationFrame", + "cancelAnimationFrame", + "Date", + ], + }); + vi.setSystemTime(new Date("2026-07-27T10:00:00Z")); + api.generatePairingCode.mockResolvedValue(minted("2026-07-27T10:10:00Z")); + + renderSection(); + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + + fireEvent.click(screen.getByTestId("remote-pair-device")); + await act(async () => { + await vi.advanceTimersByTimeAsync(50); + }); + // 150ms of fake time elapsed since mint (hydrate + settle), so 599s remain. + expect(screen.getByTestId("remote-pairing-countdown")).toHaveTextContent( + "Expires in 9:59", + ); + + await act(async () => { + await vi.advanceTimersByTimeAsync(60_000); + }); + expect(screen.getByTestId("remote-pairing-countdown")).toHaveTextContent( + "Expires in 8:59", + ); + + const outstandingCallsBeforeExpiry = api.listPairingCodes.mock.calls.length; + await act(async () => { + await vi.advanceTimersByTimeAsync(9 * 60_000); + }); + expect(screen.getByTestId("remote-pairing-expired")).toBeInTheDocument(); + expect(screen.queryByTestId("remote-pairing-card")).not.toBeInTheDocument(); + // Expiry refreshes the outstanding-codes list exactly once for this code. + expect(api.listPairingCodes.mock.calls.length).toBe( + outstandingCallsBeforeExpiry + 1, + ); + }); +}); + +// --------------------------------------------------------------------------- +// Agent control (the one deliberate consent) +// --------------------------------------------------------------------------- + +describe("agent control toggle", () => { + it("shows the explicit warning before committing a grant", async () => { + renderSection(); + await hydrate(); + + fireEvent.click(screen.getByTestId("remote-agent-control-dev-1")); + // No commit before consent. + expect(api.setDeviceAgentControl).not.toHaveBeenCalled(); + + const warning = await screen.findByTestId("remote-agent-warning"); + expect(warning).toHaveTextContent(/kanban/i); + expect(warning).toHaveTextContent(/start and steer agents/i); + expect(warning).toHaveTextContent(/inject tasks into the ready queue/i); + expect(warning).toHaveTextContent(/code execution on this Mac/i); + + fireEvent.click(screen.getByTestId("remote-agent-warning-confirm")); + expect(api.setDeviceAgentControl).toHaveBeenCalledWith("dev-1", true); + await waitFor(() => { + expect(screen.getByTestId("remote-agent-control-dev-1")).toHaveAttribute( + "aria-checked", + "true", + ); + }); + }); + + it("does not grant when the warning is cancelled", async () => { + renderSection(); + await hydrate(); + + fireEvent.click(screen.getByTestId("remote-agent-control-dev-1")); + await screen.findByTestId("remote-agent-warning"); + fireEvent.click(screen.getByTestId("remote-agent-warning-cancel")); + expect(api.setDeviceAgentControl).not.toHaveBeenCalled(); + expect(screen.getByTestId("remote-agent-control-dev-1")).toHaveAttribute( + "aria-checked", + "false", + ); + }); + + it("withdraws immediately without a dialog and refreshes sessions (teardown)", async () => { + renderSection(); + await hydrate(); + const sessionsCallsBefore = api.listSessions.mock.calls.length; + + fireEvent.click(screen.getByTestId("remote-agent-control-dev-2")); + expect(screen.queryByTestId("remote-agent-warning")).not.toBeInTheDocument(); + expect(api.setDeviceAgentControl).toHaveBeenCalledWith("dev-2", false); + // Optimistic: off before the invoke settles. + expect(screen.getByTestId("remote-agent-control-dev-2")).toHaveAttribute( + "aria-checked", + "false", + ); + // Withdrawal fires kill channels — the session list must re-prove itself. + await waitFor(() => { + expect(api.listSessions.mock.calls.length).toBeGreaterThan(sessionsCallsBefore); + }); + }); + + it("reverts the optimistic grant when the backend rejects it", async () => { + api.setDeviceAgentControl.mockRejectedValue(new Error("device revoked")); + renderSection(); + await hydrate(); + + fireEvent.click(screen.getByTestId("remote-agent-control-dev-1")); + fireEvent.click(await screen.findByTestId("remote-agent-warning-confirm")); + await waitFor(() => { + expect(screen.getByTestId("remote-agent-control-dev-1")).toHaveAttribute( + "aria-checked", + "false", + ); + }); + expect(screen.getByTestId("remote-access-error")).toHaveTextContent( + "device revoked", + ); + }); +}); + +// --------------------------------------------------------------------------- +// Revoke + sessions +// --------------------------------------------------------------------------- + +describe("revoke and sessions", () => { + it("revokes a device through the teardown-backed command after confirm", async () => { + renderSection(); + await hydrate(); + + fireEvent.click(screen.getByTestId("remote-device-revoke-dev-1")); + expect(api.revokeDevice).not.toHaveBeenCalled(); + fireEvent.click( + await screen.findByTestId("remote-device-revoke-confirm-action"), + ); + expect(api.revokeDevice).toHaveBeenCalledWith("dev-1"); + + const row = await screen.findByTestId("remote-device-dev-1"); + await waitFor(() => { + expect(within(row).getByText("Revoked")).toBeInTheDocument(); + }); + expect( + within(row).queryByTestId("remote-agent-control-dev-1"), + ).not.toBeInTheDocument(); + }); + + it("disconnects a session immediately (row leaves before the invoke settles)", async () => { + let resolveDisconnect: ((value: boolean) => void) | undefined; + api.disconnectSession.mockImplementation( + () => + new Promise((resolve) => { + resolveDisconnect = resolve; + }), + ); + renderSection(); + await hydrate(); + await screen.findByTestId("remote-session-sess-1"); + + api.listSessions.mockResolvedValue([]); + fireEvent.click(screen.getByTestId("remote-session-disconnect-sess-1")); + // Immediate: no waiting on the backend for the visual removal. + expect(screen.queryByTestId("remote-session-sess-1")).not.toBeInTheDocument(); + expect(api.disconnectSession).toHaveBeenCalledWith("sess-1"); + await act(async () => { + resolveDisconnect?.(true); + }); + }); + + it("subscribes to the local-only session events and refreshes on them", async () => { + renderSection(); + await hydrate(); + await waitFor(() => { + expect(busState.subscribe).toHaveBeenCalledWith( + "remote:session_connected", + expect.any(Function), + ); + expect(busState.subscribe).toHaveBeenCalledWith( + "remote:session_closed", + expect.any(Function), + ); + }); + + const second: RemoteSessionView = { + ...session, + id: "sess-2", + remoteAddr: "100.64.0.9:40100", + }; + api.listSessions.mockResolvedValue([session, second]); + act(() => { + busState.emit("remote:session_connected", { sessionId: "sess-2" }); + }); + expect(await screen.findByTestId("remote-session-sess-2")).toBeInTheDocument(); + }); + + it("shows the explicit audit-unavailable note when the audit surface is missing", async () => { + api.listAuditEntries.mockRejectedValue( + new Error("unknown command list_remote_audit_entries"), + ); + renderSection(); + await hydrate(); + expect(await screen.findByTestId("remote-audit-unavailable")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/settings/remote-access/RemoteAccessSection.tsx b/frontend/src/components/settings/remote-access/RemoteAccessSection.tsx new file mode 100644 index 0000000000..7017b65294 --- /dev/null +++ b/frontend/src/components/settings/remote-access/RemoteAccessSection.tsx @@ -0,0 +1,634 @@ +/** + * RemoteAccessSection — Settings → Remote Access pane (PR 1.7, §5.4). + * + * Rule 24: the pane paints its full shell synchronously; every backend invoke is + * deferred behind a paint boundary (scheduleAfterPaint). Toggles update visible + * state optimistically and revert on error. + * + * Feature-gated on `remoteEnvironments` (`ui.feature_flags`, ui_commands.rs) — + * renders nothing while the flag is off. + */ + +import { useCallback, useEffect, useMemo, useState } from "react"; +import { RadioTower } from "lucide-react"; + +import { + REMOTE_SESSION_CLOSED_EVENT, + REMOTE_SESSION_CONNECTED_EVENT, + remoteHostApi, + type AdvertisedEndpoint, + type MintedRemotePairingCode, + type RemoteAuditEntry, + type RemoteDeviceView, + type RemoteExposureMode, + type RemoteListenerStatus, + type RemotePairingCodeView, + type RemoteSessionView, +} from "@/api/remote-host"; +import { Card } from "@/components/ui/card"; +import { NoticeBanner } from "@/components/ui/notice-banner"; +import { Separator } from "@/components/ui/separator"; +import { Switch } from "@/components/ui/switch"; +import { useFeatureFlags } from "@/hooks/useFeatureFlags"; +import { useEventBus } from "@/providers/EventProvider"; + +import { + cancelScheduledJob, + scheduleAfterPaint, +} from "../SettingsDialog.performance"; + +import { RemoteDeviceList } from "./RemoteDeviceList"; +import { RemotePairingCard } from "./RemotePairingCard"; +import { RemoteSessionList } from "./RemoteSessionList"; +import { pickPreferredEndpoint } from "./remote-access-utils"; + +// ============================================================================ +// Shared building blocks +// ============================================================================ + +export function RemoteAccessCardHeader({ + title, + description, +}: { + title: string; + description: string; +}) { + return ( + <> +
+
+ +
+
+

+ {title} +

+

{description}

+
+
+ + + ); +} + +export function RemoteAccessSkeletonRows({ rows = 2 }: { rows?: number }) { + return ( + diff --git a/frontend/src/components/settings/remote-access/RemoteSessionList.tsx b/frontend/src/components/settings/remote-access/RemoteSessionList.tsx index bf08c90f87..3685b4219f 100644 --- a/frontend/src/components/settings/remote-access/RemoteSessionList.tsx +++ b/frontend/src/components/settings/remote-access/RemoteSessionList.tsx @@ -14,7 +14,6 @@ import type { } from "@/api/remote-host"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; -import { NoticeBanner } from "@/components/ui/notice-banner"; import { StatusPill } from "@/components/ui/status-pill"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { formatRelativeTime } from "@/lib/formatters"; @@ -25,7 +24,6 @@ export interface RemoteSessionListProps { devices: RemoteDeviceView[] | null; sessions: RemoteSessionView[] | null; auditEntries: RemoteAuditEntry[] | null; - auditUnavailable: boolean; onDisconnect: (sessionId: string) => void; } @@ -39,7 +37,6 @@ export function RemoteSessionList({ devices, sessions, auditEntries, - auditUnavailable, onDisconnect, }: RemoteSessionListProps) { const deviceNames = new Map( @@ -108,13 +105,7 @@ export function RemoteSessionList({

Recent activity

- {auditUnavailable ? ( - - The remote audit log has no host-local read surface yet (PR 1.2 - follow-up). Entries are being recorded and will appear here once it - lands. - - ) : auditEntries === null ? ( + {auditEntries === null ? ( ) : auditEntries.length === 0 ? (

No activity yet.

diff --git a/frontend/src/components/settings/remote-access/remote-access-utils.test.ts b/frontend/src/components/settings/remote-access/remote-access-utils.test.ts index f9d9181ee0..3801e0c002 100644 --- a/frontend/src/components/settings/remote-access/remote-access-utils.test.ts +++ b/frontend/src/components/settings/remote-access/remote-access-utils.test.ts @@ -22,6 +22,7 @@ function status(overrides: Partial = {}): RemoteListenerSt bindAddress: "127.0.0.1:3849", serveActive: true, serveDegradedReason: null, + serveDegradedKind: null, ...overrides, }; } From e7d37a6b087b50d63608c017e656a62b5ef770d1 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:31:17 +0300 Subject: [PATCH 123/416] fix(scripts): close four drift-scan evasion vectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The P-11/P-18 walk could be stepped around four ways: an aliased import (`invoke as inv` — the style lib/remote itself uses) was neither inventoried nor flagged; the fetch-bypass detector regex-matched only inside the fetch call's arguments, so `const url = backendApiUrl(x); fetch(url)` passed; window./globalThis. callees escaped both the bypass and wrapper-escape checks; and a deep "@tauri-apps/api/core.js" import bypasses the Vite alias entirely. Resolve invoke roots through import aliases (folding module-scope command constants so a named constant is still a literal), flag any reference to the backend URL helpers outside their owner, match property-access fetch/WebSocket callees, and add a repo-wide deep-specifier check. Self-test grows to 26 cases. --- scripts/check-remote-transport-drift.mjs | 264 ++++++++++++++++++++--- 1 file changed, 233 insertions(+), 31 deletions(-) diff --git a/scripts/check-remote-transport-drift.mjs b/scripts/check-remote-transport-drift.mjs index 7c3e35a25e..5328220276 100644 --- a/scripts/check-remote-transport-drift.mjs +++ b/scripts/check-remote-transport-drift.mjs @@ -4,22 +4,30 @@ * P-11 (client half) + P-18 (wrapper half) — remote transport drift scan. * * The transport seams only hold if EVERY production call goes through them. This - * scan parses `frontend/src` with the TypeScript AST and fails on the four ways a + * scan parses `frontend/src` with the TypeScript AST and fails on the five ways a * call site can slip past: * * 1. A dynamic `invoke` command expression. A command name that is not a literal * cannot be classified as remote-registered or local-only, so it defeats the * whole inventory. Forwarders (`typedInvoke(cmd, ...)` helpers that pass their * OWN parameter through) are resolved rather than flagged — the literal lives - * at their call sites, which the scan follows. - * 2. A `fetch()` built from `backendApiUrl`/`backendBaseUrl` outside - * `api/backend.ts` — i.e. a site that bypasses `backendFetch` and would keep - * hitting this Mac's backend while a remote environment is active. + * at their call sites, which the scan follows. Invoke roots are matched through + * import ALIASES (`invoke as primitiveInvoke`) and module-scope string constants + * are folded, so neither spelling hides a call site from the inventory. + * 2. Any reference to `backendApiUrl`/`backendBaseUrl`/`backendApiPath` outside + * `api/backend.ts` — a site building a local backend URL instead of going + * through `backendFetch`, which would keep hitting this Mac's backend while a + * remote environment is active. References, not `fetch()` argument text: the URL + * and the `fetch` are often one line apart. * 3. A cross-origin escape inside the transport wrapper itself (`fetch`, - * `WebSocket`, `EventSource`, `XMLHttpRequest`). Remote traffic is Rust-proxied - * precisely so the webview never holds the bearer or opens a socket (C-15). + * `window.fetch`, `WebSocket`, `EventSource`, `XMLHttpRequest`, including + * `globalThis.`-qualified forms). Remote traffic is Rust-proxied precisely so the + * webview never holds the bearer or opens a socket (C-15). * 4. `#tauri-core-primitive` — the un-aliased Tauri core — imported outside * `src/lib/remote`, which would route a caller past the wrapper entirely. + * 5. A deep `@tauri-apps/api/core.js` / `@tauri-apps/api/core/…` import anywhere: + * the Vite alias matches the bare specifier only, so a deep path silently strands + * that caller on local IPC while a remote environment is active. * * Plus a RATCHET on the command inventory: every literal command name is either * remote-registered (host facade) or listed in `local-only-commands.ts`. Anything @@ -89,6 +97,20 @@ const NETWORK_ESCAPE_GLOBALS = new Set([ "XMLHttpRequest", ]); +/** Local URL construction. A reference to ANY of these outside the owner is a bypass in progress. */ +const BACKEND_URL_HELPERS = new Set([ + "backendApiUrl", + "backendBaseUrl", + "backendApiPath", +]); + +/** + * The Vite alias redirects the specifier `@tauri-apps/api/core` EXACTLY. A deep path resolves to + * the real module at runtime, stranding that caller on local IPC while a remote environment is + * active — the same escape `#tauri-core-primitive` is fenced for, spelled differently. + */ +const UNALIASED_CORE_SPECIFIER = /^@tauri-apps\/api\/core[./]/; + function toRepoPath(filePath) { return path.relative(repoRoot, filePath).split(path.sep).join("/"); } @@ -163,6 +185,49 @@ function rootMemberCalleeName(node) { return null; } +/** + * Local names an invoke root was imported UNDER, with its command-argument index. + * + * `import { invoke as primitiveInvoke } from "…"` is the style the transport wrapper itself uses, + * so matching roots by bare name alone would leave every aliased call site both un-inventoried and + * un-flagged — invisible to the scan rather than caught by it. + */ +function invokeRootAliases(sourceFile) { + const aliases = new Map(); + for (const statement of sourceFile.statements) { + if (!ts.isImportDeclaration(statement)) continue; + const bindings = statement.importClause?.namedBindings; + if (!bindings || !ts.isNamedImports(bindings)) continue; + for (const element of bindings.elements) { + const exported = (element.propertyName ?? element.name).text; + if (INVOKE_ROOTS.has(exported)) { + aliases.set(element.name.text, INVOKE_ROOTS.get(exported)); + } + } + } + return aliases; +} + +/** + * Module-scope `const NAME = "literal"` bindings. + * + * A command named by a module constant (`invoke(REMOTE_INVOKE_COMMAND, …)`) is a literal the + * inventory can classify, not a dynamic expression — folding it is what keeps the P-11 rule + * ("every production command name must be a literal") from punishing a named constant. + */ +function stringConstants(sourceFile) { + const constants = new Map(); + for (const statement of sourceFile.statements) { + if (!ts.isVariableStatement(statement)) continue; + for (const declaration of statement.declarationList.declarations) { + if (!ts.isIdentifier(declaration.name)) continue; + const literal = literalCommand(declaration.initializer); + if (literal !== null) constants.set(declaration.name.text, literal); + } + } + return constants; +} + /** Named import bindings, so an exported forwarder resolves in its consumers. */ function importedNames(sourceFile) { const names = new Set(); @@ -258,7 +323,10 @@ export function collectForwarders(parsedFiles) { const exported = new Map(); for (const sourceFile of parsedFiles) { - const forwarders = new Map(INVOKE_ROOTS); + const forwarders = new Map([ + ...INVOKE_ROOTS, + ...invokeRootAliases(sourceFile), + ]); let changed = true; while (changed) { changed = false; @@ -290,8 +358,11 @@ export function collectForwarders(parsedFiles) { export function collectInvokeCallSites(parsedFiles, forwarders) { const sites = []; for (const sourceFile of parsedFiles) { - const local = forwarders.perFile.get(sourceFile.fileName) ?? new Map(INVOKE_ROOTS); + const local = + forwarders.perFile.get(sourceFile.fileName) ?? + new Map([...INVOKE_ROOTS, ...invokeRootAliases(sourceFile)]); const imported = importedNames(sourceFile); + const constants = stringConstants(sourceFile); const resolve = (name) => { if (local.has(name)) return local.get(name); if (imported.has(name) && forwarders.exported.has(name)) { @@ -307,7 +378,11 @@ export function collectInvokeCallSites(parsedFiles, forwarders) { if (commandIndex === undefined) return; const commandArg = node.arguments[commandIndex]; - const command = literalCommand(commandArg); + const command = + literalCommand(commandArg) ?? + (commandArg !== undefined && ts.isIdentifier(commandArg) + ? (constants.get(commandArg.text) ?? null) + : null); if (command !== null) { sites.push({ file: sourceFile.fileName, line: lineOf(sourceFile, node), command }); return; @@ -328,50 +403,81 @@ export function collectInvokeCallSites(parsedFiles, forwarders) { return sites; } -/** `fetch(...)` built from the backend URL helpers outside their owning module. */ +/** + * Any reference to the local URL helpers outside their owning module. + * + * Deliberately NOT "a `fetch()` whose argument text mentions them": `const url = + * backendApiUrl(x); fetch(url)` is the same bypass one line apart, and an aliased import + * (`backendApiUrl as u`) hides the name from every use site — so the import binding is flagged + * too. Nothing outside `api/backend.ts` has a legitimate reason to build a local backend URL; + * `backendFetch` is the seam. + */ export function collectFetchBypasses(parsedFiles) { const violations = []; for (const sourceFile of parsedFiles) { if (sourceFile.fileName === BACKEND_URL_OWNER) continue; walk(sourceFile, (node) => { - if (calleeName(node) !== "fetch") return; - const uses = node.arguments.some((argument) => - /\bbackend(ApiUrl|BaseUrl|ApiPath)\b/.test(argument.getText(sourceFile)) - ); - if (uses) { - violations.push({ - file: sourceFile.fileName, - line: lineOf(sourceFile, node), - detail: "fetch() built from backendApiUrl/backendBaseUrl — use backendFetch()", - }); - } + if (!ts.isIdentifier(node) || !BACKEND_URL_HELPERS.has(node.text)) return; + const parent = node.parent; + // `x.backendApiUrl` names a member of something else, not this helper. + if (parent && ts.isPropertyAccessExpression(parent) && parent.name === node) return; + violations.push({ + file: sourceFile.fileName, + line: lineOf(sourceFile, node), + detail: `${node.text} outside api/backend.ts — build the request with backendFetch()`, + }); }); } return violations; } -/** P-18: the transport wrapper must not open a connection from the webview. */ +/** `fetch(...)`, `window.fetch(...)`, `globalThis.fetch(...)`, `self.fetch(...)`. */ +function isFetchCall(node) { + if (!ts.isCallExpression(node)) return false; + const expression = node.expression; + if (ts.isIdentifier(expression)) return expression.text === "fetch"; + return ( + ts.isPropertyAccessExpression(expression) && + ts.isIdentifier(expression.name) && + expression.name.text === "fetch" + ); +} + +/** The constructed global's name, through `new X()` or `new window.X()`. */ +function constructedGlobalName(node) { + if (!ts.isNewExpression(node)) return null; + const expression = node.expression; + if (ts.isIdentifier(expression)) return expression.text; + if (ts.isPropertyAccessExpression(expression) && ts.isIdentifier(expression.name)) { + return expression.name.text; + } + return null; +} + +/** + * P-18: the transport wrapper must not open a connection from the webview. + * + * Property-access callees count: `window.fetch(…)` / `globalThis.WebSocket` reach the same + * runtime as the bare names and would otherwise walk straight past a bare-identifier check. + */ export function collectWrapperNetworkEscapes(parsedFiles) { const violations = []; for (const sourceFile of parsedFiles) { if (!sourceFile.fileName.startsWith(TRANSPORT_DIR)) continue; walk(sourceFile, (node) => { - if (ts.isCallExpression(node) && calleeName(node) === "fetch") { + if (isFetchCall(node)) { violations.push({ file: sourceFile.fileName, line: lineOf(sourceFile, node), detail: "fetch() inside the transport wrapper — remote traffic is Rust-proxied", }); } - if ( - ts.isNewExpression(node) && - ts.isIdentifier(node.expression) && - NETWORK_ESCAPE_GLOBALS.has(node.expression.text) - ) { + const constructed = constructedGlobalName(node); + if (constructed !== null && NETWORK_ESCAPE_GLOBALS.has(constructed)) { violations.push({ file: sourceFile.fileName, line: lineOf(sourceFile, node), - detail: `new ${node.expression.text}() inside the transport wrapper — remote traffic is Rust-proxied`, + detail: `new ${constructed}() inside the transport wrapper — remote traffic is Rust-proxied`, }); } }); @@ -379,6 +485,27 @@ export function collectWrapperNetworkEscapes(parsedFiles) { return violations; } +/** + * A deep import of the Tauri core bypasses the Vite alias, which matches the bare specifier only. + * Repo-wide: the wrapper has `#tauri-core-primitive` for its own un-aliased access and nothing + * else may reach the real module under any spelling. + */ +export function collectUnaliasedCoreImports(parsedFiles) { + const violations = []; + for (const sourceFile of parsedFiles) { + walk(sourceFile, (node) => { + if (!ts.isStringLiteral(node) && !ts.isNoSubstitutionTemplateLiteral(node)) return; + if (!UNALIASED_CORE_SPECIFIER.test(node.text)) return; + violations.push({ + file: sourceFile.fileName, + line: lineOf(sourceFile, node), + detail: `${node.text} bypasses the @tauri-apps/api/core alias — import the bare specifier`, + }); + }); + } + return violations; +} + /** The un-aliased core is the wrapper's private door; nobody else may use it. */ export function collectPrimitiveSpecifierEscapes(parsedFiles) { const violations = []; @@ -479,6 +606,31 @@ function runSelfTest() { sites.filter((site) => site.command === null).length === 1 ); + const aliasFixture = parse( + "frontend/src/api/aliased.ts", + ` + import { invoke as inv } from "@tauri-apps/api/core"; + const NAMED_COMMAND = "list_widgets"; + export const api = { + one: () => inv("get_widget", {}), + two: () => inv(NAMED_COMMAND, {}), + three: () => inv(pickCommand(), {}), + }; + ` + ); + const aliasForwarders = collectForwarders([aliasFixture]); + const aliasSites = collectInvokeCallSites([aliasFixture], aliasForwarders); + const aliasCommands = aliasSites.map((site) => site.command); + check("inventories an aliased invoke import", aliasCommands.includes("get_widget")); + check( + "folds a module-scope command constant into a literal", + aliasCommands.includes("list_widgets") + ); + check( + "still flags a dynamic command behind an aliased import", + aliasCommands.filter((command) => command === null).length === 1 + ); + const bypass = collectFetchBypasses([ parse( "frontend/src/api/thing.ts", @@ -486,6 +638,30 @@ function runSelfTest() { ), ]); check("flags a fetch(backendApiUrl(...)) bypass", bypass.length === 1); + check( + "flags a backend URL built one line before the fetch", + collectFetchBypasses([ + parse( + "frontend/src/api/indirect.ts", + `const url = backendApiUrl("x");\nconst r = await fetch(url);` + ), + ]).length === 1 + ); + check( + "flags an aliased backend URL helper import", + collectFetchBypasses([ + parse( + "frontend/src/api/aliasedUrl.ts", + `import { backendApiUrl as u } from "@/api/backend";\nawait fetch(u("x"));` + ), + ]).length === 1 + ); + check( + "ignores an unrelated member named backendApiUrl", + collectFetchBypasses([ + parse("frontend/src/api/member.ts", `const v = config.backendApiUrl;`), + ]).length === 0 + ); check( "exempts the backend.ts seam itself", collectFetchBypasses([ @@ -512,6 +688,15 @@ function runSelfTest() { parse("frontend/src/api/other.ts", `await fetch("https://x");`), ]).length === 0 ); + check( + "flags window/globalThis network escapes inside the wrapper", + collectWrapperNetworkEscapes([ + parse( + `${TRANSPORT_DIR}sneaky.ts`, + `await window.fetch("https://host/x");\nconst s = new globalThis.WebSocket("wss://host");` + ), + ]).length === 2 + ); const primitive = collectPrimitiveSpecifierEscapes([ parse("frontend/src/api/sneaky.ts", `import { invoke } from "#tauri-core-primitive";`), @@ -524,6 +709,22 @@ function runSelfTest() { ]).length === 0 ); + check( + "flags a deep import that bypasses the core alias", + collectUnaliasedCoreImports([ + parse( + "frontend/src/api/deep.ts", + `import { invoke } from "@tauri-apps/api/core.js";` + ), + ]).length === 1 + ); + check( + "allows the aliased bare core specifier", + collectUnaliasedCoreImports([ + parse("frontend/src/api/fine.ts", `import { invoke } from "@tauri-apps/api/core";`), + ]).length === 0 + ); + check( "parses registered command names", parseRegisteredCommands( @@ -546,7 +747,7 @@ function runSelfTest() { failures.forEach((failure) => console.error(` ${failure}`)); process.exit(1); } - console.log("PASS: drift-scan self-test (16 detector cases)"); + console.log("PASS: drift-scan self-test (26 detector cases)"); process.exit(0); } @@ -582,6 +783,7 @@ const hardFailures = [ ...collectFetchBypasses(parsedFiles), ...collectWrapperNetworkEscapes(parsedFiles), ...collectPrimitiveSpecifierEscapes(parsedFiles), + ...collectUnaliasedCoreImports(parsedFiles), ]; if (hardFailures.length > 0) { From 8422980bb4594c2e3ee473d08c97eb7c0da58559 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:37:01 +0300 Subject: [PATCH 124/416] docs(remote): regenerate the remote-commands manifest after the multi-env merge The command census grew from 536 to 538 after merging feat/remote-multi-env (list_pending_permission_gates, list_pending_question_gates landed). Both new commands inherit their module's conservative AgentControl default and are correctly excluded from the detector (a) floor (pure reads, no spawn reachability). Floor stays at 103 commands. --- docs/generated/remote-commands.json | 53 ++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/docs/generated/remote-commands.json b/docs/generated/remote-commands.json index 4c64b61718..0da377b2c3 100644 --- a/docs/generated/remote-commands.json +++ b/docs/generated/remote-commands.json @@ -181,7 +181,7 @@ "authorityBearing": true, "enclosingFunction": "application/app_setup.rs:::::launch_startup_attempt", "file": "application/app_setup.rs", - "id": "application/app_setup.rs::application/app_setup.rs:::::launch_startup_attempt@91c4f344f9f4eb74", + "id": "application/app_setup.rs::application/app_setup.rs:::::launch_startup_attempt@a33c293f5037c924", "kind": "async_runtime::spawn" }, { @@ -1040,24 +1040,31 @@ }, { "authorityBearing": false, - "enclosingFunction": "remote_server/capture.rs:::::install_if_host_mode_configured", - "file": "remote_server/capture.rs", - "id": "remote_server/capture.rs::remote_server/capture.rs:::::install_if_host_mode_configured@30f4c73c0d21e75b", - "kind": "thread::spawn" + "enclosingFunction": "remote_server/mod.rs:::::start_listener", + "file": "remote_server/mod.rs", + "id": "remote_server/mod.rs::remote_server/mod.rs:::::start_listener@be5782b8beb913b0", + "kind": "async_runtime::spawn" }, { "authorityBearing": false, - "enclosingFunction": "remote_server/capture.rs:::::install_if_host_mode_configured", - "file": "remote_server/capture.rs", - "id": "remote_server/capture.rs::remote_server/capture.rs:::::install_if_host_mode_configured@30f4c73c0d21e75b~1", - "kind": "thread::spawn" + "enclosingFunction": "remote_server/retention.rs:::::spawn_pruner", + "file": "remote_server/retention.rs", + "id": "remote_server/retention.rs::remote_server/retention.rs:::::spawn_pruner@a6866fa8f9aee649", + "kind": "tokio::spawn" }, { "authorityBearing": false, - "enclosingFunction": "remote_server/mod.rs:::::start_listener", - "file": "remote_server/mod.rs", - "id": "remote_server/mod.rs::remote_server/mod.rs:::::start_listener@be5782b8beb913b0", - "kind": "async_runtime::spawn" + "enclosingFunction": "remote_server/sequencer.rs::RemoteSequencer::start", + "file": "remote_server/sequencer.rs", + "id": "remote_server/sequencer.rs::remote_server/sequencer.rs::RemoteSequencer::start@d4b8ecd0cec0470a", + "kind": "tokio::spawn" + }, + { + "authorityBearing": false, + "enclosingFunction": "remote_server/sequencer.rs::RemoteSequencer::start", + "file": "remote_server/sequencer.rs", + "id": "remote_server/sequencer.rs::remote_server/sequencer.rs::RemoteSequencer::start@dbb327c115f4ccf", + "kind": "tokio::spawn" }, { "authorityBearing": false, @@ -4827,6 +4834,16 @@ "reason": "conservative-module-default: may steer or arm autonomous work", "registered": false }, + { + "capabilities": [ + "agentControl" + ], + "class": "agentControl", + "command": "list_pending_permission_gates", + "module": "permission_commands", + "reason": "conservative-module-default: may steer or arm autonomous work", + "registered": false + }, { "capabilities": [ "agentControl" @@ -4847,6 +4864,16 @@ "reason": "conservative-module-default: may steer or arm autonomous work", "registered": false }, + { + "capabilities": [ + "agentControl" + ], + "class": "agentControl", + "command": "list_pending_question_gates", + "module": "question_commands", + "reason": "conservative-module-default: may steer or arm autonomous work", + "registered": false + }, { "capabilities": [ "agentControl" From e7739e64f481ffa33d1d86c66dfa7efe7a3a6320 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:37:46 +0300 Subject: [PATCH 125/416] style(remote): rustfmt the hand-resolved merge conflict in mod.rs Whitespace only, no semantic change: the RemoteRouterState::new(...) .with_stream(...) chain from resolving the feat/remote-multi-env merge conflict needed rustfmt's line-wrap. --- src-tauri/src/remote_server/mod.rs | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src-tauri/src/remote_server/mod.rs b/src-tauri/src/remote_server/mod.rs index e988e9bd73..53778ee7a2 100644 --- a/src-tauri/src/remote_server/mod.rs +++ b/src-tauri/src/remote_server/mod.rs @@ -500,14 +500,11 @@ pub(crate) async fn start_listener( let (stopped_tx, stopped) = oneshot::channel(); let auth = RemoteAuthContext::from_db(store.db(), handle.sessions.clone(), settings.exposure_mode); - let mut state = RemoteRouterState::new( - settings.environment_id.as_str(), - auth, - app_handle.clone(), - ) - // Installed at app setup, not here: the listener toggle governs network exposure only, so - // a restart of the listener must not restart the stream (P-15, P-23). - .with_stream(handle.stream()); + let mut state = + RemoteRouterState::new(settings.environment_id.as_str(), auth, app_handle.clone()) + // Installed at app setup, not here: the listener toggle governs network exposure only, so + // a restart of the listener must not restart the stream (P-15, P-23). + .with_stream(handle.stream()); if let Some(sink) = handle.lifecycle_sink() { state = state.with_lifecycle_sink(sink); } From 9f828cba3c8c262d4650251e25ba7b1e6fcd97ff Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:38:09 +0300 Subject: [PATCH 126/416] test: align 422 reason phrase with the IANA registry rename --- frontend/src/api/backend-fetch.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/api/backend-fetch.test.ts b/frontend/src/api/backend-fetch.test.ts index 310042665c..451e15371f 100644 --- a/frontend/src/api/backend-fetch.test.ts +++ b/frontend/src/api/backend-fetch.test.ts @@ -175,7 +175,7 @@ describe("remote environment", () => { expect(response.ok).toBe(false); expect(response.status).toBe(422); - expect(response.statusText).toBe("Unprocessable Entity"); + expect(response.statusText).toBe("Unprocessable Content"); await expect(response.json()).resolves.toEqual({ error: "bad input" }); }); From 19d8ed5863c4ea5c0d0425ad4de8d8d99d1a1928 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:39:12 +0300 Subject: [PATCH 127/416] feat(remote): Rust proxy outbound WebSocket and per-environment frame relay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit remote_connect stops being a NOT_CONNECTED stub: bearer -> single-use ws-ticket -> dial -> require hello as the first frame -> hand the socket to a per-environment relay task, which republishes every frame to the webview and answers heartbeats in Rust so a busy renderer cannot get the session killed. The relay never sends subscribe — the client owns afterSeq, and writes it back through the new remote_stream_send. Sessions are generation-tagged so a superseded socket cannot deregister its replacement, and the relay task is spawned from async context only (rule 17). The ws ticket is shape-validated before it reaches a query string, and a post-upgrade auth refusal maps to the same typed rejection as a pre-upgrade one so the supervisor blocks instead of retrying a dead credential. stream_send shares connect's guard rather than the active-environment binding: §6.4 keeps background environments' sockets alive, and the frames it carries are typed protocol control on a socket the proxy already owns. --- src-tauri/Cargo.lock | 7 + src-tauri/Cargo.toml | 3 + .../crates/ralphx-remote-protocol/src/lib.rs | 8 + .../snapshots/event-classifications.json | 12 + src-tauri/src/application/app_state.rs | 17 +- src-tauri/src/application/mod.rs | 4 + .../application/remote_environment_service.rs | 191 +++++++++- .../remote_environment_service_tests.rs | 323 +++++++++++++++- .../src/application/remote_event_relay.rs | 337 +++++++++++++++++ .../application/remote_event_relay_tests.rs | 350 +++++++++++++++++ src-tauri/src/commands/registry.rs | 1 + .../commands/remote_environment_commands.rs | 35 +- .../remote_environment_commands_tests.rs | 48 +++ src-tauri/src/infrastructure/mod.rs | 4 + .../src/infrastructure/remote_host_client.rs | 6 +- .../src/infrastructure/remote_ws_client.rs | 354 ++++++++++++++++++ .../infrastructure/remote_ws_client_tests.rs | 119 ++++++ 17 files changed, 1799 insertions(+), 20 deletions(-) create mode 100644 src-tauri/src/application/remote_event_relay.rs create mode 100644 src-tauri/src/application/remote_event_relay_tests.rs create mode 100644 src-tauri/src/infrastructure/remote_ws_client.rs create mode 100644 src-tauri/src/infrastructure/remote_ws_client_tests.rs diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 1e5d5a8342..e1bba57f67 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3653,6 +3653,7 @@ dependencies = [ "tempfile", "thiserror 1.0.69", "tokio", + "tokio-tungstenite", "tokio-util", "toml 0.9.11+spec-1.1.0", "tower 0.4.13", @@ -5500,7 +5501,11 @@ checksum = "edc5f74e248dc973e0dbb7b74c7e0d6fcc301c694ff50049504004ef4d0cdcd9" dependencies = [ "futures-util", "log", + "rustls", + "rustls-native-certs", + "rustls-pki-types", "tokio", + "tokio-rustls", "tungstenite", ] @@ -5806,6 +5811,8 @@ dependencies = [ "httparse", "log", "rand 0.8.6", + "rustls", + "rustls-pki-types", "sha1", "thiserror 1.0.69", "utf-8", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bfc1153212..9ee62ece01 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -90,6 +90,9 @@ hyper = { version = "1", features = ["client", "http1"] } hyper-util = { version = "0.1", features = ["client-legacy", "tokio"] } hyper-rustls = "0.27" rustls = "0.23" +# 0.24 pinned: axum's ws feature already locks tokio-tungstenite 0.24, so the outbound +# client (PR 2.3) reuses it instead of introducing a second tungstenite in the lock. +tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "handshake", "rustls-tls-native-roots"] } hmac = "0.12" http-body-util = "0.1" portable-pty = "0.8" diff --git a/src-tauri/crates/ralphx-remote-protocol/src/lib.rs b/src-tauri/crates/ralphx-remote-protocol/src/lib.rs index 2f84f834a0..85189a32fe 100644 --- a/src-tauri/crates/ralphx-remote-protocol/src/lib.rs +++ b/src-tauri/crates/ralphx-remote-protocol/src/lib.rs @@ -417,6 +417,14 @@ pub const EVENT_CLASSIFICATIONS: &[EventClassification] = &[ // rows structurally, so they can never reach the sequencer or `remote_event_log`. local_backend("remote:session_connected"), local_backend("remote:session_closed"), + // PR 2.3: the CLIENT proxy's inbound relay channel — the outbound-WS relay re-emits every + // host frame (`remote:stream_frame`) and its own teardown (`remote:stream_closed`) onto the + // local bus for the TS NetworkEventBus. Local-only so a client's own relay can never be + // captured and fanned back out to a host it is itself paired with. The environment id + // travels in the PAYLOAD, not the name: a dynamic emit name is unresolvable to the P-6 + // emit scanner. + local_backend("remote:stream_frame"), + local_backend("remote:stream_closed"), ]; #[cfg(test)] diff --git a/src-tauri/crates/ralphx-remote-protocol/tests/snapshots/event-classifications.json b/src-tauri/crates/ralphx-remote-protocol/tests/snapshots/event-classifications.json index a3f783eec4..c25b46ae24 100644 --- a/src-tauri/crates/ralphx-remote-protocol/tests/snapshots/event-classifications.json +++ b/src-tauri/crates/ralphx-remote-protocol/tests/snapshots/event-classifications.json @@ -574,5 +574,17 @@ "delivery": "localOnly", "origin": "backend", "excludedFromV1": false + }, + { + "name": "remote:stream_frame", + "delivery": "localOnly", + "origin": "backend", + "excludedFromV1": false + }, + { + "name": "remote:stream_closed", + "delivery": "localOnly", + "origin": "backend", + "excludedFromV1": false } ] diff --git a/src-tauri/src/application/app_state.rs b/src-tauri/src/application/app_state.rs index 916ce1f63f..b7cc03772a 100644 --- a/src-tauri/src/application/app_state.rs +++ b/src-tauri/src/application/app_state.rs @@ -579,6 +579,7 @@ impl AppState { fn production_remote_environment_service( shared_conn: &Arc>, + app_handle: &AppHandle, ) -> Arc { let host_client: Arc = match crate::infrastructure::HyperRemoteHostClient::new() { @@ -593,6 +594,12 @@ impl AppState { )) } }; + // Constructing the relay spawns nothing (rule 17): sessions are spawned from + // the async `connect` path only. Frames land on the webview's local bus. + let relay = Arc::new(crate::application::RemoteEventRelay::new( + Arc::new(crate::infrastructure::TungsteniteRemoteWsClient::new()), + Arc::new(crate::application::TauriFrameSink(app_handle.clone())), + )); Arc::new(RemoteEnvironmentService::new( Arc::new( crate::infrastructure::sqlite::SqliteRemoteEnvironmentRepository::from_shared( @@ -601,6 +608,7 @@ impl AppState { ), Arc::new(MacosKeychainSecretStore::new()), host_client, + relay, )) } @@ -611,6 +619,10 @@ impl AppState { Arc::new(crate::infrastructure::UnavailableRemoteHostClient::new( "remote host client is not wired in tests", )), + Arc::new(crate::application::RemoteEventRelay::new( + Arc::new(crate::infrastructure::remote_ws_client::MockRemoteWsClient::new()), + Arc::new(crate::application::NoopFrameSink), + )), )) } @@ -1348,7 +1360,10 @@ impl AppState { api_key_repo: Arc::new(SqliteApiKeyRepository::from_shared(Arc::clone( &shared_conn, ))), - remote_environment_service: Self::production_remote_environment_service(&shared_conn), + remote_environment_service: Self::production_remote_environment_service( + &shared_conn, + &app_handle, + ), atlassian_integration_service: Self::production_atlassian_integration_service( &shared_conn, ), diff --git a/src-tauri/src/application/mod.rs b/src-tauri/src/application/mod.rs index 1b4807e524..fe7a8b3477 100644 --- a/src-tauri/src/application/mod.rs +++ b/src-tauri/src/application/mod.rs @@ -195,6 +195,7 @@ pub mod ready_task_scheduler; pub mod reconciliation; pub mod recovery_queue; pub mod remote_environment_service; +pub mod remote_event_relay; pub mod resume_validator; pub mod review_issue_service; pub mod review_service; @@ -371,6 +372,9 @@ pub use remote_environment_service::{ RemoteEnvironmentError, RemoteEnvironmentReconcileReport, RemoteEnvironmentService, LOCAL_ENVIRONMENT_ID, }; +pub use remote_event_relay::{ + NoopFrameSink, RemoteConnectOutcome, RemoteEventRelay, RemoteFrameSink, TauriFrameSink, +}; pub use resume_validator::{ResumeValidationResult, ResumeValidator}; pub use review_issue_service::{CreateIssueInput, ReviewIssueService}; pub use review_service::ReviewService; diff --git a/src-tauri/src/application/remote_environment_service.rs b/src-tauri/src/application/remote_environment_service.rs index 9d869b4364..3752750a2f 100644 --- a/src-tauri/src/application/remote_environment_service.rs +++ b/src-tauri/src/application/remote_environment_service.rs @@ -19,9 +19,10 @@ use std::sync::Arc; -use ralphx_remote_protocol::{ErrorCode, Scope, PROTOCOL_VERSION}; +use ralphx_remote_protocol::{ClientFrame, ErrorCode, Scope, PROTOCOL_VERSION}; use tokio::sync::RwLock; +use crate::application::remote_event_relay::{RemoteConnectOutcome, RemoteEventRelay}; use crate::domain::entities::remote_environment::{ RemoteEnvironment, RemoteEnvironmentId, RemoteEnvironmentStatus, }; @@ -30,8 +31,9 @@ use crate::domain::services::{SecretStore, SecretStoreError}; use crate::error::AppError; use crate::infrastructure::remote_host_client::{ InvokeWireRequest, PairWireRequest, RemoteFetchRequest, RemoteHostClient, - RemoteHostClientError, RemoteHttpResponse, REMOTE_DESCRIPTOR_PATH, + RemoteHostClientError, RemoteHttpResponse, REMOTE_DESCRIPTOR_PATH, REMOTE_WS_TICKET_PATH, }; +use crate::infrastructure::remote_ws_client::RemoteWsError; /// The always-present local environment identity (§6.4). It has no supervisor, no /// registry row, and never accepts remote proxy calls. @@ -47,8 +49,9 @@ const DEFAULT_REQUESTED_SCOPES: &[Scope] = &[Scope::UiRead, Scope::UiOperate]; /// Typed failures of the remote environment surface (rule 5: no string matching). #[derive(Debug, thiserror::Error)] pub enum RemoteEnvironmentError { - /// Transport is not wired yet — the outbound HTTP invoke path and WS land in - /// PR 2.2/2.3. Authorization already ran when this is returned. + /// Kept for IPC-code stability (`NOT_CONNECTED`); since PR 2.3 the live + /// stream surface reports a missing session as `Unreachable` instead, so the + /// supervisor's retry taxonomy stays single-sourced. #[error("remote transport is not connected")] NotConnected, #[error("environment {requested} is not the active environment ({active})")] @@ -233,6 +236,9 @@ pub struct RemoteEnvironmentService { repo: Arc, secret_store: Arc, host_client: Arc, + /// The outbound event-stream sessions (PR 2.3). The relay owns sockets; this + /// service owns AUTHORIZATION over them. + relay: Arc, /// Rust-side mirror of the frontend `environmentStore` identity (§6.4). /// The ONLY writer is `set_active_environment`; proxy authorization reads it. active_environment_id: RwLock, @@ -243,11 +249,13 @@ impl RemoteEnvironmentService { repo: Arc, secret_store: Arc, host_client: Arc, + relay: Arc, ) -> Self { Self { repo, secret_store, host_client, + relay, active_environment_id: RwLock::new(LOCAL_ENVIRONMENT_ID.to_string()), } } @@ -648,12 +656,17 @@ impl RemoteEnvironmentService { } // ------------------------------------------------------------------ - // Proxy command surface (stubs; transport lands in PR 2.2/2.3) + // Event stream (PR 2.3): connect / disconnect / stream_send // ------------------------------------------------------------------ - /// Opens the outbound WS for `id`. The socket body lands in PR 2.3; the stub - /// still enforces that only a registered, usable environment can be connected. - pub async fn connect(&self, id: &str) -> Result<(), RemoteEnvironmentError> { + /// Requires a registered `active` row and rejects `"local"` — the shared guard + /// of the stream surface. Deliberately NOT active-env-bound: §6.4 keeps + /// background environments' sockets alive for health/liveness, and the + /// supervisor connects them exactly like the active one. + async fn usable_stream_target( + &self, + id: &str, + ) -> Result { if id == LOCAL_ENVIRONMENT_ID { return Err(RemoteEnvironmentError::LocalEnvironment); } @@ -668,11 +681,54 @@ impl RemoteEnvironmentService { env.status.as_str(), )); } - Err(RemoteEnvironmentError::NotConnected) + Ok(env) + } + + /// Opens the outbound WS for `id`: bearer → single-use ticket → dial → hello. + /// + /// The hello's `protocolVersion` is relayed VERBATIM, not gated here. §3.2 + /// negotiation is `minClientProtocol`-based, and only the descriptor carries + /// that field — this side has just the pairing-time snapshot, so any Rust-side + /// equality/ordering gate would false-block a legitimately upgraded host. The + /// TS supervisor owns the `blocked` decision (§6.5) with the descriptor in + /// hand; the P-10 lying-descriptor check lives there. + pub async fn connect( + &self, + id: &str, + ) -> Result { + let env = self.usable_stream_target(id).await?; + let token = self.bearer_for(&env).await?; + let ticket = self.mint_ws_ticket(&env, &token).await?; + let outcome = self + .relay + .connect(env.id.as_str(), &env.base_url, &ticket) + .await + .map_err(stream_error)?; + if outcome.protocol_version != env.protocol_version { + tracing::warn!( + environment = env.id.as_str(), + stored = env.protocol_version, + hello = outcome.protocol_version, + "Host protocol version differs from the paired row; the supervisor owns the skew decision (§6.5)" + ); + } + // Best-effort bookkeeping only — a connected stream must not fail over a + // timestamp write. + let timestamp = chrono::Utc::now() + .format("%Y-%m-%dT%H:%M:%S+00:00") + .to_string(); + if let Err(error) = self.repo.touch_last_connected(&env.id, ×tamp).await { + tracing::warn!( + environment = env.id.as_str(), + %error, + "Recording last_connected_at failed (best-effort)" + ); + } + Ok(outcome) } /// Closes the outbound WS for `id`. Disconnecting an unconnected environment - /// is idempotent success; the socket teardown body lands in PR 2.3. + /// is idempotent success. pub async fn disconnect(&self, id: &str) -> Result<(), RemoteEnvironmentError> { if id == LOCAL_ENVIRONMENT_ID { return Err(RemoteEnvironmentError::LocalEnvironment); @@ -681,9 +737,89 @@ impl RemoteEnvironmentService { .get(&RemoteEnvironmentId::from_string(id)) .await? .ok_or_else(|| RemoteEnvironmentError::UnknownEnvironment(id.to_string()))?; + self.relay.disconnect(id); Ok(()) } + /// Sends one protocol control frame (`subscribe` / `cursorAck` / `heartbeatAck`) + /// on the environment's live event socket. + /// + /// Same authorization shape as `connect`/`disconnect`, deliberately NOT + /// `authorize_proxy_target` (P-26 stays on data/command paths): background + /// environments keep live sockets for health/liveness, and an active-env gate + /// here would kill every background supervisor's stream. The frames this path + /// carries are typed protocol control only, on a socket the Rust proxy already + /// owns and whose bytes never reach JS unrelayed — a compromised renderer can at + /// most nudge a retention lease on a stream it was already receiving; it cannot + /// read new data, invoke commands, or reach a host the user never paired. + pub async fn stream_send( + &self, + id: &str, + frame: ClientFrame, + ) -> Result<(), RemoteEnvironmentError> { + self.usable_stream_target(id).await?; + self.relay.send(id, frame).map_err(stream_error) + } + + /// Mints a single-use WS ticket on the host (`POST /remote/v1/auth/ws-ticket`). + /// + /// 401/403 stay typed — they are the supervisor's `blocked` entries. Any other + /// non-2xx and any unparsable body is `Unreachable`: never a silent empty + /// ticket, never a retry-looking success. + async fn mint_ws_ticket( + &self, + env: &RemoteEnvironment, + token: &str, + ) -> Result { + let response = self + .host_client + .fetch( + &env.base_url, + token, + &RemoteFetchRequest { + path: REMOTE_WS_TICKET_PATH.to_string(), + method: "POST".to_string(), + headers: vec![("content-type".to_string(), "application/json".to_string())], + body: Some("{}".to_string()), + }, + ) + .await + .map_err(transport_error)?; + match response.status { + 401 => Err(RemoteEnvironmentError::Transport { + code: ErrorCode::RemoteUnauthorized, + message: "host refused this device's credential for the event stream" + .to_string(), + }), + 403 => Err(RemoteEnvironmentError::Transport { + code: ErrorCode::RemoteForbidden, + message: "this device's scopes do not permit the event stream".to_string(), + }), + status if !(200..300).contains(&status) => Err(RemoteEnvironmentError::Unreachable( + format!("ws ticket mint answered {status}"), + )), + _ => { + let wire: WsTicketWire = + serde_json::from_str(&response.body).map_err(|error| { + RemoteEnvironmentError::Unreachable(format!( + "ws ticket response unparsable: {error}" + )) + })?; + if wire.ticket.is_empty() { + return Err(RemoteEnvironmentError::Unreachable( + "host answered with an empty ws ticket".to_string(), + )); + } + tracing::debug!( + environment = env.id.as_str(), + expires_in_secs = wire.expires_in_secs, + "Minted a remote WS ticket" + ); + Ok(wire.ticket) + } + } + } + /// Forwards one command invoke to the active environment (§6.3). /// /// Active-env-bound: a non-active id is rejected BEFORE any transport work or @@ -879,6 +1015,41 @@ fn validate_remote_fetch_headers( Ok(out) } +/// Wire response of `POST /remote/v1/auth/ws-ticket` (host: `WsTicketResponse`, +/// camelCase on the wire). +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct WsTicketWire { + ticket: String, + expires_in_secs: i64, +} + +/// Maps outbound-WS failures into the transport taxonomy. +/// +/// `Rejected{401|403}` are the supervisor's `blocked` entries and stay typed; +/// everything else — including protocol violations — is `Unreachable`, which the +/// supervisor treats as retryable. Retry itself never lives here (A-5: the TS +/// supervisor is the sole retry owner). +fn stream_error(error: RemoteWsError) -> RemoteEnvironmentError { + match error { + RemoteWsError::Rejected { + status: 401, + message, + } => RemoteEnvironmentError::Transport { + code: ErrorCode::RemoteUnauthorized, + message, + }, + RemoteWsError::Rejected { + status: 403, + message, + } => RemoteEnvironmentError::Transport { + code: ErrorCode::RemoteForbidden, + message, + }, + other => RemoteEnvironmentError::Unreachable(other.short_reason()), + } +} + /// Maps a client→host wire failure into the transport taxonomy. /// /// `Timeout` is deliberately NOT `REMOTE_UNREACHABLE`: the request was sent, so the diff --git a/src-tauri/src/application/remote_environment_service_tests.rs b/src-tauri/src/application/remote_environment_service_tests.rs index 07de68d40b..ad08a204b9 100644 --- a/src-tauri/src/application/remote_environment_service_tests.rs +++ b/src-tauri/src/application/remote_environment_service_tests.rs @@ -8,11 +8,16 @@ use async_trait::async_trait; use ralphx_remote_protocol::{EnvironmentDescriptor, Scope, PROTOCOL_VERSION}; use super::*; +use crate::application::remote_event_relay::NoopFrameSink; use crate::domain::entities::remote_environment::RemoteEnvironmentStatus; use crate::infrastructure::memory::{MemoryRemoteEnvironmentRepository, MemorySecretStore}; use crate::infrastructure::remote_host_client::{ MockRemoteHostClient, PairWireResponse, RecordedHostCall, RemoteHostClientError, }; +use crate::infrastructure::remote_ws_client::{ + MockRemoteWsClient, MockRemoteWsConnection, MockRemoteWsHandle, RemoteWsClient, +}; +use ralphx_remote_protocol::ServerFrame; const HOST_URL: &str = "https://mac-studio.tailnet.ts.net"; const HOST_URL_DIRECT: &str = "http://100.101.102.103:3849"; @@ -42,6 +47,8 @@ struct Fixture { repo: Arc, secrets: Arc, host: Arc, + ws: Arc, + relay: Arc, service: RemoteEnvironmentService, } @@ -52,19 +59,35 @@ fn fixture() -> Fixture { )) } +/// A relay over a scripted mock socket, for services that never dial in a test. +fn test_relay() -> Arc { + Arc::new(RemoteEventRelay::new( + Arc::new(MockRemoteWsClient::new()), + Arc::new(NoopFrameSink), + )) +} + fn fixture_with_host(host: MockRemoteHostClient) -> Fixture { let repo = Arc::new(MemoryRemoteEnvironmentRepository::new()); let secrets = Arc::new(MemorySecretStore::new()); let host = Arc::new(host); + let ws = Arc::new(MockRemoteWsClient::new()); + let relay = Arc::new(RemoteEventRelay::new( + Arc::clone(&ws) as Arc, + Arc::new(NoopFrameSink), + )); let service = RemoteEnvironmentService::new( Arc::clone(&repo) as Arc, Arc::clone(&secrets) as Arc, Arc::clone(&host) as Arc, + Arc::clone(&relay), ); Fixture { repo, secrets, host, + ws, + relay, service, } } @@ -903,6 +926,7 @@ async fn remove_keeps_the_pending_delete_row_when_the_keychain_delete_fails() { Arc::clone(&repo) as _, Arc::clone(&secrets) as _, Arc::clone(&host) as _, + test_relay(), ); let env = service @@ -1070,6 +1094,7 @@ async fn reconciler_defers_pending_add_when_the_keychain_read_errors() { Arc::clone(&repo) as _, Arc::new(UnreadableSecretStore) as _, Arc::clone(&host) as _, + test_relay(), ); let env = repo .upsert_paired(crate::domain::repositories::UpsertPairedEnvironment { @@ -1171,9 +1196,34 @@ async fn reconciler_leaves_active_rows_untouched() { } // ============================================================================ -// Proxy stubs: connect/disconnect +// Event stream (PR 2.3): connect / disconnect / stream_send // ============================================================================ +fn stream_hello(protocol_version: u32) -> ServerFrame { + ServerFrame::Hello { + protocol_version, + environment_id: "env-1".to_string(), + stream_epoch: "epoch-1".to_string(), + server_version: "0.81.0".to_string(), + max_seq: 42, + heartbeat_secs: 20, + } +} + +/// Scripts the two-step happy path: a 200 ticket mint and a socket whose first +/// frame is `hello`. +fn script_stream_success(f: &Fixture, protocol_version: u32) -> MockRemoteWsHandle { + f.host + .script_fetch(200, r#"{"ticket":"tick-1","expiresInSecs":60}"#); + let (connection, handle) = MockRemoteWsConnection::scripted(); + handle + .inbound + .send(Ok(stream_hello(protocol_version))) + .expect("scripted hello should queue"); + f.ws.script_connection(connection); + handle +} + #[tokio::test] async fn connect_requires_a_registered_active_environment() { let f = fixture(); @@ -1182,16 +1232,279 @@ async fn connect_requires_a_registered_active_environment() { f.service.connect("nope").await, Err(RemoteEnvironmentError::UnknownEnvironment(_)) )); + assert!(matches!( + f.service.connect(LOCAL_ENVIRONMENT_ID).await, + Err(RemoteEnvironmentError::LocalEnvironment) + )); + // Authorization runs BEFORE any network effect: no ticket mint, no dial. + assert!(f.host.recorded_calls().is_empty()); + assert!(f.ws.dialed_urls().is_empty()); +} + +#[tokio::test] +async fn connect_mints_a_ticket_and_returns_the_hello_outcome() { + let f = fixture(); + let env = f + .service + .pair(HOST_URL, "rxp_code", "Mac Studio") + .await + .expect("pairing should succeed"); + let _handle = script_stream_success(&f, PROTOCOL_VERSION); + + let outcome = f + .service + .connect(env.id.as_str()) + .await + .expect("connect should succeed"); + + // The outcome is keyed to the registry ROW, not the host's self-reported id. + assert_eq!(outcome.environment_id, env.id.as_str()); + assert_eq!(outcome.host_environment_id, "env-1"); + assert_eq!(outcome.stream_epoch, "epoch-1"); + assert_eq!(outcome.max_seq, 42); + assert_eq!(outcome.protocol_version, PROTOCOL_VERSION); + assert!(f.relay.is_connected(env.id.as_str())); + + // The ticket was minted with the STORED bearer against the ws-ticket route… + let minted = f + .host + .recorded_calls() + .into_iter() + .find_map(|call| match call { + RecordedHostCall::Fetch { token, request, .. } => Some((token, request)), + _ => None, + }) + .expect("the host should have seen one ticket mint"); + assert_eq!(minted.0, TOKEN); + assert_eq!( + minted.1.path, + crate::infrastructure::remote_host_client::REMOTE_WS_TICKET_PATH + ); + assert_eq!(minted.1.method, "POST"); + // …and the dialed URL is the wss form of the base URL carrying that ticket. + assert_eq!( + f.ws.dialed_urls(), + vec!["wss://mac-studio.tailnet.ts.net/remote/v1/events?ticket=tick-1".to_string()] + ); + // Best-effort bookkeeping recorded the connect. + let row = f + .repo + .get(&env.id) + .await + .expect("get") + .expect("row exists"); + assert!(row.last_connected_at.is_some()); +} + +/// 401/403 on the ticket mint are the supervisor's `blocked` entries; they must +/// stay typed and must stop the dial. +#[tokio::test] +async fn a_refused_ticket_mint_is_typed_and_never_dials() { + for (status, expected) in [(401, "REMOTE_UNAUTHORIZED"), (403, "REMOTE_FORBIDDEN")] { + let f = fixture(); + let env = f + .service + .pair(HOST_URL, "rxp_code", "Mac Studio") + .await + .expect("pairing should succeed"); + f.host.script_fetch(status, "no"); + + let error = f + .service + .connect(env.id.as_str()) + .await + .expect_err("a refused ticket must fail"); + assert_eq!(error.code(), expected, "status {status}"); + assert!(f.ws.dialed_urls().is_empty(), "no ticket, no dial"); + assert!(!f.relay.is_connected(env.id.as_str())); + } +} + +/// A ticket body that does not parse is `Unreachable` — never a silent empty +/// ticket smuggled into the dial URL. +#[tokio::test] +async fn an_unparsable_ticket_body_is_unreachable() { + let f = fixture(); + let env = f + .service + .pair(HOST_URL, "rxp_code", "Mac Studio") + .await + .expect("pairing should succeed"); + f.host.script_fetch(200, r#"{"ok":true,"result":null}"#); + + let error = f + .service + .connect(env.id.as_str()) + .await + .expect_err("an unparsable ticket body must fail"); + assert_eq!(error.code(), "REMOTE_UNREACHABLE"); + assert!(f.ws.dialed_urls().is_empty()); +} + +/// A refused WS handshake keeps its auth typing so the supervisor blocks instead +/// of retrying a dead credential. +#[tokio::test] +async fn a_rejected_ws_handshake_maps_to_the_auth_taxonomy() { + let f = fixture(); + let env = f + .service + .pair(HOST_URL, "rxp_code", "Mac Studio") + .await + .expect("pairing should succeed"); + f.host + .script_fetch(200, r#"{"ticket":"tick-1","expiresInSecs":60}"#); + f.ws + .script_error(crate::infrastructure::remote_ws_client::RemoteWsError::Rejected { + status: 403, + message: "scope refused".to_string(), + }); + + let error = f + .service + .connect(env.id.as_str()) + .await + .expect_err("a rejected handshake must fail"); + assert_eq!(error.code(), "REMOTE_FORBIDDEN"); + assert!(!f.relay.is_connected(env.id.as_str())); +} + +/// The hello version is relayed VERBATIM, never gated in Rust: negotiation is +/// `minClientProtocol`-based and only the descriptor carries that field, so a +/// Rust-side gate against the pairing-time snapshot would false-block a +/// legitimately upgraded host. The TS supervisor owns the `blocked` decision +/// (§6.5) with the descriptor in hand. +#[tokio::test] +async fn a_hello_version_that_differs_from_the_stored_row_still_connects() { + let f = fixture(); + let env = f + .service + .pair(HOST_URL, "rxp_code", "Mac Studio") + .await + .expect("pairing should succeed"); + let _handle = script_stream_success(&f, PROTOCOL_VERSION + 1); + + let outcome = f + .service + .connect(env.id.as_str()) + .await + .expect("a newer host hello must not be blocked in Rust"); + assert_eq!( + outcome.protocol_version, + PROTOCOL_VERSION + 1, + "the hello version reaches TS verbatim for the supervisor's gate" + ); + assert!( + f.relay.is_connected(env.id.as_str()), + "the socket survives; the skew decision belongs to the supervisor" + ); +} + +#[tokio::test] +async fn disconnect_tears_the_session_down_and_is_idempotent_when_unconnected() { + let f = fixture(); + let env = f + .service + .pair(HOST_URL, "rxp_code", "Mac Studio") + .await + .expect("pairing should succeed"); + let _handle = script_stream_success(&f, PROTOCOL_VERSION); + f.service + .connect(env.id.as_str()) + .await + .expect("connect should succeed"); + + f.service + .disconnect(env.id.as_str()) + .await + .expect("disconnect should succeed"); + assert!(!f.relay.is_connected(env.id.as_str())); + // Idempotent: disconnecting an unconnected environment stays success. + assert!(f.service.disconnect(env.id.as_str()).await.is_ok()); +} +#[tokio::test] +async fn stream_send_reaches_the_live_socket() { + let f = fixture(); let env = f .service .pair(HOST_URL, "rxp_code", "Mac Studio") .await .expect("pairing should succeed"); - // Transport is a stub until PR 2.3; the typed error proves authorization ran. + let mut handle = script_stream_success(&f, PROTOCOL_VERSION); + f.service + .connect(env.id.as_str()) + .await + .expect("connect should succeed"); + + f.service + .stream_send( + env.id.as_str(), + ralphx_remote_protocol::ClientFrame::Subscribe { + after_seq: 42, + stream_epoch: "epoch-1".to_string(), + }, + ) + .await + .expect("stream_send should reach the live session"); + + let sent = tokio::time::timeout(std::time::Duration::from_secs(5), handle.outbound.recv()) + .await + .expect("the frame should arrive in time") + .expect("the outbound channel should stay open"); + assert_eq!( + sent, + ralphx_remote_protocol::ClientFrame::Subscribe { + after_seq: 42, + stream_epoch: "epoch-1".to_string(), + } + ); +} + +/// The stream surface shares `connect`'s guards — local and unknown targets are +/// refused, and NO active-environment binding applies: background environments +/// keep their sockets (and their `subscribe`/`cursorAck` frames) alive (§6.4). +#[tokio::test] +async fn stream_send_uses_the_stream_guards_not_the_active_binding() { + let f = fixture(); + let (a, b) = two_paired_environments(&f).await; + f.service + .set_active_environment(&a) + .await + .expect("activating A should succeed"); + assert!(matches!( - f.service.connect(env.id.as_str()).await, - Err(RemoteEnvironmentError::NotConnected) + f.service + .stream_send( + LOCAL_ENVIRONMENT_ID, + ralphx_remote_protocol::ClientFrame::CursorAck { seq: 1 }, + ) + .await, + Err(RemoteEnvironmentError::LocalEnvironment) )); - assert!(f.service.disconnect(env.id.as_str()).await.is_ok()); + assert!(matches!( + f.service + .stream_send( + "nope", + ralphx_remote_protocol::ClientFrame::CursorAck { seq: 1 }, + ) + .await, + Err(RemoteEnvironmentError::UnknownEnvironment(_)) + )); + + // BACKGROUND environment: the control frame is authorized (no active-env gate) + // and fails only because no session is live — as `Unreachable`, which the + // supervisor retries, never `NotActiveEnvironment`, which it would block on. + let error = f + .service + .stream_send( + &b, + ralphx_remote_protocol::ClientFrame::CursorAck { seq: 1 }, + ) + .await + .expect_err("no live session for B"); + assert_eq!(error.code(), "REMOTE_UNREACHABLE"); + assert!( + !matches!(error, RemoteEnvironmentError::NotActiveEnvironment { .. }), + "background environments' stream control must not be active-env-gated" + ); } diff --git a/src-tauri/src/application/remote_event_relay.rs b/src-tauri/src/application/remote_event_relay.rs new file mode 100644 index 0000000000..c9b68c2487 --- /dev/null +++ b/src-tauri/src/application/remote_event_relay.rs @@ -0,0 +1,337 @@ +// RemoteEventRelay — per-environment outbound WS sessions and the frame relay into +// the webview (PR 2.3, §3.2/§6.5). +// +// Ownership split, deliberately: +// - This relay owns the SOCKETS: one live session per environment row, hello +// validation, Rust-side heartbeat acks, and the relay of every server frame into +// the local event bus. +// - The TS `NetworkEventBus` owns the PROTOCOL CURSOR: it decides `afterSeq` +// (cold `H = hello.maxSeq` vs warm `lastSeq`) and sends `subscribe`/`cursorAck` +// through `remote_stream_send`. The relay never subscribes on its own. +// - The TS supervisor owns RETRY (A-5). A dropped socket here emits exactly one +// `remote:stream_closed` and stops; nothing in Rust redials. +// +// The relayed events are classified **Local-only** in the protocol crate: they are +// the CLIENT proxy's inbound channel, and fanning them back out through a host's +// capture bank would let one paired device watch another's stream. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex as StdMutex, PoisonError}; + +use ralphx_remote_protocol::{ClientFrame, ErrorCode, ServerFrame}; +use serde_json::json; +use tokio::sync::{mpsc, oneshot}; + +use crate::infrastructure::remote_ws_client::{ + ws_events_url, RemoteWsClient, RemoteWsConnection, RemoteWsError, +}; + +#[cfg(test)] +#[path = "remote_event_relay_tests.rs"] +mod tests; + +/// Sink for relayed frames — a seam so relay tests need no Tauri AppHandle +/// (mirrors `remote_server::ws::SessionLifecycleSink`). +pub trait RemoteFrameSink: Send + Sync { + fn emit(&self, name: &str, payload: serde_json::Value); +} + +/// No-op sink for relays built without an app handle (unit tests, AppState::new_test). +pub struct NoopFrameSink; + +impl RemoteFrameSink for NoopFrameSink { + fn emit(&self, _name: &str, _payload: serde_json::Value) {} +} + +/// Production sink: the webview's local event bus (mirrors `TauriLifecycleSink`). +pub struct TauriFrameSink(pub tauri::AppHandle); + +impl RemoteFrameSink for TauriFrameSink { + fn emit(&self, name: &str, payload: serde_json::Value) { + use tauri::Emitter; + if let Err(error) = self.0.emit(name, payload) { + tracing::warn!(%error, event_name = name, "Emitting a remote stream event failed"); + } + } +} + +/// Local-only relay events. FIXED names, never formatted — a dynamic emit name is +/// unresolvable to the P-6 emit scanner, so the environment id travels in the +/// payload instead. Classified Local-only in `ralphx-remote-protocol`. +pub const REMOTE_STREAM_FRAME_EVENT: &str = "remote:stream_frame"; +pub const REMOTE_STREAM_CLOSED_EVENT: &str = "remote:stream_closed"; + +/// What `remote_connect` hands back to JS: the hello, re-keyed to the registry row. +#[derive(Debug, Clone, PartialEq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteConnectOutcome { + /// The registry ROW id — the JS-facing environment identity. + pub environment_id: String, + /// The host's self-reported environment id (`hello.environmentId`). + pub host_environment_id: String, + pub stream_epoch: String, + pub max_seq: u64, + pub heartbeat_secs: u32, + pub protocol_version: u32, +} + +/// One live session's registry entry. The `generation` makes "am I still the +/// current session" falsifiable: a superseded session must never deregister (or +/// otherwise touch) its replacement. +struct RelaySession { + generation: u64, + outbound: mpsc::UnboundedSender, + kill: oneshot::Sender<()>, +} + +pub struct RemoteEventRelay { + ws_client: Arc, + sink: Arc, + /// `Arc` so the spawned relay task can deregister itself; the map is never + /// locked across an await. + sessions: Arc>>, + generations: AtomicU64, +} + +/// The sessions map only ever holds `Send` handles and is never locked across an +/// await; if a panic ever poisons it the map itself is still structurally sound, so +/// recover the guard instead of turning every later connect into a panic. +fn lock_sessions( + sessions: &StdMutex>, +) -> std::sync::MutexGuard<'_, HashMap> { + sessions.lock().unwrap_or_else(PoisonError::into_inner) +} + +impl RemoteEventRelay { + pub fn new(ws_client: Arc, sink: Arc) -> Self { + Self { + ws_client, + sink, + sessions: Arc::new(StdMutex::new(HashMap::new())), + generations: AtomicU64::new(0), + } + } + + /// Opens the outbound WS for `row_id`: tear down any live session first + /// (idempotent reconnect — never two sockets per environment), dial, require + /// `hello` as the FIRST frame, then hand the socket to the relay task. + /// + /// Deliberately does NOT send `subscribe`: the TS `NetworkEventBus` owns + /// `afterSeq` and sends it through `remote_stream_send`. + pub async fn connect( + &self, + row_id: &str, + base_url: &str, + ticket: &str, + ) -> Result { + self.teardown(row_id); + + let url = ws_events_url(base_url, ticket)?; + let mut connection = self.ws_client.connect(&url).await?; + + // §3.2: the server speaks first, and it speaks `hello`. Anything else is not + // a stream this client can trust — close and report, register nothing. + let outcome = match connection.recv().await { + Some(Ok(ServerFrame::Hello { + protocol_version, + environment_id, + stream_epoch, + server_version: _, + max_seq, + heartbeat_secs, + })) => RemoteConnectOutcome { + environment_id: row_id.to_string(), + host_environment_id: environment_id, + stream_epoch, + max_seq, + heartbeat_secs, + protocol_version, + }, + Some(Ok(ServerFrame::Error { code, message })) => { + connection.close().await; + // An auth refusal after the upgrade must look exactly like one before + // it, or the supervisor would retry a dead credential (§6.5 blocked). + return Err(match code { + ErrorCode::RemoteUnauthorized => RemoteWsError::Rejected { + status: 401, + message, + }, + ErrorCode::RemoteForbidden => RemoteWsError::Rejected { + status: 403, + message, + }, + _ => RemoteWsError::Protocol(format!( + "host opened the stream with an error frame: {message}" + )), + }); + } + Some(Ok(other)) => { + connection.close().await; + return Err(RemoteWsError::Protocol(format!( + "expected hello as the first frame, got {}", + frame_kind(&other) + ))); + } + Some(Err(error)) => { + connection.close().await; + return Err(error); + } + None => { + return Err(RemoteWsError::Closed( + "socket ended before hello".to_string(), + )) + } + }; + + let generation = self.generations.fetch_add(1, Ordering::Relaxed) + 1; + let (outbound_tx, outbound_rx) = mpsc::unbounded_channel(); + let (kill_tx, kill_rx) = oneshot::channel(); + { + let mut sessions = lock_sessions(&self.sessions); + if let Some(displaced) = sessions.insert( + row_id.to_string(), + RelaySession { + generation, + outbound: outbound_tx, + kill: kill_tx, + }, + ) { + // A concurrent connect raced this one between teardown and insert. + // The displaced session dies; its teardown cannot deregister this + // one (generation mismatch). + let _ = displaced.kill.send(()); + } + } + // Rule 17: this is an async fn, so a plain `tokio::spawn` is the correct + // spawn — never from a sync constructor. + tokio::spawn(run_relay_session( + row_id.to_string(), + generation, + connection, + outbound_rx, + kill_rx, + Arc::clone(&self.sink), + Arc::clone(&self.sessions), + )); + Ok(outcome) + } + + /// Pushes one client frame onto the session's socket. + pub fn send(&self, row_id: &str, frame: ClientFrame) -> Result<(), RemoteWsError> { + let sessions = lock_sessions(&self.sessions); + let Some(session) = sessions.get(row_id) else { + return Err(RemoteWsError::Closed( + "no live session for this environment".to_string(), + )); + }; + session.outbound.send(frame).map_err(|_| { + RemoteWsError::Closed("the relay task for this environment has ended".to_string()) + }) + } + + /// Idempotent teardown: fires the kill and drops the registry entry. The relay + /// task emits the single `remote:stream_closed` on its way out. + pub fn disconnect(&self, row_id: &str) { + self.teardown(row_id); + } + + pub fn is_connected(&self, row_id: &str) -> bool { + lock_sessions(&self.sessions).contains_key(row_id) + } + + fn teardown(&self, row_id: &str) { + let removed = lock_sessions(&self.sessions).remove(row_id); + if let Some(session) = removed { + // The task may already have exited; a dead receiver is fine. + let _ = session.kill.send(()); + } + } +} + +/// Frame name for error messages, without dragging payloads into them. +fn frame_kind(frame: &ServerFrame) -> &'static str { + match frame { + ServerFrame::Hello { .. } => "hello", + ServerFrame::Event { .. } => "event", + ServerFrame::ReplayDone { .. } => "replayDone", + ServerFrame::Reset { .. } => "reset", + ServerFrame::Heartbeat { .. } => "heartbeat", + ServerFrame::Error { .. } => "error", + } +} + +/// What one turn of the relay loop observed. Classification only — the handlers run +/// after the `select!`, once the branch futures are dropped, which is what lets a +/// handler send on the same socket a branch was reading from (same shape as the +/// host's `run_session`). +enum RelayEvent { + Kill, + Outbound(Option), + Incoming(Option>), +} + +/// Runs one relay session to completion. Whichever way it leaves — peer close, +/// send failure, kill — it closes the socket, deregisters ONLY its own generation, +/// and emits `remote:stream_closed` exactly once. +async fn run_relay_session( + row_id: String, + generation: u64, + mut connection: Box, + mut outbound: mpsc::UnboundedReceiver, + mut kill: oneshot::Receiver<()>, + sink: Arc, + sessions: Arc>>, +) { + let reason = loop { + let event = tokio::select! { + biased; + _ = &mut kill => RelayEvent::Kill, + frame = outbound.recv() => RelayEvent::Outbound(frame), + incoming = connection.recv() => RelayEvent::Incoming(incoming), + }; + match event { + RelayEvent::Kill => break "disconnected".to_string(), + // The registry entry owning this sender is gone: torn down or superseded. + RelayEvent::Outbound(None) => break "disconnected".to_string(), + RelayEvent::Outbound(Some(frame)) => { + if let Err(error) = connection.send(frame).await { + break error.short_reason(); + } + } + RelayEvent::Incoming(None) => break "socket closed".to_string(), + RelayEvent::Incoming(Some(Err(error))) => break error.short_reason(), + RelayEvent::Incoming(Some(Ok(frame))) => { + if let ServerFrame::Heartbeat { t } = frame { + // Acked HERE, in Rust, so a busy webview can never starve the + // host's 2-unacked budget (§3.2). The frame is still relayed — + // the TS watchdog counts frame silence, not acks. + if let Err(error) = connection.send(ClientFrame::HeartbeatAck { t }).await { + break error.short_reason(); + } + } + sink.emit( + REMOTE_STREAM_FRAME_EVENT, + json!({ "environmentId": row_id, "frame": frame }), + ); + } + } + }; + + connection.close().await; + { + // Deregister only when the slot still belongs to THIS session: a superseded + // session tearing down must not deregister its replacement. + let mut sessions = lock_sessions(&sessions); + if sessions + .get(&row_id) + .is_some_and(|session| session.generation == generation) + { + sessions.remove(&row_id); + } + } + sink.emit( + REMOTE_STREAM_CLOSED_EVENT, + json!({ "environmentId": row_id, "reason": reason }), + ); +} diff --git a/src-tauri/src/application/remote_event_relay_tests.rs b/src-tauri/src/application/remote_event_relay_tests.rs new file mode 100644 index 0000000000..808f493a42 --- /dev/null +++ b/src-tauri/src/application/remote_event_relay_tests.rs @@ -0,0 +1,350 @@ +// RemoteEventRelay tests: hello gating, frame relay, Rust-side heartbeat acks, +// single-close teardown, and generation-scoped supersession. All against the +// scripted mock socket — no real network. + +use super::*; +use crate::infrastructure::remote_ws_client::{ + MockRemoteWsClient, MockRemoteWsConnection, MockRemoteWsHandle, +}; +use ralphx_remote_protocol::PROTOCOL_VERSION; + +const ROW_ID: &str = "row-1"; + +/// Recording sink backed by a channel so tests can AWAIT relayed events instead of +/// polling shared state. +struct ChannelSink(mpsc::UnboundedSender<(String, serde_json::Value)>); + +impl RemoteFrameSink for ChannelSink { + fn emit(&self, name: &str, payload: serde_json::Value) { + let _ = self.0.send((name.to_string(), payload)); + } +} + +struct Fixture { + relay: RemoteEventRelay, + ws: Arc, + events: mpsc::UnboundedReceiver<(String, serde_json::Value)>, +} + +fn fixture() -> Fixture { + let ws = Arc::new(MockRemoteWsClient::new()); + let (tx, events) = mpsc::unbounded_channel(); + let relay = RemoteEventRelay::new( + Arc::clone(&ws) as Arc, + Arc::new(ChannelSink(tx)), + ); + Fixture { relay, ws, events } +} + +fn hello() -> ServerFrame { + ServerFrame::Hello { + protocol_version: PROTOCOL_VERSION, + environment_id: "env-1".to_string(), + stream_epoch: "epoch-1".to_string(), + server_version: "0.81.0".to_string(), + max_seq: 42, + heartbeat_secs: 20, + } +} + +/// Scripts one connection whose first frame is already queued. +fn script_hello_connection(f: &Fixture) -> MockRemoteWsHandle { + let (connection, handle) = MockRemoteWsConnection::scripted(); + handle + .inbound + .send(Ok(hello())) + .expect("scripted hello should queue"); + f.ws.script_connection(connection); + handle +} + +async fn recv_event(f: &mut Fixture) -> (String, serde_json::Value) { + tokio::time::timeout(std::time::Duration::from_secs(5), f.events.recv()) + .await + .expect("a relayed event should arrive in time") + .expect("the sink channel should stay open") +} + +// ============================================================================ +// connect — hello gating +// ============================================================================ + +#[tokio::test] +async fn connect_captures_the_hello_and_registers_the_session() { + let mut f = fixture(); + let _handle = script_hello_connection(&f); + + let outcome = f + .relay + .connect(ROW_ID, "http://100.101.102.103:3849", "tick-1") + .await + .expect("connect should succeed"); + + assert_eq!( + outcome, + RemoteConnectOutcome { + environment_id: ROW_ID.to_string(), + host_environment_id: "env-1".to_string(), + stream_epoch: "epoch-1".to_string(), + max_seq: 42, + heartbeat_secs: 20, + protocol_version: PROTOCOL_VERSION, + } + ); + assert!(f.relay.is_connected(ROW_ID)); + assert_eq!( + f.ws.dialed_urls(), + vec!["ws://100.101.102.103:3849/remote/v1/events?ticket=tick-1".to_string()] + ); + // No subscribe went out: the TS NetworkEventBus owns afterSeq. + assert!( + f.events.try_recv().is_err(), + "connect alone must not relay anything" + ); +} + +#[tokio::test] +async fn a_non_hello_first_frame_is_a_protocol_error_and_leaves_no_session() { + let f = fixture(); + let (connection, handle) = MockRemoteWsConnection::scripted(); + handle + .inbound + .send(Ok(ServerFrame::Heartbeat { t: 1 })) + .expect("scripted frame should queue"); + f.ws.script_connection(connection); + + let error = f + .relay + .connect(ROW_ID, "https://host.example", "tick-1") + .await + .expect_err("a non-hello first frame must fail"); + + assert!(matches!(error, RemoteWsError::Protocol(_))); + assert!(!f.relay.is_connected(ROW_ID)); + assert!(handle.was_closed(), "the refused socket must be closed"); +} + +/// An auth refusal delivered as the first frame must look exactly like a refused +/// handshake, so the supervisor blocks instead of retrying a dead credential. +#[tokio::test] +async fn an_unauthorized_error_first_frame_is_rejected_401() { + let f = fixture(); + let (connection, handle) = MockRemoteWsConnection::scripted(); + handle + .inbound + .send(Ok(ServerFrame::Error { + code: ErrorCode::RemoteUnauthorized, + message: "This device is no longer authorized.".to_string(), + })) + .expect("scripted frame should queue"); + f.ws.script_connection(connection); + + let error = f + .relay + .connect(ROW_ID, "https://host.example", "tick-1") + .await + .expect_err("an unauthorized first frame must fail"); + + assert_eq!( + error, + RemoteWsError::Rejected { + status: 401, + message: "This device is no longer authorized.".to_string(), + } + ); + assert!(!f.relay.is_connected(ROW_ID)); + assert!(handle.was_closed()); +} + +#[tokio::test] +async fn a_socket_that_ends_before_hello_is_closed_with_no_session() { + let f = fixture(); + let (connection, handle) = MockRemoteWsConnection::scripted(); + drop(handle); // peer closes before speaking + f.ws.script_connection(connection); + + let error = f + .relay + .connect(ROW_ID, "https://host.example", "tick-1") + .await + .expect_err("a silent socket must fail"); + assert!(matches!(error, RemoteWsError::Closed(_))); + assert!(!f.relay.is_connected(ROW_ID)); +} + +// ============================================================================ +// Relay — frames, heartbeats +// ============================================================================ + +#[tokio::test] +async fn relayed_frames_carry_the_row_id_and_the_wire_shaped_frame() { + let mut f = fixture(); + let handle = script_hello_connection(&f); + f.relay + .connect(ROW_ID, "https://host.example", "tick-1") + .await + .expect("connect should succeed"); + + handle + .inbound + .send(Ok(ServerFrame::Event { + seq: Some(43), + name: "task:created".to_string(), + payload: serde_json::json!({"id": "task-1"}), + })) + .expect("event should queue"); + + let (name, payload) = recv_event(&mut f).await; + assert_eq!(name, REMOTE_STREAM_FRAME_EVENT); + assert_eq!(payload["environmentId"], ROW_ID); + // The frame keeps its wire shape (camelCase type tag) so TS reuses one decoder. + assert_eq!(payload["frame"]["type"], "event"); + assert_eq!(payload["frame"]["seq"], 43); + assert_eq!(payload["frame"]["name"], "task:created"); +} + +#[tokio::test] +async fn a_heartbeat_is_acked_on_the_socket_and_still_relayed() { + let mut f = fixture(); + let mut handle = script_hello_connection(&f); + f.relay + .connect(ROW_ID, "https://host.example", "tick-1") + .await + .expect("connect should succeed"); + + handle + .inbound + .send(Ok(ServerFrame::Heartbeat { t: 7 })) + .expect("heartbeat should queue"); + + // The relay is what the TS watchdog counts: the heartbeat must arrive there… + let (name, payload) = recv_event(&mut f).await; + assert_eq!(name, REMOTE_STREAM_FRAME_EVENT); + assert_eq!(payload["frame"]["type"], "heartbeat"); + // …and the ack was already on the socket BEFORE the relay emit, from Rust, so a + // busy webview can never exhaust the host's 2-unacked budget. + let acked = handle + .outbound + .try_recv() + .expect("the ack must precede the relayed frame"); + assert_eq!(acked, ClientFrame::HeartbeatAck { t: 7 }); +} + +#[tokio::test] +async fn send_reaches_the_socket() { + let f = fixture(); + let mut handle = script_hello_connection(&f); + f.relay + .connect(ROW_ID, "https://host.example", "tick-1") + .await + .expect("connect should succeed"); + + f.relay + .send( + ROW_ID, + ClientFrame::Subscribe { + after_seq: 42, + stream_epoch: "epoch-1".to_string(), + }, + ) + .expect("send should reach the live session"); + + let sent = tokio::time::timeout(std::time::Duration::from_secs(5), handle.outbound.recv()) + .await + .expect("outbound frame should arrive in time") + .expect("outbound channel should stay open"); + assert_eq!( + sent, + ClientFrame::Subscribe { + after_seq: 42, + stream_epoch: "epoch-1".to_string(), + } + ); +} + +#[tokio::test] +async fn send_to_an_unknown_environment_is_a_typed_error_not_a_panic() { + let f = fixture(); + let error = f + .relay + .send("nope", ClientFrame::CursorAck { seq: 1 }) + .expect_err("no session, no send"); + assert!(matches!(error, RemoteWsError::Closed(_))); +} + +// ============================================================================ +// Teardown — single close event, registry hygiene, supersession +// ============================================================================ + +#[tokio::test] +async fn a_socket_end_emits_exactly_one_closed_event_and_clears_the_registry() { + let mut f = fixture(); + let handle = script_hello_connection(&f); + f.relay + .connect(ROW_ID, "https://host.example", "tick-1") + .await + .expect("connect should succeed"); + + drop(handle.inbound); // the peer goes away + + let (name, payload) = recv_event(&mut f).await; + assert_eq!(name, REMOTE_STREAM_CLOSED_EVENT); + assert_eq!(payload["environmentId"], ROW_ID); + assert_eq!(payload["reason"], "socket closed"); + assert!(!f.relay.is_connected(ROW_ID)); + // Exactly once: the task has exited (the closed event is its last act), so + // nothing further can arrive. + assert!(f.events.try_recv().is_err()); +} + +#[tokio::test] +async fn disconnect_kills_the_session_and_is_idempotent() { + let mut f = fixture(); + let _handle = script_hello_connection(&f); + f.relay + .connect(ROW_ID, "https://host.example", "tick-1") + .await + .expect("connect should succeed"); + + f.relay.disconnect(ROW_ID); + f.relay.disconnect(ROW_ID); // second call is a no-op, not a panic + + let (name, payload) = recv_event(&mut f).await; + assert_eq!(name, REMOTE_STREAM_CLOSED_EVENT); + assert_eq!(payload["reason"], "disconnected"); + assert!(!f.relay.is_connected(ROW_ID)); + assert!(f.events.try_recv().is_err()); +} + +/// One socket per environment: reconnecting supersedes the old session, and the OLD +/// session's teardown must not deregister the NEW one (generation check). +#[tokio::test] +async fn reconnecting_the_same_environment_supersedes_without_killing_the_replacement() { + let mut f = fixture(); + let handle_a = script_hello_connection(&f); + f.relay + .connect(ROW_ID, "https://host.example", "tick-1") + .await + .expect("first connect should succeed"); + + let handle_b = script_hello_connection(&f); + f.relay + .connect(ROW_ID, "https://host.example", "tick-2") + .await + .expect("second connect should succeed"); + + // The old session announces its own death… + let (name, payload) = recv_event(&mut f).await; + assert_eq!(name, REMOTE_STREAM_CLOSED_EVENT); + assert_eq!(payload["environmentId"], ROW_ID); + assert!(handle_a.was_closed(), "the superseded socket must be closed"); + // …and the NEW session survived it: still registered, still relaying. + assert!(f.relay.is_connected(ROW_ID)); + handle_b + .inbound + .send(Ok(ServerFrame::Heartbeat { t: 9 })) + .expect("frame should queue on the new socket"); + let (name, payload) = recv_event(&mut f).await; + assert_eq!(name, REMOTE_STREAM_FRAME_EVENT); + assert_eq!(payload["frame"]["t"], 9); +} diff --git a/src-tauri/src/commands/registry.rs b/src-tauri/src/commands/registry.rs index 58a3691c91..25073e08c9 100644 --- a/src-tauri/src/commands/registry.rs +++ b/src-tauri/src/commands/registry.rs @@ -29,6 +29,7 @@ macro_rules! register_tauri_commands { commands::remote_environment_commands::set_active_environment, commands::remote_environment_commands::remote_connect, commands::remote_environment_commands::remote_disconnect, + commands::remote_environment_commands::remote_stream_send, commands::remote_environment_commands::remote_invoke, commands::remote_environment_commands::remote_fetch, // remote auth (PR 1.2) diff --git a/src-tauri/src/commands/remote_environment_commands.rs b/src-tauri/src/commands/remote_environment_commands.rs index 28ae53595d..63ef6a5cec 100644 --- a/src-tauri/src/commands/remote_environment_commands.rs +++ b/src-tauri/src/commands/remote_environment_commands.rs @@ -8,6 +8,7 @@ //! trusted JS argument (P-26) — the `id` args below only SELECT a target, the //! service decides whether that target is authorized. +use ralphx_remote_protocol::ClientFrame; use serde::{Deserialize, Serialize}; use tauri::State; @@ -15,6 +16,7 @@ use crate::application::remote_environment_service::{ RemoteEnvironmentError, RemoteEnvironmentService, RemoteFetchCall, RemoteFetchOutcome, RemoteInvokeOutcome, }; +use crate::application::remote_event_relay::RemoteConnectOutcome; use crate::domain::entities::remote_environment::{RemoteEnvironment, RemoteEnvironmentStatus}; use crate::AppState; @@ -176,13 +178,15 @@ pub async fn set_active_environment( .map_err(to_command_error) } -/// Opens the Rust-owned outbound connection for an environment (WS body lands in -/// PR 2.3 — until then this returns `NOT_CONNECTED` after authorization). +/// Opens the Rust-owned outbound event socket for an environment (§3.2): bearer → +/// single-use ticket → dial → hello. The hello comes back as the outcome; the +/// stream frames themselves arrive as local `remote:stream_frame` events. The +/// socket — and the bearer/ticket — never reach JS (P-18). #[tauri::command] pub async fn remote_connect( input: RemoteEnvironmentIdInput, state: State<'_, AppState>, -) -> Result<(), String> { +) -> Result { service(&state) .connect(&input.id) .await @@ -200,6 +204,31 @@ pub async fn remote_disconnect( .map_err(to_command_error) } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteStreamSendInput { + pub id: String, + /// Typed as `ClientFrame` on purpose — the deserializer bounds what can be sent + /// (`subscribe` / `cursorAck` / `heartbeatAck`); this is never a raw JSON + /// passthrough to the socket. + pub frame: ClientFrame, +} + +/// Sends one protocol control frame on an environment's live event socket. The TS +/// `NetworkEventBus` owns the cursor (`afterSeq`, `cursorAck`) and speaks through +/// this command. Same authorization as `remote_connect` — background environments' +/// sockets stay drivable (§6.4); data/command paths remain active-env-bound (P-26). +#[tauri::command] +pub async fn remote_stream_send( + input: RemoteStreamSendInput, + state: State<'_, AppState>, +) -> Result<(), String> { + service(&state) + .stream_send(&input.id, input.frame) + .await + .map_err(to_command_error) +} + /// Forwards one command invoke through the Rust proxy (§6.3). Active-env-bound /// (P-26); the bearer stays in Rust. /// diff --git a/src-tauri/src/commands/remote_environment_commands_tests.rs b/src-tauri/src/commands/remote_environment_commands_tests.rs index 337030eb41..c17770dc23 100644 --- a/src-tauri/src/commands/remote_environment_commands_tests.rs +++ b/src-tauri/src/commands/remote_environment_commands_tests.rs @@ -101,6 +101,34 @@ fn invoke_input_defaults_missing_args_to_null() { assert!(input.args.is_null()); } +/// The frame arrives typed: the `ClientFrame` deserializer bounds what the webview +/// can put on the socket — never a raw JSON passthrough. +#[test] +fn stream_send_input_accepts_camel_case_wire_frames() { + let input: RemoteStreamSendInput = serde_json::from_value(serde_json::json!({ + "id": "row-1", + "frame": {"type": "subscribe", "afterSeq": 10, "streamEpoch": "epoch-1"}, + })) + .expect("camelCase frame should deserialize"); + assert_eq!(input.id, "row-1"); + assert_eq!( + input.frame, + ralphx_remote_protocol::ClientFrame::Subscribe { + after_seq: 10, + stream_epoch: "epoch-1".to_string(), + } + ); + + assert!( + serde_json::from_value::(serde_json::json!({ + "id": "row-1", + "frame": {"type": "not_a_client_frame"}, + })) + .is_err(), + "unknown frame types must be refused at the IPC boundary" + ); +} + #[test] fn pair_input_accepts_camel_case_fields() { let input: PairRemoteEnvironmentInput = serde_json::from_value(serde_json::json!({ @@ -140,6 +168,7 @@ fn the_remote_environment_command_surface_is_registered() { "remote_environment_commands::set_active_environment", "remote_environment_commands::remote_connect", "remote_environment_commands::remote_disconnect", + "remote_environment_commands::remote_stream_send", "remote_environment_commands::remote_invoke", "remote_environment_commands::remote_fetch", ] { @@ -262,6 +291,21 @@ async fn remote_disconnect_rejects_an_unknown_id() { assert!(!error.is_empty()); } +#[tokio::test] +async fn remote_stream_send_rejects_an_unknown_id() { + let app = test_app(); + let error = remote_stream_send( + RemoteStreamSendInput { + id: "missing".to_string(), + frame: ralphx_remote_protocol::ClientFrame::CursorAck { seq: 1 }, + }, + app.state::(), + ) + .await + .expect_err("unknown environment should be rejected"); + assert!(!error.is_empty()); +} + #[tokio::test] async fn remote_invoke_rejects_an_unknown_id() { let app = test_app(); @@ -316,6 +360,10 @@ async fn list_remote_environments_maps_a_seeded_environment() { Arc::new(crate::infrastructure::UnavailableRemoteHostClient::new( "not needed by list", )), + Arc::new(crate::application::remote_event_relay::RemoteEventRelay::new( + Arc::new(crate::infrastructure::remote_ws_client::MockRemoteWsClient::new()), + Arc::new(crate::application::remote_event_relay::NoopFrameSink), + )), ); let app = test_app_with_service(service); diff --git a/src-tauri/src/infrastructure/mod.rs b/src-tauri/src/infrastructure/mod.rs index 69f8bbc36e..bf5143f3af 100644 --- a/src-tauri/src/infrastructure/mod.rs +++ b/src-tauri/src/infrastructure/mod.rs @@ -13,6 +13,7 @@ pub mod linear_client; pub mod login_shell_env; pub mod memory; pub mod remote_host_client; +pub mod remote_ws_client; pub mod services; pub mod secret_store; pub mod sqlite; @@ -35,6 +36,9 @@ pub use linear_client::HyperLinearApiClient; pub use remote_host_client::{ HyperRemoteHostClient, RemoteHostClient, RemoteHostClientError, UnavailableRemoteHostClient, }; +pub use remote_ws_client::{ + RemoteWsClient, RemoteWsConnection, RemoteWsError, TungsteniteRemoteWsClient, +}; pub use services::GhCliGithubService; pub use sqlite::{get_default_db_path, open_connection, open_memory_connection, run_migrations}; pub use supervisor::{EventBus, EventSubscriber}; diff --git a/src-tauri/src/infrastructure/remote_host_client.rs b/src-tauri/src/infrastructure/remote_host_client.rs index 52563825b7..fe414a0740 100644 --- a/src-tauri/src/infrastructure/remote_host_client.rs +++ b/src-tauri/src/infrastructure/remote_host_client.rs @@ -35,6 +35,8 @@ pub const REMOTE_PAIR_PATH: &str = "/remote/v1/auth/pair"; pub const REMOTE_SESSION_PATH: &str = "/remote/v1/session"; /// Self-revocation route used by the staged remove machine (best-effort). pub const REMOTE_REVOKE_PATH: &str = "/remote/v1/auth/revoke"; +/// Single-use WS ticket mint (mirrors `remote_server::WS_TICKET_PATH`, §3.2). +pub const REMOTE_WS_TICKET_PATH: &str = "/remote/v1/auth/ws-ticket"; /// Wire request for `POST /remote/v1/auth/pair` (§4.2, C-11: camelCase). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -187,7 +189,9 @@ pub struct HyperRemoteHostClient { dispatch_timeout: Duration, } -fn install_rustls_crypto_provider() { +/// Installs the aws-lc-rs rustls provider exactly once. `pub(crate)` because the +/// outbound WS client (`remote_ws_client`) must set the same default before dialing. +pub(crate) fn install_rustls_crypto_provider() { static INSTALL_PROVIDER: std::sync::Once = std::sync::Once::new(); INSTALL_PROVIDER.call_once(|| { let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); diff --git a/src-tauri/src/infrastructure/remote_ws_client.rs b/src-tauri/src/infrastructure/remote_ws_client.rs new file mode 100644 index 0000000000..63d1e35255 --- /dev/null +++ b/src-tauri/src/infrastructure/remote_ws_client.rs @@ -0,0 +1,354 @@ +// RemoteWsClient — the client→host WebSocket transport seam (PR 2.3, §3.2). +// +// Deliberately thin: it dials, decodes `ServerFrame`s, and encodes `ClientFrame`s. +// It carries NO retry logic (A-5: the TS supervisor is the sole retry owner) and NO +// protocol state — hello handling, heartbeat acking, and session bookkeeping live in +// `application::remote_event_relay`, where they are testable against the mock below. +// +// Trait-based (like `RemoteHostClient`) so the relay runs against a scripted socket +// in unit tests instead of a real TCP upgrade. + +use std::collections::VecDeque; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex as StdMutex}; + +use async_trait::async_trait; +use futures::{SinkExt, StreamExt}; +use ralphx_remote_protocol::{ClientFrame, ServerFrame}; +use tokio::net::TcpStream; +use tokio::sync::mpsc; +use tokio::time::Duration; +use tokio_tungstenite::tungstenite::Message; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream}; + +use crate::infrastructure::remote_host_client::install_rustls_crypto_provider; + +#[cfg(test)] +#[path = "remote_ws_client_tests.rs"] +mod tests; + +/// The host's event stream endpoint (mirrors `remote_server::ws::WS_EVENTS_PATH`). +pub const REMOTE_EVENTS_PATH: &str = "/remote/v1/events"; + +/// Typed failures of the outbound WS transport. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RemoteWsError { + /// The dial provably did nothing (refused connection, bad URL, timeout before + /// the upgrade completed). + Unreachable(String), + /// The handshake reached the host and the host answered with an HTTP refusal. + /// 401/403 are the supervisor's `blocked` entries — they must stay + /// distinguishable from a dead host. + Rejected { status: u16, message: String }, + /// The socket spoke, but not the protocol: an undecodable text frame, a binary + /// frame, or a frame the current state cannot accept. + Protocol(String), + /// The socket ended or refused a send. + Closed(String), +} + +impl RemoteWsError { + /// Compact human-readable form for `remote:stream_closed` payloads and logs. + pub fn short_reason(&self) -> String { + match self { + Self::Unreachable(message) => format!("unreachable: {message}"), + Self::Rejected { status, message } => format!("rejected ({status}): {message}"), + Self::Protocol(message) => format!("protocol error: {message}"), + Self::Closed(message) => format!("closed: {message}"), + } + } +} + +/// Builds the `ws(s)://…/remote/v1/events?ticket=…` dial URL from a paired +/// environment's base URL and a freshly minted single-use ticket. +/// +/// The ticket is shape-validated BEFORE interpolation: it becomes part of a URL, so +/// an unvalidated value is a request-forgery seam (`?`/`&`/`#` would splice query +/// keys or truncate the path). Host-minted tickets are URL-safe base64, so the +/// accepted alphabet is exactly `[A-Za-z0-9_-]`. +pub fn ws_events_url(base_url: &str, ticket: &str) -> Result { + if ticket.is_empty() + || !ticket + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') + { + return Err(RemoteWsError::Unreachable( + "ws ticket has an invalid shape and was not sent".to_string(), + )); + } + let trimmed = base_url.trim().trim_end_matches('/'); + let ws_base = if let Some(rest) = trimmed.strip_prefix("https://") { + format!("wss://{rest}") + } else if let Some(rest) = trimmed.strip_prefix("http://") { + format!("ws://{rest}") + } else { + return Err(RemoteWsError::Unreachable(format!( + "base URL has no WebSocket form (not http/https): {base_url}" + ))); + }; + if ws_base.ends_with("://") { + return Err(RemoteWsError::Unreachable(format!( + "base URL has no host: {base_url}" + ))); + } + Ok(format!("{ws_base}{REMOTE_EVENTS_PATH}?ticket={ticket}")) +} + +/// Decodes one wire text frame. A decode failure is a typed `Protocol` error, never +/// a silent skip — an undecodable frame means the client and host disagree about the +/// protocol, and skipping it would splice an invisible hole into the stream. +fn decode_server_frame(text: &str) -> Result { + serde_json::from_str(text) + .map_err(|error| RemoteWsError::Protocol(format!("unrecognized server frame: {error}"))) +} + +/// Dials the host's event stream endpoint. +#[async_trait] +pub trait RemoteWsClient: Send + Sync { + async fn connect(&self, ws_url: &str) -> Result, RemoteWsError>; +} + +/// One live socket. Implementations MUST be cancel-safe in [`Self::recv`]: the relay +/// loop `select!`s on it and drops the future whenever another branch wins. +#[async_trait] +pub trait RemoteWsConnection: Send { + /// `None` = the socket ended (peer close or EOF). `Some(Err(_))` = an unusable + /// frame or a transport error; the relay tears the session down. + async fn recv(&mut self) -> Option>; + async fn send(&mut self, frame: ClientFrame) -> Result<(), RemoteWsError>; + async fn close(&mut self); +} + +// ============================================================================ +// Production implementation using tokio-tungstenite +// ============================================================================ + +pub struct TungsteniteRemoteWsClient { + /// Budget for the whole dial (TCP + TLS + upgrade). Matches the HTTP client's + /// request budget: a host that cannot complete an upgrade in this window is not + /// serving a stream this session should wait on. + connect_timeout: Duration, +} + +impl TungsteniteRemoteWsClient { + pub fn new() -> Self { + Self { + connect_timeout: Duration::from_secs(15), + } + } +} + +impl Default for TungsteniteRemoteWsClient { + fn default() -> Self { + Self::new() + } +} + +/// Maps a tungstenite handshake failure into the transport taxonomy. Only an HTTP +/// answer becomes `Rejected` — everything else provably never reached an admitting +/// host and stays `Unreachable`. +fn handshake_error(error: tokio_tungstenite::tungstenite::Error) -> RemoteWsError { + match error { + tokio_tungstenite::tungstenite::Error::Http(response) => { + let status = response.status().as_u16(); + let message: String = response + .body() + .as_deref() + .map(|body| String::from_utf8_lossy(body).chars().take(300).collect()) + .unwrap_or_default(); + RemoteWsError::Rejected { status, message } + } + other => RemoteWsError::Unreachable(other.to_string()), + } +} + +#[async_trait] +impl RemoteWsClient for TungsteniteRemoteWsClient { + async fn connect(&self, ws_url: &str) -> Result, RemoteWsError> { + // Same provider the HTTP client installs; whichever seam dials first wins the + // `Once`, and the rustls default is set before any TLS handshake starts. + install_rustls_crypto_provider(); + let (stream, _response) = tokio::time::timeout( + self.connect_timeout, + tokio_tungstenite::connect_async(ws_url), + ) + .await + .map_err(|_| { + RemoteWsError::Unreachable(format!( + "no WebSocket upgrade after {}s", + self.connect_timeout.as_secs() + )) + })? + .map_err(handshake_error)?; + Ok(Box::new(TungsteniteRemoteWsConnection { stream })) + } +} + +struct TungsteniteRemoteWsConnection { + stream: WebSocketStream>, +} + +#[async_trait] +impl RemoteWsConnection for TungsteniteRemoteWsConnection { + async fn recv(&mut self) -> Option> { + loop { + // `StreamExt::next` is cancel-safe, so dropping this future in `select!` + // loses nothing. + match self.stream.next().await? { + Ok(Message::Text(text)) => return Some(decode_server_frame(&text)), + // Ping/Pong are answered by tungstenite itself; binary frames are not + // part of the v1 protocol (§3.2). + Ok(Message::Binary(_)) => { + return Some(Err(RemoteWsError::Protocol( + "binary frames are not part of the remote protocol".to_string(), + ))) + } + Ok(Message::Close(_)) => return None, + Ok(_) => continue, + Err(error) => return Some(Err(RemoteWsError::Closed(error.to_string()))), + } + } + } + + async fn send(&mut self, frame: ClientFrame) -> Result<(), RemoteWsError> { + let text = serde_json::to_string(&frame).map_err(|error| { + RemoteWsError::Protocol(format!("client frame is not serializable: {error}")) + })?; + self.stream + .send(Message::Text(text)) + .await + .map_err(|error| RemoteWsError::Closed(error.to_string())) + } + + async fn close(&mut self) { + let _ = self.stream.close(None).await; + } +} + +// ============================================================================ +// Test mock — the scripted socket the relay/service tests run against +// ============================================================================ + +/// Scripted WS endpoint (mirrors `MockRemoteHostClient`): each `connect` pops the +/// next scripted connection or error; every dialed URL is recorded for assertion. +pub struct MockRemoteWsClient { + scripts: StdMutex>>, + dialed: StdMutex>, +} + +impl MockRemoteWsClient { + pub fn new() -> Self { + Self { + scripts: StdMutex::new(VecDeque::new()), + dialed: StdMutex::new(Vec::new()), + } + } + + /// Scripts the next dial to succeed with `connection`. + pub fn script_connection(&self, connection: MockRemoteWsConnection) { + self.scripts + .lock() + .expect("mock ws scripts poisoned") + .push_back(Ok(connection)); + } + + /// Scripts the next dial to fail. + pub fn script_error(&self, error: RemoteWsError) { + self.scripts + .lock() + .expect("mock ws scripts poisoned") + .push_back(Err(error)); + } + + pub fn dialed_urls(&self) -> Vec { + self.dialed.lock().expect("mock ws dial log poisoned").clone() + } +} + +impl Default for MockRemoteWsClient { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl RemoteWsClient for MockRemoteWsClient { + async fn connect(&self, ws_url: &str) -> Result, RemoteWsError> { + self.dialed + .lock() + .expect("mock ws dial log poisoned") + .push(ws_url.to_string()); + match self + .scripts + .lock() + .expect("mock ws scripts poisoned") + .pop_front() + { + Some(Ok(connection)) => Ok(Box::new(connection)), + Some(Err(error)) => Err(error), + None => Err(RemoteWsError::Unreachable( + "no scripted mock connection".to_string(), + )), + } + } +} + +/// One scripted socket. Inbound frames are injected through the paired +/// [`MockRemoteWsHandle`]; outbound frames and the close call are observable there. +pub struct MockRemoteWsConnection { + inbound: mpsc::UnboundedReceiver>, + outbound: mpsc::UnboundedSender, + closed: Arc, +} + +/// The test's end of a [`MockRemoteWsConnection`]. Dropping `inbound` ends the +/// scripted socket (recv → `None`), which is how tests simulate a peer close. +pub struct MockRemoteWsHandle { + pub inbound: mpsc::UnboundedSender>, + pub outbound: mpsc::UnboundedReceiver, + closed: Arc, +} + +impl MockRemoteWsHandle { + pub fn was_closed(&self) -> bool { + self.closed.load(Ordering::SeqCst) + } +} + +impl MockRemoteWsConnection { + pub fn scripted() -> (Self, MockRemoteWsHandle) { + let (inbound_tx, inbound_rx) = mpsc::unbounded_channel(); + let (outbound_tx, outbound_rx) = mpsc::unbounded_channel(); + let closed = Arc::new(AtomicBool::new(false)); + ( + Self { + inbound: inbound_rx, + outbound: outbound_tx, + closed: Arc::clone(&closed), + }, + MockRemoteWsHandle { + inbound: inbound_tx, + outbound: outbound_rx, + closed, + }, + ) + } +} + +#[async_trait] +impl RemoteWsConnection for MockRemoteWsConnection { + async fn recv(&mut self) -> Option> { + self.inbound.recv().await + } + + async fn send(&mut self, frame: ClientFrame) -> Result<(), RemoteWsError> { + self.outbound + .send(frame) + .map_err(|_| RemoteWsError::Closed("mock outbound receiver dropped".to_string())) + } + + async fn close(&mut self) { + self.closed.store(true, Ordering::SeqCst); + self.inbound.close(); + } +} diff --git a/src-tauri/src/infrastructure/remote_ws_client_tests.rs b/src-tauri/src/infrastructure/remote_ws_client_tests.rs new file mode 100644 index 0000000000..b9387fda80 --- /dev/null +++ b/src-tauri/src/infrastructure/remote_ws_client_tests.rs @@ -0,0 +1,119 @@ +// remote_ws_client tests: URL construction (scheme mapping + ticket shape guard) and +// pure frame decode/encode against the documented wire shapes. No real sockets. + +use super::*; + +// ============================================================================ +// ws_events_url — scheme mapping +// ============================================================================ + +#[test] +fn ws_events_url_maps_http_to_ws_and_appends_the_ticket() { + assert_eq!( + ws_events_url("http://100.101.102.103:3849", "abc_DEF-123").expect("valid inputs"), + "ws://100.101.102.103:3849/remote/v1/events?ticket=abc_DEF-123" + ); +} + +#[test] +fn ws_events_url_maps_https_to_wss_and_tolerates_a_trailing_slash() { + assert_eq!( + ws_events_url("https://mac-studio.tailnet.ts.net/", "t0").expect("valid inputs"), + "wss://mac-studio.tailnet.ts.net/remote/v1/events?ticket=t0" + ); +} + +#[test] +fn ws_events_url_rejects_non_http_schemes() { + for base in ["file:///etc/passwd", "ftp://host", "mac-studio.local", ""] { + let error = ws_events_url(base, "ticket").expect_err("non-http base must be rejected"); + assert!( + matches!(error, RemoteWsError::Unreachable(_)), + "base {base:?} produced {error:?}" + ); + } + assert!( + matches!( + ws_events_url("https://", "ticket"), + Err(RemoteWsError::Unreachable(_)) + ), + "a scheme without a host is not dialable" + ); +} + +/// The ticket lands in a URL query string, so anything outside the URL-safe base64 +/// alphabet is a request-forgery seam and must never be interpolated. +#[test] +fn ws_events_url_rejects_malformed_tickets() { + for ticket in [ + "", + "a b", + "a?b", + "a&b=1", + "a#frag", + "a/b", + "a%2Fb", + "über", + "a\nb", + ] { + let error = ws_events_url("https://host.example", ticket) + .expect_err("malformed ticket must be rejected"); + assert!( + matches!(error, RemoteWsError::Unreachable(_)), + "ticket {ticket:?} produced {error:?}" + ); + } +} + +// ============================================================================ +// Frame decode/encode — the documented wire shapes (C-11: real serialization) +// ============================================================================ + +#[test] +fn server_frames_decode_from_the_camel_case_wire_shape() { + let hello = decode_server_frame( + r#"{"type":"hello","protocolVersion":1,"environmentId":"env-1","streamEpoch":"epoch-1","serverVersion":"0.81.0","maxSeq":42,"heartbeatSecs":20}"#, + ) + .expect("hello should decode"); + assert_eq!( + hello, + ServerFrame::Hello { + protocol_version: 1, + environment_id: "env-1".to_string(), + stream_epoch: "epoch-1".to_string(), + server_version: "0.81.0".to_string(), + max_seq: 42, + heartbeat_secs: 20, + } + ); + + let heartbeat = + decode_server_frame(r#"{"type":"heartbeat","t":1753700000}"#).expect("heartbeat decodes"); + assert_eq!(heartbeat, ServerFrame::Heartbeat { t: 1_753_700_000 }); +} + +#[test] +fn an_undecodable_text_frame_is_a_protocol_error_not_a_skip() { + let error = decode_server_frame(r#"{"type":"no_such_frame"}"#) + .expect_err("unknown frame type must error"); + assert!(matches!(error, RemoteWsError::Protocol(_))); + + let error = decode_server_frame("not json at all").expect_err("garbage must error"); + assert!(matches!(error, RemoteWsError::Protocol(_))); +} + +#[test] +fn client_frames_serialize_to_the_camel_case_wire_shape() { + assert_eq!( + serde_json::to_value(ClientFrame::Subscribe { + after_seq: 10, + stream_epoch: "epoch-1".to_string(), + }) + .expect("subscribe serializes"), + serde_json::json!({"type": "subscribe", "afterSeq": 10, "streamEpoch": "epoch-1"}) + ); + assert_eq!( + serde_json::to_value(ClientFrame::HeartbeatAck { t: 7 }).expect("ack serializes"), + serde_json::json!({"type": "heartbeatAck", "t": 7}) + ); +} From 5e7b7e1d8c725bb19256908b48f3aeadf13d44aa Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:48:14 +0300 Subject: [PATCH 128/416] test: scan for the get_or_create call form, not its comment mention --- src-tauri/src/commands/remote_host_commands_tests.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands/remote_host_commands_tests.rs b/src-tauri/src/commands/remote_host_commands_tests.rs index a354be7ccb..535659d45b 100644 --- a/src-tauri/src/commands/remote_host_commands_tests.rs +++ b/src-tauri/src/commands/remote_host_commands_tests.rs @@ -115,8 +115,10 @@ fn the_status_reads_never_configure_host_mode() { .nth(1) .unwrap_or_else(|| panic!("{command} should exist")); let end = body.find("\n}\n").expect("the command body should end"); + // Match the call form `.get_or_create()` so the guard catches real reads while ignoring + // the explanatory comment inside `list_remote_advertised_endpoints` that names the method. assert!( - !body[..end].contains("get_or_create()"), + !body[..end].contains(".get_or_create()"), "{command} must read with get(), not mint the settings row" ); } From a7378d0db2fc15bafd0d9f2cc5385ab04213a1a8 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:26:47 +0300 Subject: [PATCH 129/416] docs(remote): regenerate the remote-commands manifest after the hardening merge --- docs/generated/remote-commands.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/generated/remote-commands.json b/docs/generated/remote-commands.json index 0da377b2c3..798eb4cb6b 100644 --- a/docs/generated/remote-commands.json +++ b/docs/generated/remote-commands.json @@ -181,7 +181,7 @@ "authorityBearing": true, "enclosingFunction": "application/app_setup.rs:::::launch_startup_attempt", "file": "application/app_setup.rs", - "id": "application/app_setup.rs::application/app_setup.rs:::::launch_startup_attempt@a33c293f5037c924", + "id": "application/app_setup.rs::application/app_setup.rs:::::launch_startup_attempt@b33103c07384b8f8", "kind": "async_runtime::spawn" }, { From 293b6069109228b04297f4633e690dedc8a78e56 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:33:33 +0300 Subject: [PATCH 130/416] feat(remote): environment-keyed store isolation with audited zustand inventory Adds the checked-in, test-enforced classification of all 22 live zustand create() sites (store-isolation-inventory), an env-scoped persist storage adapter that splits persisted slices into a shared key for global prefs and {store}:{environmentId} keys for env-owned fields (local keeps the legacy byte-compatible layout), and a single environment-switch funnel that resets env-owned in-memory state and rehydrates persisted slices under write suppression. P-13 A-B-A round-trip tests prove selection/data survival and bidirectional absence of host identifiers; remote reads never fall back to the shared key's env-owned values. --- .../components/agents/agentArtifactUiStore.ts | 4 + .../components/agents/agentTerminalStore.ts | 11 ++ frontend/src/hooks/useAgentHookEvents.ts | 4 + .../src/hooks/useSupervisorAlerts.store.ts | 4 + .../src/lib/remote/env-scoped-storage.test.ts | 83 ++++++++++ frontend/src/lib/remote/env-scoped-storage.ts | 95 +++++++++++ .../lib/remote/env-state-isolation.test.ts | 51 ++++++ .../src/lib/remote/env-state-isolation.ts | 24 +++ .../remote/store-isolation-inventory.test.ts | 63 ++++++++ .../lib/remote/store-isolation-inventory.ts | 76 +++++++++ frontend/src/stores/activityStore.ts | 4 + frontend/src/stores/agentSessionStore.ts | 11 ++ frontend/src/stores/artifactStore.ts | 4 + frontend/src/stores/chatStore.ts | 4 + .../stores/environmentStore.envswitch.test.ts | 149 ++++++++++++++++++ frontend/src/stores/environmentStore.ts | 5 +- frontend/src/stores/ideationStore.ts | 4 + .../src/stores/integrationDashboardStore.ts | 4 + frontend/src/stores/methodologyStore.ts | 4 + frontend/src/stores/planStore.ts | 4 + frontend/src/stores/projectStore.ts | 12 ++ frontend/src/stores/proposalStore.ts | 4 + frontend/src/stores/qaStore.ts | 4 + frontend/src/stores/reviewStore.ts | 4 + frontend/src/stores/taskStore.ts | 4 + frontend/src/stores/ticketingStore.ts | 11 ++ frontend/src/stores/uiStore.ts | 34 ++++ frontend/src/stores/workflowStore.ts | 4 + 28 files changed, 684 insertions(+), 1 deletion(-) create mode 100644 frontend/src/lib/remote/env-scoped-storage.test.ts create mode 100644 frontend/src/lib/remote/env-scoped-storage.ts create mode 100644 frontend/src/lib/remote/env-state-isolation.test.ts create mode 100644 frontend/src/lib/remote/env-state-isolation.ts create mode 100644 frontend/src/lib/remote/store-isolation-inventory.test.ts create mode 100644 frontend/src/lib/remote/store-isolation-inventory.ts create mode 100644 frontend/src/stores/environmentStore.envswitch.test.ts diff --git a/frontend/src/components/agents/agentArtifactUiStore.ts b/frontend/src/components/agents/agentArtifactUiStore.ts index abfc385a92..656f248eb0 100644 --- a/frontend/src/components/agents/agentArtifactUiStore.ts +++ b/frontend/src/components/agents/agentArtifactUiStore.ts @@ -1,4 +1,6 @@ import { create } from "zustand"; + +import { registerEnvIsolatedStore } from "@/lib/remote/env-state-isolation"; import type { AgentArtifactState, AgentTaskArtifactMode } from "@/stores/agentSessionStore"; import type { @@ -72,6 +74,8 @@ export const useAgentArtifactUiStore = create< })), })); +registerEnvIsolatedStore({ name: "useAgentArtifactUiStore", reset: () => useAgentArtifactUiStore.setState(useAgentArtifactUiStore.getInitialState(), true) }); + export function selectOptimisticArtifactState(conversationId: string | null) { return (state: AgentArtifactUiState): AgentArtifactState | null => conversationId ? state.artifactByConversationId[conversationId] ?? null : null; diff --git a/frontend/src/components/agents/agentTerminalStore.ts b/frontend/src/components/agents/agentTerminalStore.ts index 2711b86889..2f5a32fb6f 100644 --- a/frontend/src/components/agents/agentTerminalStore.ts +++ b/frontend/src/components/agents/agentTerminalStore.ts @@ -2,6 +2,8 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import { immer } from "zustand/middleware/immer"; +import { createEnvScopedStorage } from "@/lib/remote/env-scoped-storage"; +import { registerEnvIsolatedStore } from "@/lib/remote/env-state-isolation"; export type AgentTerminalPlacement = "auto" | "chat" | "panel"; export type AgentTerminalDock = "chat" | "panel"; export type AgentTerminalCachedStatus = "closed" | "running" | "exited" | "error"; @@ -118,6 +120,7 @@ export const useAgentTerminalStore = create< })), { name: "ralphx-agent-terminal-ui", + storage: createEnvScopedStorage("ralphx-agent-terminal-ui"), partialize: (state) => ({ openByConversationId: state.openByConversationId, heightByConversationId: state.heightByConversationId, @@ -128,3 +131,11 @@ export const useAgentTerminalStore = create< } ) ); + +registerEnvIsolatedStore({ + name: "useAgentTerminalStore", + reset: () => useAgentTerminalStore.setState(useAgentTerminalStore.getInitialState(), true), + rehydrate: () => { + void useAgentTerminalStore.persist.rehydrate(); + }, +}); diff --git a/frontend/src/hooks/useAgentHookEvents.ts b/frontend/src/hooks/useAgentHookEvents.ts index 38fbde061b..3cd42dc60b 100644 --- a/frontend/src/hooks/useAgentHookEvents.ts +++ b/frontend/src/hooks/useAgentHookEvents.ts @@ -22,6 +22,8 @@ import type { } from "@/types/hook-event"; import { create } from "zustand"; +import { registerEnvIsolatedStore } from "@/lib/remote/env-state-isolation"; + // ============================================================================ // Store // ============================================================================ @@ -71,6 +73,8 @@ export const useHookEventsStore = create((set) => ({ set({ activeHooks: new Map(), events: [] }), })); +registerEnvIsolatedStore({ name: "useHookEventsStore", reset: () => useHookEventsStore.setState(useHookEventsStore.getInitialState(), true) }); + // ============================================================================ // Transform // ============================================================================ diff --git a/frontend/src/hooks/useSupervisorAlerts.store.ts b/frontend/src/hooks/useSupervisorAlerts.store.ts index a7ee3b2469..95f0e1b553 100644 --- a/frontend/src/hooks/useSupervisorAlerts.store.ts +++ b/frontend/src/hooks/useSupervisorAlerts.store.ts @@ -7,6 +7,8 @@ import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; + +import { registerEnvIsolatedStore } from "@/lib/remote/env-state-isolation"; import type { SupervisorAlert, SupervisorConfig } from "@/types/supervisor"; // ============================================================================ @@ -133,3 +135,5 @@ export const useSupervisorStore = create()( }), })) ); + +registerEnvIsolatedStore({ name: "useSupervisorStore", reset: () => useSupervisorStore.setState(useSupervisorStore.getInitialState(), true) }); diff --git a/frontend/src/lib/remote/env-scoped-storage.test.ts b/frontend/src/lib/remote/env-scoped-storage.test.ts new file mode 100644 index 0000000000..420bafd958 --- /dev/null +++ b/frontend/src/lib/remote/env-scoped-storage.test.ts @@ -0,0 +1,83 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { resetTransportEnvironmentId, setTransportEnvironmentId } from "./active-environment"; +import { createEnvScopedStorage, runSuppressed } from "./env-scoped-storage"; + +const name = "ralphx-project-store"; +const storage = createEnvScopedStorage<{ activeProjectId: string | null }>(name); + +beforeEach(() => { + localStorage.clear(); + resetTransportEnvironmentId(); +}); + +describe("env-scoped persistence", () => { + it("keeps local byte-compatible under only the legacy key", () => { + const value = { state: { activeProjectId: "proj-local" }, version: 3 }; + storage.setItem(name, value); + expect(storage.getItem(name)).toEqual(value); + expect(localStorage.getItem(`${name}:local`)).toBeNull(); + }); + + it("never falls back to local env fields for a remote read", () => { + localStorage.setItem( + name, + JSON.stringify({ state: { activeProjectId: "proj-local" }, version: 2 }), + ); + setTransportEnvironmentId("env-b"); + expect(storage.getItem(name)?.state).toEqual({}); + localStorage.setItem( + `${name}:env-b`, + JSON.stringify({ state: { activeProjectId: "proj-b" }, version: 2 }), + ); + expect(storage.getItem(name)?.state).toEqual({ activeProjectId: "proj-b" }); + }); + + it("splits remote writes without clobbering local env fields or restamping their version", () => { + localStorage.setItem( + name, + JSON.stringify({ state: { activeProjectId: "proj-local" }, version: 1 }), + ); + setTransportEnvironmentId("env-b"); + storage.setItem(name, { state: { activeProjectId: "proj-b" }, version: 4 }); + expect(JSON.parse(localStorage.getItem(name) ?? "null")).toEqual({ + state: { activeProjectId: "proj-local" }, + version: 1, + }); + expect(JSON.parse(localStorage.getItem(`${name}:env-b`) ?? "null")).toEqual({ + state: { activeProjectId: "proj-b" }, + version: 4, + }); + + setTransportEnvironmentId("local"); + storage.setItem(name, { state: { activeProjectId: "proj-local-current" }, version: 5 }); + expect(JSON.parse(localStorage.getItem(name) ?? "null")).toEqual({ + state: { activeProjectId: "proj-local-current" }, + version: 5, + }); + }); + + it("suppresses nested writes and releases after exceptions", () => { + expect(() => + runSuppressed(() => + runSuppressed(() => { + storage.setItem(name, { state: { activeProjectId: "blocked" }, version: 1 }); + throw new Error("boom"); + }), + ), + ).toThrow("boom"); + expect(localStorage.getItem(name)).toBeNull(); + storage.setItem(name, { state: { activeProjectId: "allowed" }, version: 1 }); + expect(localStorage.getItem(name)).toContain("allowed"); + }); + + it("uses the oldest slice version on merged remote reads", () => { + localStorage.setItem(name, JSON.stringify({ state: {}, version: 7 })); + localStorage.setItem( + `${name}:env-b`, + JSON.stringify({ state: { activeProjectId: "b" }, version: 3 }), + ); + setTransportEnvironmentId("env-b"); + expect(storage.getItem(name)?.version).toBe(3); + }); +}); diff --git a/frontend/src/lib/remote/env-scoped-storage.ts b/frontend/src/lib/remote/env-scoped-storage.ts new file mode 100644 index 0000000000..50623885ed --- /dev/null +++ b/frontend/src/lib/remote/env-scoped-storage.ts @@ -0,0 +1,95 @@ +import type { PersistStorage, StorageValue } from "zustand/middleware"; + +import { getTransportEnvironmentId, LOCAL_ENVIRONMENT_ID } from "./active-environment"; +import { STORE_ISOLATION_INVENTORY } from "./store-isolation-inventory"; + +const persistedSpecs = new Map( + STORE_ISOLATION_INVENTORY.flatMap((entry) => + entry.persisted ? [[entry.persisted.storageName, entry.persisted] as const] : [], + ), +); + +let suppressionDepth = 0; + +export function runSuppressed(operation: () => T): T { + suppressionDepth += 1; + try { + return operation(); + } finally { + suppressionDepth -= 1; + } +} + +function parse(value: string | null): StorageValue | null { + if (value === null) return null; + return JSON.parse(value) as StorageValue; +} + +function pick(source: unknown, fields: readonly string[]): Record { + if (!source || typeof source !== "object") return {}; + const record = source as Record; + return Object.fromEntries(fields.filter((field) => field in record).map((field) => [field, record[field]])); +} + +export function createEnvScopedStorage(storageName: string): PersistStorage { + const spec = persistedSpecs.get(storageName); + if (!spec) throw new Error(`Unknown env-scoped persisted store: ${storageName}`); + + return { + getItem: () => { + const storage = globalThis.localStorage; + if (!storage) return null; + const environmentId = getTransportEnvironmentId(); + const shared = parse(storage.getItem(storageName)); + if (environmentId === LOCAL_ENVIRONMENT_ID) return shared; + const scoped = parse(storage.getItem(`${storageName}:${environmentId}`)); + if (!shared && !scoped) return null; + + const versions = [shared?.version, scoped?.version].filter( + (version): version is number => typeof version === "number", + ); + // The oldest component version wins so persist migration runs if either slice is stale. + const version = versions.length > 0 ? Math.min(...versions) : undefined; + return { + state: { + ...pick(shared?.state, spec.globalFields), + ...pick(scoped?.state, spec.envFields), + } as S, + ...(version !== undefined ? { version } : {}), + }; + }, + setItem: (_name, value) => { + const storage = globalThis.localStorage; + if (!storage || suppressionDepth > 0) return; + const environmentId = getTransportEnvironmentId(); + if (environmentId === LOCAL_ENVIRONMENT_ID) { + storage.setItem(storageName, JSON.stringify(value)); + return; + } + const shared = parse(storage.getItem(storageName)); + storage.setItem( + storageName, + JSON.stringify({ + state: { + ...pick(shared?.state, spec.envFields), + ...pick(value.state, spec.globalFields), + }, + version: shared?.version ?? value.version, + }), + ); + storage.setItem( + `${storageName}:${environmentId}`, + JSON.stringify({ state: pick(value.state, spec.envFields), version: value.version }), + ); + }, + removeItem: () => { + const storage = globalThis.localStorage; + if (!storage || suppressionDepth > 0) return; + const environmentId = getTransportEnvironmentId(); + // Remote removal clears only that environment; shared local/global data is conservative. + storage.removeItem( + environmentId === LOCAL_ENVIRONMENT_ID ? storageName : `${storageName}:${environmentId}`, + ); + }, + }; +} diff --git a/frontend/src/lib/remote/env-state-isolation.test.ts b/frontend/src/lib/remote/env-state-isolation.test.ts new file mode 100644 index 0000000000..9cb42b40d0 --- /dev/null +++ b/frontend/src/lib/remote/env-state-isolation.test.ts @@ -0,0 +1,51 @@ +import { create } from "zustand"; +import { persist } from "zustand/middleware"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { createEnvScopedStorage } from "./env-scoped-storage"; +import { + onEnvironmentSwitched, + registerEnvIsolatedStore, + resetEnvStateIsolationForTests, +} from "./env-state-isolation"; + +beforeEach(() => { + localStorage.clear(); + resetEnvStateIsolationForTests(); +}); + +describe("environment state isolation funnel", () => { + it("resets before rehydrate and re-registration replaces", () => { + const order: string[] = []; + registerEnvIsolatedStore({ name: "store", reset: () => order.push("old") }); + registerEnvIsolatedStore({ name: "store", reset: () => order.push("reset"), rehydrate: () => order.push("rehydrate") }); + onEnvironmentSwitched(); + expect(order).toEqual(["reset", "rehydrate"]); + }); + + it("supports full and fields-only resets while global stores stay untouched", () => { + const full = create<{ count: number; increment: () => void }>((set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })) })); + const mixed = create<{ env: string; global: string }>(() => ({ env: "initial", global: "global" })); + const untouched = vi.fn(); + full.setState({ count: 9 }); + mixed.setState({ env: "changed", global: "kept" }); + registerEnvIsolatedStore({ name: "full", reset: () => full.setState(full.getInitialState(), true) }); + registerEnvIsolatedStore({ name: "mixed", reset: () => mixed.setState({ env: mixed.getInitialState().env }) }); + onEnvironmentSwitched(); + expect(full.getState().count).toBe(0); + full.getState().increment(); + expect(full.getState().count).toBe(1); + expect(mixed.getState()).toEqual({ env: "initial", global: "kept" }); + expect(untouched).not.toHaveBeenCalled(); + }); + + it("rehydrates synchronous localStorage before returning", () => { + const store = create<{ activeProjectId: string | null }>()( + persist(() => ({ activeProjectId: null }), { name: "ralphx-project-store", storage: createEnvScopedStorage("ralphx-project-store") }), + ); + localStorage.setItem("ralphx-project-store", JSON.stringify({ state: { activeProjectId: "restored" }, version: 0 })); + registerEnvIsolatedStore({ name: "persisted", reset: () => store.setState(store.getInitialState(), true), rehydrate: () => { void store.persist.rehydrate(); } }); + onEnvironmentSwitched(); + expect(store.getState().activeProjectId).toBe("restored"); + }); +}); diff --git a/frontend/src/lib/remote/env-state-isolation.ts b/frontend/src/lib/remote/env-state-isolation.ts new file mode 100644 index 0000000000..d929f062e5 --- /dev/null +++ b/frontend/src/lib/remote/env-state-isolation.ts @@ -0,0 +1,24 @@ +import { runSuppressed } from "./env-scoped-storage"; + +export interface EnvIsolatedStoreRegistration { + readonly name: string; + readonly reset?: () => void; + readonly rehydrate?: () => void; +} + +const registrations = new Map(); + +export function registerEnvIsolatedStore(registration: EnvIsolatedStoreRegistration): void { + registrations.set(registration.name, registration); +} + +export function onEnvironmentSwitched(): void { + runSuppressed(() => { + for (const registration of registrations.values()) registration.reset?.(); + for (const registration of registrations.values()) registration.rehydrate?.(); + }); +} + +export function resetEnvStateIsolationForTests(): void { + registrations.clear(); +} diff --git a/frontend/src/lib/remote/store-isolation-inventory.test.ts b/frontend/src/lib/remote/store-isolation-inventory.test.ts new file mode 100644 index 0000000000..6a13926bd7 --- /dev/null +++ b/frontend/src/lib/remote/store-isolation-inventory.test.ts @@ -0,0 +1,63 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { useAgentTerminalStore } from "@/components/agents/agentTerminalStore"; +import { useAgentSessionStore } from "@/stores/agentSessionStore"; +import { useProjectStore } from "@/stores/projectStore"; +import { useTicketingStore } from "@/stores/ticketingStore"; +import { STORE_ISOLATION_INVENTORY } from "./store-isolation-inventory"; + +function findStores(root: string): string[] { + const found: string[] = []; + const walk = (directory: string) => { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + if (entry.name === "node_modules" || entry.name === "__tests__") continue; + const path = join(directory, entry.name); + if (entry.isDirectory()) walk(path); + else if (!/\.test\./.test(entry.name)) { + const source = readFileSync(path, "utf8"); + if (/from ["']zustand["']/.test(source) && /\bcreate\s* { + it("exactly enumerates every Zustand create site", () => { + expect(STORE_ISOLATION_INVENTORY.map((entry) => entry.modulePath).sort()).toEqual( + findStores(join(process.cwd(), "src")), + ); + }); + + it("matches every persisted partialize contract with disjoint fields", () => { + const stores = new Map([ + ["ralphx-project-store", useProjectStore], + ["ralphx-agent-session-store", useAgentSessionStore as typeof useProjectStore], + ["ralphx-ticketing-store", useTicketingStore as typeof useProjectStore], + ["ralphx-agent-terminal-ui", useAgentTerminalStore as typeof useProjectStore], + ]); + for (const entry of STORE_ISOLATION_INVENTORY) { + if (!entry.persisted) continue; + const store = stores.get(entry.persisted.storageName); + expect(store, entry.storeName).toBeDefined(); + const partialize = store!.persist.getOptions().partialize; + expect(partialize).toBeTypeOf("function"); + const actual = Object.keys(partialize!(store!.getState())).sort(); + const expected = [...entry.persisted.envFields, ...entry.persisted.globalFields].sort(); + expect(actual, entry.storeName).toEqual(expected); + expect(entry.persisted.envFields.filter((field) => entry.persisted!.globalFields.includes(field))).toEqual([]); + } + }); + + it("has valid classifications, rationales, and one infrastructure store", () => { + const allowed = ["env-owned", "global", "mixed", "infrastructure"]; + expect(STORE_ISOLATION_INVENTORY.every((entry) => entry.rationale.trim().length > 0)).toBe(true); + expect(STORE_ISOLATION_INVENTORY.every((entry) => allowed.includes(entry.classification))).toBe(true); + expect(STORE_ISOLATION_INVENTORY.filter((entry) => entry.classification === "infrastructure")).toHaveLength(1); + }); +}); diff --git a/frontend/src/lib/remote/store-isolation-inventory.ts b/frontend/src/lib/remote/store-isolation-inventory.ts new file mode 100644 index 0000000000..b7e1b296c9 --- /dev/null +++ b/frontend/src/lib/remote/store-isolation-inventory.ts @@ -0,0 +1,76 @@ +export type StoreClassification = "env-owned" | "global" | "mixed" | "infrastructure"; + +export type ResetPolicy = + | { readonly mode: "full" } + | { readonly mode: "fields"; readonly fields: readonly string[] } + | { readonly mode: "none" }; + +export interface PersistedSliceSpec { + readonly storageName: string; + readonly envFields: readonly string[]; + readonly globalFields: readonly string[]; +} + +export interface StoreInventoryEntry { + readonly storeName: string; + readonly modulePath: string; + readonly classification: StoreClassification; + readonly persisted: PersistedSliceSpec | null; + readonly reset: ResetPolicy; + readonly rationale: string; +} + +const full = { mode: "full" } as const; +const none = { mode: "none" } as const; + +export const STORE_ISOLATION_INVENTORY: readonly StoreInventoryEntry[] = [ + { storeName: "useActivityStore", modulePath: "src/stores/activityStore.ts", classification: "env-owned", persisted: null, reset: full, rationale: "Contains backend activity and host task identifiers." }, + { + storeName: "useAgentSessionStore", + modulePath: "src/stores/agentSessionStore.ts", + classification: "mixed", + persisted: { + storageName: "ralphx-agent-session-store", + envFields: ["focusedProjectId", "selectedProjectId", "selectedConversationId", "lastSelectedConversationByProjectId", "expandedProjectIds", "sidebarProjectFilterIds", "pinnedConversationIds", "artifactByConversationId", "runtimeByConversationId", "serviceTierByConversationId", "roleRuntimeOverridesByConversationId", "lastRuntimeByProjectId", "branchBaseCacheByProjectId", "lastBranchBaseSelectionByProjectId"], + globalFields: ["defaultStartMode", "showAllProjects", "showEmptyProjectGroups", "projectSort", "sidebarGroupBy", "sidebarPublicationStateFilters", "lastModelEffortByProvider"], + }, + reset: full, + rationale: "Combines host-keyed sessions with global agent sidebar and runtime preferences.", + }, + { storeName: "useArtifactStore", modulePath: "src/stores/artifactStore.ts", classification: "env-owned", persisted: null, reset: full, rationale: "Contains backend artifacts, buckets, and selections." }, + { storeName: "useChatStore", modulePath: "src/stores/chatStore.ts", classification: "env-owned", persisted: null, reset: full, rationale: "Contains host conversations, messages, runs, queues, and drafts." }, + { storeName: "useEnvironmentStore", modulePath: "src/stores/environmentStore.ts", classification: "infrastructure", persisted: null, reset: none, rationale: "Owns environment identity and the switch funnel." }, + { storeName: "useIdeationStore", modulePath: "src/stores/ideationStore.ts", classification: "env-owned", persisted: null, reset: full, rationale: "Contains backend ideation sessions, artifacts, and verification state." }, + { storeName: "useIntegrationDashboardStore", modulePath: "src/stores/integrationDashboardStore.ts", classification: "env-owned", persisted: null, reset: full, rationale: "Dashboard state is keyed by host project and backend integration entities." }, + { storeName: "useMethodologyStore", modulePath: "src/stores/methodologyStore.ts", classification: "env-owned", persisted: null, reset: full, rationale: "Contains backend methodologies and workflow identifiers." }, + { storeName: "usePlanStore", modulePath: "src/stores/planStore.ts", classification: "env-owned", persisted: null, reset: full, rationale: "Contains host project-keyed plans and backend candidates." }, + { + storeName: "useProjectStore", modulePath: "src/stores/projectStore.ts", classification: "env-owned", + persisted: { storageName: "ralphx-project-store", envFields: ["activeProjectId"], globalFields: [] }, + reset: full, rationale: "Projects and the active project identifier belong to one host.", + }, + { storeName: "useProposalStore", modulePath: "src/stores/proposalStore.ts", classification: "env-owned", persisted: null, reset: full, rationale: "Contains backend task proposals and proposal identifiers." }, + { storeName: "useQAStore", modulePath: "src/stores/qaStore.ts", classification: "env-owned", persisted: null, reset: full, rationale: "Contains backend QA settings and task-keyed results." }, + { storeName: "useReviewStore", modulePath: "src/stores/reviewStore.ts", classification: "env-owned", persisted: null, reset: full, rationale: "Contains backend reviews and selected review identifiers." }, + { storeName: "useTaskStore", modulePath: "src/stores/taskStore.ts", classification: "env-owned", persisted: null, reset: full, rationale: "Contains the active host's backend tasks." }, + { storeName: "useThemeStore", modulePath: "src/stores/themeStore.ts", classification: "global", persisted: null, reset: none, rationale: "Theme, motion, and font scale are client presentation preferences." }, + { + storeName: "useTicketingStore", modulePath: "src/stores/ticketingStore.ts", classification: "mixed", + persisted: { storageName: "ralphx-ticketing-store", envFields: ["activeProvider", "activeContainerId", "filters", "selectedTicketRef", "lastOpenedAt"], globalFields: ["viewMode"] }, + reset: full, rationale: "Ticket entities and filters are host-owned while view mode is presentation.", + }, + { + storeName: "useUiStore", modulePath: "src/stores/uiStore.ts", classification: "mixed", persisted: null, + reset: { mode: "fields", fields: ["activeModal", "modalContext", "notifications", "loading", "confirmation", "activeQuestions", "answeredQuestions", "recoveryPrompt", "recoveryPromptSurface", "executionStatus", "boardSearchQuery", "isSearching", "graphSelection", "taskHistoryState", "taskCreationContext", "preserveCurrentViewOnProjectSwitch", "activityFilter", "collapsedColumns", "viewByProject", "pendingConfirmationQueue", "autoAcceptPlans", "autoAcceptSessions"] }, + rationale: "Resets host-derived runtime and identifier-keyed UI while retaining presentation preferences and client-owned feature flags.", + }, + { storeName: "useWorkflowStore", modulePath: "src/stores/workflowStore.ts", classification: "env-owned", persisted: null, reset: full, rationale: "Contains backend workflows and active workflow identifiers." }, + { storeName: "useAgentArtifactUiStore", modulePath: "src/components/agents/agentArtifactUiStore.ts", classification: "env-owned", persisted: null, reset: full, rationale: "Artifact UI is keyed by host conversation identifiers." }, + { + storeName: "useAgentTerminalStore", modulePath: "src/components/agents/agentTerminalStore.ts", classification: "mixed", + persisted: { storageName: "ralphx-agent-terminal-ui", envFields: ["openByConversationId", "heightByConversationId", "activeTerminalByConversationId", "metadataByConversationId"], globalFields: ["placement"] }, + reset: full, rationale: "Terminal maps are conversation-owned while placement is presentation.", + }, + { storeName: "useHookEventsStore", modulePath: "src/hooks/useAgentHookEvents.ts", classification: "env-owned", persisted: null, reset: full, rationale: "Contains streamed backend hook events and task identifiers." }, + { storeName: "useSupervisorStore", modulePath: "src/hooks/useSupervisorAlerts.store.ts", classification: "env-owned", persisted: null, reset: full, rationale: "Contains backend supervisor alerts, connection state, and host configuration." }, +]; diff --git a/frontend/src/stores/activityStore.ts b/frontend/src/stores/activityStore.ts index 370c8f2ac1..34b14b007d 100644 --- a/frontend/src/stores/activityStore.ts +++ b/frontend/src/stores/activityStore.ts @@ -7,6 +7,8 @@ import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; + +import { registerEnvIsolatedStore } from "@/lib/remote/env-state-isolation"; import type { AgentMessageEvent, SupervisorAlertEvent } from "@/types/events"; // ============================================================================ @@ -136,3 +138,5 @@ export const useActivityStore = create()( ), })) ); + +registerEnvIsolatedStore({ name: "useActivityStore", reset: () => useActivityStore.setState(useActivityStore.getInitialState(), true) }); diff --git a/frontend/src/stores/agentSessionStore.ts b/frontend/src/stores/agentSessionStore.ts index 5fec35e227..80b0b61c16 100644 --- a/frontend/src/stores/agentSessionStore.ts +++ b/frontend/src/stores/agentSessionStore.ts @@ -2,6 +2,8 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import { immer } from "zustand/middleware/immer"; +import { createEnvScopedStorage } from "@/lib/remote/env-scoped-storage"; +import { registerEnvIsolatedStore } from "@/lib/remote/env-state-isolation"; import { normalizeAgentRuntimeForPersistence, type AgentEffort, @@ -983,6 +985,7 @@ export const useAgentSessionStore = create useAgentSessionStore.setState(useAgentSessionStore.getInitialState(), true), + rehydrate: () => { + void useAgentSessionStore.persist.rehydrate(); + }, +}); + export function selectArtifactState(conversationId: string | null) { return (state: AgentSessionState): AgentArtifactState => conversationId diff --git a/frontend/src/stores/artifactStore.ts b/frontend/src/stores/artifactStore.ts index fba88dd742..b90a6c6f1a 100644 --- a/frontend/src/stores/artifactStore.ts +++ b/frontend/src/stores/artifactStore.ts @@ -7,6 +7,8 @@ import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; + +import { registerEnvIsolatedStore } from "@/lib/remote/env-state-isolation"; import type { Artifact, ArtifactBucket, ArtifactType } from "@/types/artifact"; // ============================================================================ @@ -122,6 +124,8 @@ export const useArtifactStore = create()( })) ); +registerEnvIsolatedStore({ name: "useArtifactStore", reset: () => useArtifactStore.setState(useArtifactStore.getInitialState(), true) }); + // ============================================================================ // Selectors (defined outside store for memoization) // ============================================================================ diff --git a/frontend/src/stores/chatStore.ts b/frontend/src/stores/chatStore.ts index 050faf36ef..e5e42b07b4 100644 --- a/frontend/src/stores/chatStore.ts +++ b/frontend/src/stores/chatStore.ts @@ -8,6 +8,8 @@ import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; + +import { registerEnvIsolatedStore } from "@/lib/remote/env-state-isolation"; import type { ChatMessage } from "@/types/ideation"; import type { ChatContext } from "@/types/chat"; import type { ModelDisplay } from "@/types/chat-conversation"; @@ -676,6 +678,8 @@ export const useChatStore = create()( })) ); +registerEnvIsolatedStore({ name: "useChatStore", reset: () => useChatStore.setState(useChatStore.getInitialState(), true) }); + // ============================================================================ // Context Key Helper // ============================================================================ diff --git a/frontend/src/stores/environmentStore.envswitch.test.ts b/frontend/src/stores/environmentStore.envswitch.test.ts new file mode 100644 index 0000000000..318e3cdbe2 --- /dev/null +++ b/frontend/src/stores/environmentStore.envswitch.test.ts @@ -0,0 +1,149 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { remoteEnvironmentsApi } from "@/api/remote-environments"; +import { useAgentTerminalStore } from "@/components/agents/agentTerminalStore"; +import { resetTransportEnvironmentId } from "@/lib/remote/active-environment"; +import type { RemoteEnvironmentSummary } from "@/api/remote-environments"; +import type { Task } from "@/types/task"; +import { useProjectStore } from "./projectStore"; +import { useTaskStore } from "./taskStore"; +import { useUiStore } from "./uiStore"; +import { LOCAL_ENVIRONMENT_ID, useEnvironmentStore } from "./environmentStore"; + +vi.mock("@/api/remote-environments", () => ({ + remoteEnvironmentsApi: { + pair: vi.fn(), + list: vi.fn(), + remove: vi.fn(), + getActiveEnvironment: vi.fn(), + setActiveEnvironment: vi.fn(), + }, +})); + +const mockedApi = vi.mocked(remoteEnvironmentsApi); +const remote: RemoteEnvironmentSummary = { + id: "env-b", + environmentId: "host-b", + name: "Host B", + baseUrl: "https://host-b.test", + candidateUrls: [], + scopes: ["ui:read", "ui:operate"], + protocolVersion: 1, + status: "active", + createdAt: "2026-07-28T00:00:00Z", + lastConnectedAt: null, +}; +const localTask: Task = { + id: "task-local", + projectId: "proj-local", + category: "feature", + title: "Local task", + description: null, + priority: 0, + internalStatus: "backlog", + createdAt: "2026-07-28T00:00:00Z", + updatedAt: "2026-07-28T00:00:00Z", + startedAt: null, + completedAt: null, +}; + +function resetAll(): void { + localStorage.clear(); + resetTransportEnvironmentId(); + useEnvironmentStore.setState({ + activeEnvironmentId: LOCAL_ENVIRONMENT_ID, + environments: [ + { id: LOCAL_ENVIRONMENT_ID, name: "This Mac", kind: "local" }, + { id: remote.id, name: remote.name, kind: "remote", remote }, + ], + connectionStates: { [LOCAL_ENVIRONMENT_ID]: "connected" }, + }); + useProjectStore.setState(useProjectStore.getInitialState(), true); + useTaskStore.setState(useTaskStore.getInitialState(), true); + useAgentTerminalStore.setState(useAgentTerminalStore.getInitialState(), true); + useUiStore.setState(useUiStore.getInitialState(), true); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockedApi.setActiveEnvironment.mockResolvedValue(null); + resetAll(); +}); + +describe("P-13 environment store isolation", () => { + it("round-trips A to B to A with bidirectional absence", async () => { + useProjectStore.getState().selectProject("proj-local"); + useTaskStore.getState().setTasks([localTask]); + useAgentTerminalStore.getState().setOpen("conversation-local", true); + + await useEnvironmentStore.getState().setActiveEnvironment("env-b"); + expect(useProjectStore.getState().activeProjectId).toBeNull(); + expect(useTaskStore.getState().tasks).toEqual({}); + expect(useAgentTerminalStore.getState().openByConversationId).toEqual({}); + expect(localStorage.getItem("ralphx-project-store:env-b") ?? "").not.toContain("proj-local"); + expect(localStorage.getItem("ralphx-project-store")).toContain("proj-local"); + + useProjectStore.getState().selectProject("proj-b"); + await useEnvironmentStore.getState().setActiveEnvironment(LOCAL_ENVIRONMENT_ID); + expect(useProjectStore.getState().activeProjectId).toBe("proj-local"); + expect(JSON.stringify(useProjectStore.getState())).not.toContain("proj-b"); + expect(localStorage.getItem("ralphx-project-store")).not.toContain("proj-b"); + + await useEnvironmentStore.getState().setActiveEnvironment("env-b"); + expect(useProjectStore.getState().activeProjectId).toBe("proj-b"); + for (let index = 0; index < localStorage.length; index += 1) { + const key = localStorage.key(index); + if (key?.startsWith("ralphx-") && key.endsWith(":env-b")) { + expect(localStorage.getItem(key)).not.toMatch(/proj-local|task-local|conversation-local/); + } + } + }); + + it("rehydrates the previous environment after a rejected optimistic switch", async () => { + useProjectStore.getState().selectProject("proj-local"); + mockedApi.setActiveEnvironment.mockRejectedValue(new Error("refused")); + await expect(useEnvironmentStore.getState().setActiveEnvironment("env-b")).rejects.toThrow("refused"); + expect(useEnvironmentStore.getState().activeEnvironmentId).toBe(LOCAL_ENVIRONMENT_ID); + expect(useProjectStore.getState().activeProjectId).toBe("proj-local"); + }); + + it("isolates synchronously before the backend promise settles", async () => { + let resolve: () => void = () => {}; + mockedApi.setActiveEnvironment.mockImplementation( + () => + new Promise((done) => { + resolve = () => done(null); + }), + ); + useProjectStore.getState().selectProject("proj-local"); + const pending = useEnvironmentStore.getState().setActiveEnvironment("env-b"); + expect(useProjectStore.getState().activeProjectId).toBeNull(); + resolve(); + await pending; + }); + it("does no storage work for a no-op switch", async () => { + const getSpy = vi.spyOn(Storage.prototype, "getItem"); + const setSpy = vi.spyOn(Storage.prototype, "setItem"); + getSpy.mockClear(); + setSpy.mockClear(); + await useEnvironmentStore.getState().setActiveEnvironment(LOCAL_ENVIRONMENT_ID); + expect(getSpy).not.toHaveBeenCalled(); + expect(setSpy).not.toHaveBeenCalled(); + getSpy.mockRestore(); + setSpy.mockRestore(); + }); + + it("preserves client feature flags while resetting environment-owned UI state", async () => { + const flags = { + ...useUiStore.getState().featureFlags, + remoteEnvironments: !useUiStore.getState().featureFlags.remoteEnvironments, + }; + useUiStore.getState().setFeatureFlags(flags); + useUiStore.getState().setBoardSearchQuery("host-specific query"); + + await useEnvironmentStore.getState().setActiveEnvironment("env-b"); + + expect(useUiStore.getState().featureFlags).toEqual(flags); + expect(useUiStore.getState().boardSearchQuery).toBeNull(); + }); +}); diff --git a/frontend/src/stores/environmentStore.ts b/frontend/src/stores/environmentStore.ts index f1b59035eb..0a4a463cb0 100644 --- a/frontend/src/stores/environmentStore.ts +++ b/frontend/src/stores/environmentStore.ts @@ -17,6 +17,8 @@ */ import { create } from "zustand"; + +import { onEnvironmentSwitched } from "@/lib/remote/env-state-isolation"; import { remoteEnvironmentsApi, type RemoteEnvironmentSummary, @@ -164,7 +166,7 @@ export const useEnvironmentStore = create((set, get) => ({ })); /** - * Single writer of the transport's active-environment mirror (PR 2.2). + * Single writer of the transport mirror and environment-state isolation funnel. * * Subscribing here rather than calling `setTransportEnvironmentId` beside each * `set({ activeEnvironmentId })` means a future assignment cannot forget to mirror — @@ -179,5 +181,6 @@ export const useEnvironmentStore = create((set, get) => ({ useEnvironmentStore.subscribe((state, previous) => { if (state.activeEnvironmentId !== previous.activeEnvironmentId) { setTransportEnvironmentId(state.activeEnvironmentId); + onEnvironmentSwitched(); } }); diff --git a/frontend/src/stores/ideationStore.ts b/frontend/src/stores/ideationStore.ts index 5e4c3a5a34..5765012a7f 100644 --- a/frontend/src/stores/ideationStore.ts +++ b/frontend/src/stores/ideationStore.ts @@ -7,6 +7,8 @@ import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; + +import { registerEnvIsolatedStore } from "@/lib/remote/env-state-isolation"; import type { IdeationSession, IdeationSessionStatus } from "@/types/ideation"; import type { Artifact } from "@/types/artifact"; import type { IdeationSettings } from "@/types/ideation-config"; @@ -317,6 +319,8 @@ export const useIdeationStore = create()( })) ); +registerEnvIsolatedStore({ name: "useIdeationStore", reset: () => useIdeationStore.setState(useIdeationStore.getInitialState(), true) }); + if (typeof window !== "undefined" && !window.__TAURI_INTERNALS__) { window.__ideationStore = useIdeationStore; } diff --git a/frontend/src/stores/integrationDashboardStore.ts b/frontend/src/stores/integrationDashboardStore.ts index 7a1814abb8..7e780b70a9 100644 --- a/frontend/src/stores/integrationDashboardStore.ts +++ b/frontend/src/stores/integrationDashboardStore.ts @@ -1,4 +1,6 @@ import { create } from "zustand"; + +import { registerEnvIsolatedStore } from "@/lib/remote/env-state-isolation"; import { immer } from "zustand/middleware/immer"; export type GitHubBranchAssociationFilter = "all" | "pull_requests" | "tickets" | "rx"; @@ -135,3 +137,5 @@ export const useIntegrationDashboardStore = create< }), })), ); + +registerEnvIsolatedStore({ name: "useIntegrationDashboardStore", reset: () => useIntegrationDashboardStore.setState(useIntegrationDashboardStore.getInitialState(), true) }); diff --git a/frontend/src/stores/methodologyStore.ts b/frontend/src/stores/methodologyStore.ts index 505a9e1fe7..95feaa0634 100644 --- a/frontend/src/stores/methodologyStore.ts +++ b/frontend/src/stores/methodologyStore.ts @@ -8,6 +8,8 @@ import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; +import { registerEnvIsolatedStore } from "@/lib/remote/env-state-isolation"; + // ============================================================================ // Types (matching API response structure) // ============================================================================ @@ -168,6 +170,8 @@ export const useMethodologyStore = create })) ); +registerEnvIsolatedStore({ name: "useMethodologyStore", reset: () => useMethodologyStore.setState(useMethodologyStore.getInitialState(), true) }); + // ============================================================================ // Selectors (defined outside store for memoization) // ============================================================================ diff --git a/frontend/src/stores/planStore.ts b/frontend/src/stores/planStore.ts index 8fd91dc2a1..7d0ebd25f2 100644 --- a/frontend/src/stores/planStore.ts +++ b/frontend/src/stores/planStore.ts @@ -8,6 +8,8 @@ import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; + +import { registerEnvIsolatedStore } from "@/lib/remote/env-state-isolation"; import { planApi, type PlanCandidateResponse, @@ -201,6 +203,8 @@ export const usePlanStore = create()( })) ); +registerEnvIsolatedStore({ name: "usePlanStore", reset: () => usePlanStore.setState(usePlanStore.getInitialState(), true) }); + if (typeof window !== "undefined" && !window.__TAURI_INTERNALS__) { window.__planStore = usePlanStore; } diff --git a/frontend/src/stores/projectStore.ts b/frontend/src/stores/projectStore.ts index b96a9c4c9c..94f695fee3 100644 --- a/frontend/src/stores/projectStore.ts +++ b/frontend/src/stores/projectStore.ts @@ -11,6 +11,9 @@ import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; + +import { createEnvScopedStorage } from "@/lib/remote/env-scoped-storage"; +import { registerEnvIsolatedStore } from "@/lib/remote/env-state-isolation"; import { persist } from "zustand/middleware"; import type { Project } from "@/types/project"; @@ -88,12 +91,21 @@ export const useProjectStore = create()( })), { name: "ralphx-project-store", + storage: createEnvScopedStorage("ralphx-project-store"), // Only persist the activeProjectId, not the projects (those come from backend) partialize: (state) => ({ activeProjectId: state.activeProjectId }), } ) ); +registerEnvIsolatedStore({ + name: "useProjectStore", + reset: () => useProjectStore.setState(useProjectStore.getInitialState(), true), + rehydrate: () => { + void useProjectStore.persist.rehydrate(); + }, +}); + // ============================================================================ // Selectors (defined outside store for memoization) // ============================================================================ diff --git a/frontend/src/stores/proposalStore.ts b/frontend/src/stores/proposalStore.ts index f171366f01..2ca5a60d0d 100644 --- a/frontend/src/stores/proposalStore.ts +++ b/frontend/src/stores/proposalStore.ts @@ -7,6 +7,8 @@ import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; + +import { registerEnvIsolatedStore } from "@/lib/remote/env-state-isolation"; import type { TaskProposal, Priority } from "@/types/ideation"; // ============================================================================ @@ -137,6 +139,8 @@ export const useProposalStore = create()( })) ); +registerEnvIsolatedStore({ name: "useProposalStore", reset: () => useProposalStore.setState(useProposalStore.getInitialState(), true) }); + if (typeof window !== "undefined" && !window.__TAURI_INTERNALS__) { window.__proposalStore = useProposalStore; } diff --git a/frontend/src/stores/qaStore.ts b/frontend/src/stores/qaStore.ts index 94542d3ddf..877a878fe5 100644 --- a/frontend/src/stores/qaStore.ts +++ b/frontend/src/stores/qaStore.ts @@ -7,6 +7,8 @@ import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; + +import { registerEnvIsolatedStore } from "@/lib/remote/env-state-isolation"; import { enableMapSet } from "immer"; import type { QASettings } from "@/types/qa-config"; import type { TaskQAResponse } from "@/lib/tauri"; @@ -137,6 +139,8 @@ export const useQAStore = create()( })) ); +registerEnvIsolatedStore({ name: "useQAStore", reset: () => useQAStore.setState(useQAStore.getInitialState(), true) }); + // ============================================================================ // Selectors // ============================================================================ diff --git a/frontend/src/stores/reviewStore.ts b/frontend/src/stores/reviewStore.ts index 54bc4306d8..338000c385 100644 --- a/frontend/src/stores/reviewStore.ts +++ b/frontend/src/stores/reviewStore.ts @@ -7,6 +7,8 @@ import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; + +import { registerEnvIsolatedStore } from "@/lib/remote/env-state-isolation"; import type { ReviewResponse } from "@/lib/tauri"; // ============================================================================ @@ -104,6 +106,8 @@ export const useReviewStore = create()( })) ); +registerEnvIsolatedStore({ name: "useReviewStore", reset: () => useReviewStore.setState(useReviewStore.getInitialState(), true) }); + // ============================================================================ // Selectors // ============================================================================ diff --git a/frontend/src/stores/taskStore.ts b/frontend/src/stores/taskStore.ts index 4bf66914e0..36e84c998a 100644 --- a/frontend/src/stores/taskStore.ts +++ b/frontend/src/stores/taskStore.ts @@ -8,6 +8,8 @@ import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; + +import { registerEnvIsolatedStore } from "@/lib/remote/env-state-isolation"; import type { Task, InternalStatus } from "@/types/task"; // ============================================================================ @@ -69,6 +71,8 @@ export const useTaskStore = create()( })) ); +registerEnvIsolatedStore({ name: "useTaskStore", reset: () => useTaskStore.setState(useTaskStore.getInitialState(), true) }); + // ============================================================================ // Selectors (defined outside store for memoization) // ============================================================================ diff --git a/frontend/src/stores/ticketingStore.ts b/frontend/src/stores/ticketingStore.ts index 639bf61353..f93a1f9eaa 100644 --- a/frontend/src/stores/ticketingStore.ts +++ b/frontend/src/stores/ticketingStore.ts @@ -2,6 +2,8 @@ import { create } from "zustand"; import { persist } from "zustand/middleware"; import { immer } from "zustand/middleware/immer"; +import { createEnvScopedStorage } from "@/lib/remote/env-scoped-storage"; +import { registerEnvIsolatedStore } from "@/lib/remote/env-state-isolation"; import type { TicketRef, TicketingProvider } from "@/api/ticketing"; export type TicketingViewMode = "list" | "kanban"; @@ -161,6 +163,7 @@ export const useTicketingStore = create()( })), { name: "ralphx-ticketing-store", + storage: createEnvScopedStorage("ralphx-ticketing-store"), version: 2, migrate: migrateTicketingState, partialize: (state) => ({ @@ -174,3 +177,11 @@ export const useTicketingStore = create()( }, ), ); + +registerEnvIsolatedStore({ + name: "useTicketingStore", + reset: () => useTicketingStore.setState(useTicketingStore.getInitialState(), true), + rehydrate: () => { + void useTicketingStore.persist.rehydrate(); + }, +}); diff --git a/frontend/src/stores/uiStore.ts b/frontend/src/stores/uiStore.ts index e4856345cb..9a2e28e017 100644 --- a/frontend/src/stores/uiStore.ts +++ b/frontend/src/stores/uiStore.ts @@ -8,6 +8,8 @@ import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; + +import { registerEnvIsolatedStore } from "@/lib/remote/env-state-isolation"; import { enableMapSet } from "immer"; import { invoke } from "@tauri-apps/api/core"; import { featureFlagsSchema } from "@/types/feature-flags"; @@ -857,6 +859,38 @@ export const useUiStore = create()( })) ); +registerEnvIsolatedStore({ + name: "useUiStore", + reset: () => { + const initial = useUiStore.getInitialState(); + useUiStore.setState({ + activeModal: initial.activeModal, + modalContext: initial.modalContext, + notifications: initial.notifications, + loading: initial.loading, + confirmation: initial.confirmation, + activeQuestions: initial.activeQuestions, + answeredQuestions: initial.answeredQuestions, + recoveryPrompt: initial.recoveryPrompt, + recoveryPromptSurface: initial.recoveryPromptSurface, + executionStatus: initial.executionStatus, + boardSearchQuery: initial.boardSearchQuery, + isSearching: initial.isSearching, + graphSelection: initial.graphSelection, + taskHistoryState: initial.taskHistoryState, + taskCreationContext: initial.taskCreationContext, + preserveCurrentViewOnProjectSwitch: + initial.preserveCurrentViewOnProjectSwitch, + activityFilter: initial.activityFilter, + collapsedColumns: initial.collapsedColumns, + viewByProject: initial.viewByProject, + pendingConfirmationQueue: initial.pendingConfirmationQueue, + autoAcceptPlans: initial.autoAcceptPlans, + autoAcceptSessions: initial.autoAcceptSessions, + }); + }, +}); + // Expose uiStore to window in web mode for Playwright testing if (typeof window !== "undefined" && !window.__TAURI_INTERNALS__) { window.__uiStore = useUiStore; diff --git a/frontend/src/stores/workflowStore.ts b/frontend/src/stores/workflowStore.ts index de7cf30c37..48f506d0fb 100644 --- a/frontend/src/stores/workflowStore.ts +++ b/frontend/src/stores/workflowStore.ts @@ -7,6 +7,8 @@ import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; + +import { registerEnvIsolatedStore } from "@/lib/remote/env-state-isolation"; import type { WorkflowSchema, WorkflowColumn } from "@/types/workflow"; // ============================================================================ @@ -100,6 +102,8 @@ export const useWorkflowStore = create()( })) ); +registerEnvIsolatedStore({ name: "useWorkflowStore", reset: () => useWorkflowStore.setState(useWorkflowStore.getInitialState(), true) }); + // ============================================================================ // Selectors (defined outside store for memoization) // ============================================================================ From 01ac7045cc5f0fc942bc18398775f95e9e22104e Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:39:19 +0300 Subject: [PATCH 131/416] feat(remote): keyed QueryClient registry and environment-scoped provider remount getQueryClient becomes an environment-keyed registry (default argument follows the transport's active environment) with retained instances for fast back-switch paint and zero query-key edits (A-8). A new EnvironmentScopedProviders seam keys the QueryClientProvider + EventProvider subtree by activeEnvironmentId so the event-bus memo re-runs and the workspace remounts per environment; EventProvider accepts an optional environmentId with the no-argument path unchanged. AppContent reads its client from context, tracking the active environment. --- frontend/src/App.tsx | 15 +- frontend/src/lib/queryClient.test.ts | 46 ++++++ frontend/src/lib/queryClient.ts | 25 ++- .../EnvironmentScopedProviders.test.tsx | 149 ++++++++++++++++++ .../providers/EnvironmentScopedProviders.tsx | 35 ++++ frontend/src/providers/EventProvider.test.tsx | 21 +++ frontend/src/providers/EventProvider.tsx | 13 +- 7 files changed, 285 insertions(+), 19 deletions(-) create mode 100644 frontend/src/lib/queryClient.test.ts create mode 100644 frontend/src/providers/EnvironmentScopedProviders.test.tsx create mode 100644 frontend/src/providers/EnvironmentScopedProviders.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 05f27ea0c9..b2ffd287c7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -4,11 +4,10 @@ */ import { lazy, Suspense, useMemo, useState, useEffect, useCallback, useRef } from "react"; +import { useQueryClient } from "@tanstack/react-query"; import { useShallow } from "zustand/react/shallow"; -import { QueryClientProvider } from "@tanstack/react-query"; import { toast } from "sonner"; -import { getQueryClient } from "@/lib/queryClient"; -import { EventProvider } from "@/providers/EventProvider"; +import { EnvironmentScopedProviders } from "@/providers/EnvironmentScopedProviders"; import { NotificationCenterPanel } from "@/components/notifications/NotificationCenterPanel"; import { ExecutionControlBar } from "@/components/execution/ExecutionControlBar"; import { @@ -79,7 +78,6 @@ import { ScreenshotGalleryTestPage } from "@/test-pages/ScreenshotGalleryTest"; import { ChatActivityVisualTestPage } from "@/test-pages/ChatActivityVisualTest"; import { preloadAutomationsView } from "@/components/automations/preloadAutomationsView"; -const queryClient = getQueryClient(); const ATLASSIAN_AWARENESS_TOAST_KEY = "ralphx.atlassianIntegrationAwareness.v1"; const LazyAutomationsView = lazy(() => preloadAutomationsView()); const LazyAgentsView = lazy(async () => { @@ -219,6 +217,7 @@ function AgentsRouteShell() { } function AppContent({ backgroundSettled }: { backgroundSettled: boolean }) { + const queryClient = useQueryClient(); // Check for test page first (must happen before any hooks for ESLint compliance) const testPage = useMemo(() => getTestPage(), []); @@ -1252,11 +1251,9 @@ function App({ startupStatus }: { startupStatus?: StartupStatus }) { const backgroundSettled = startupStatus?.backgroundComplete ?? true; return ( - - - - - + + + ); } diff --git a/frontend/src/lib/queryClient.test.ts b/frontend/src/lib/queryClient.test.ts new file mode 100644 index 0000000000..8f47473c66 --- /dev/null +++ b/frontend/src/lib/queryClient.test.ts @@ -0,0 +1,46 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { + resetTransportEnvironmentId, + setTransportEnvironmentId, +} from "@/lib/remote/active-environment"; +import { getQueryClient, resetQueryClient } from "./queryClient"; + +afterEach(() => { + resetQueryClient(); + resetTransportEnvironmentId(); +}); + +describe("getQueryClient", () => { + it("retains one isolated client and cache per environment", () => { + const environmentA = getQueryClient("env-a"); + const environmentAAgain = getQueryClient("env-a"); + const environmentB = getQueryClient("env-b"); + + environmentA.setQueryData(["shared-key"], "environment-a-data"); + + expect(environmentAAgain).toBe(environmentA); + expect(environmentB).not.toBe(environmentA); + expect(environmentA.getQueryData(["shared-key"])).toBe("environment-a-data"); + expect(environmentB.getQueryData(["shared-key"])).toBeUndefined(); + }); + + it("uses the transport environment for its default argument", () => { + const localClient = getQueryClient(); + + setTransportEnvironmentId("env-b"); + + expect(getQueryClient()).toBe(getQueryClient("env-b")); + expect(getQueryClient()).not.toBe(localClient); + }); + + it("drops every retained client when reset", () => { + const environmentA = getQueryClient("env-a"); + const environmentB = getQueryClient("env-b"); + + resetQueryClient(); + + expect(getQueryClient("env-a")).not.toBe(environmentA); + expect(getQueryClient("env-b")).not.toBe(environmentB); + }); +}); diff --git a/frontend/src/lib/queryClient.ts b/frontend/src/lib/queryClient.ts index f77dd46346..ea7e2bcffa 100644 --- a/frontend/src/lib/queryClient.ts +++ b/frontend/src/lib/queryClient.ts @@ -6,6 +6,8 @@ import { QueryClient } from "@tanstack/react-query"; +import { getTransportEnvironmentId } from "@/lib/remote/active-environment"; + /** * Default stale time for queries (5 minutes) * Data is considered fresh for this duration. @@ -49,17 +51,26 @@ export function createQueryClient(): QueryClient { } /** - * Singleton QueryClient instance for the app - * Created lazily to support testing with fresh instances. + * Environment-keyed QueryClient instances for the app. + * Created lazily and retained so switching back can reuse a warm cache. */ -let queryClient: QueryClient | null = null; +const queryClients = new Map(); -export function getQueryClient(): QueryClient { +export function getQueryClient( + environmentId: string = getTransportEnvironmentId(), +): QueryClient { + let queryClient = queryClients.get(environmentId); if (!queryClient) { queryClient = createQueryClient(); + queryClients.set(environmentId, queryClient); - // Expose queryClient to window in web mode for Playwright testing - if (typeof window !== 'undefined' && !window.__TAURI_INTERNALS__) { + // Preserve the original first-creation Playwright exposure. The mounted + // EnvironmentScopedProviders is the single writer for subsequent switches. + if ( + queryClients.size === 1 && + typeof window !== "undefined" && + !window.__TAURI_INTERNALS__ + ) { window.__queryClient = queryClient; } } @@ -70,5 +81,5 @@ export function getQueryClient(): QueryClient { * Reset the query client (for testing) */ export function resetQueryClient(): void { - queryClient = null; + queryClients.clear(); } diff --git a/frontend/src/providers/EnvironmentScopedProviders.test.tsx b/frontend/src/providers/EnvironmentScopedProviders.test.tsx new file mode 100644 index 0000000000..c2bc591ccd --- /dev/null +++ b/frontend/src/providers/EnvironmentScopedProviders.test.tsx @@ -0,0 +1,149 @@ +import { invoke } from "@tauri-apps/api/core"; +import { useQueryClient, type QueryClient } from "@tanstack/react-query"; +import { act, render, screen, waitFor } from "@testing-library/react"; +import { useEffect, type ReactNode } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { getQueryClient, resetQueryClient } from "@/lib/queryClient"; +import { resetTransportEnvironmentId } from "@/lib/remote/active-environment"; +import { + LOCAL_ENVIRONMENT_ID, + useEnvironmentStore, +} from "@/stores/environmentStore"; +import { EnvironmentScopedProviders } from "./EnvironmentScopedProviders"; + +vi.mock("@/api/remote-environments", () => ({ + remoteEnvironmentsApi: { + pair: vi.fn(), + list: vi.fn(), + remove: vi.fn(), + getActiveEnvironment: vi.fn(), + setActiveEnvironment: vi.fn(), + }, +})); + +vi.mock("@/providers/EventProvider", () => ({ + EventProvider: ({ children }: { children: ReactNode }) => <>{children}, +})); + +function ClientProbe({ + onClient, + onMount, + onUnmount, +}: { + onClient?: (client: QueryClient) => void; + onMount?: () => void; + onUnmount?: () => void; +}) { + const client = useQueryClient(); + onClient?.(client); + + useEffect(() => { + onMount?.(); + return () => onUnmount?.(); + }, [onMount, onUnmount]); + + return ( +
+ {client.getQueryData(["retained"]) ?? "empty"} +
+ ); +} + +function setActiveEnvironmentId(activeEnvironmentId: string): void { + act(() => { + useEnvironmentStore.setState({ activeEnvironmentId }); + }); +} + +beforeEach(() => { + resetQueryClient(); + resetTransportEnvironmentId(); + useEnvironmentStore.setState({ activeEnvironmentId: LOCAL_ENVIRONMENT_ID }); + window.__queryClient = undefined; +}); + +afterEach(() => { + resetQueryClient(); + resetTransportEnvironmentId(); + useEnvironmentStore.setState({ activeEnvironmentId: LOCAL_ENVIRONMENT_ID }); + vi.unstubAllGlobals(); +}); + +describe("EnvironmentScopedProviders", () => { + it("provides the active environment's QueryClient", () => { + const observedClients: QueryClient[] = []; + render( + + observedClients.push(client)} /> + , + ); + + expect(observedClients).toEqual([getQueryClient(LOCAL_ENVIRONMENT_ID)]); + }); + + it("remounts the subtree with the new environment client", () => { + const mount = vi.fn(); + const unmount = vi.fn(); + render( + + + , + ); + const localClient = window.__queryClient; + + setActiveEnvironmentId("env-b"); + + expect(mount).toHaveBeenCalledTimes(2); + expect(unmount).toHaveBeenCalledTimes(1); + expect(window.__queryClient).toBe(getQueryClient("env-b")); + expect(window.__queryClient).not.toBe(localClient); + }); + + it("retains the original cache when switching away and back", () => { + const localClient = getQueryClient(LOCAL_ENVIRONMENT_ID); + localClient.setQueryData(["retained"], "local-value"); + render( + + + , + ); + + setActiveEnvironmentId("env-b"); + setActiveEnvironmentId(LOCAL_ENVIRONMENT_ID); + + expect(window.__queryClient).toBe(localClient); + expect(screen.getByTestId("client-probe")).toHaveTextContent("local-value"); + expect(localClient.getQueryData(["retained"])).toBe("local-value"); + }); + + it("performs no fetch or invoke when it mounts and remounts", () => { + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + + render( + + + , + ); + setActiveEnvironmentId("env-b"); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(vi.mocked(invoke)).not.toHaveBeenCalled(); + }); + + it("keeps the web-mode Playwright client aligned across a switch", async () => { + render( + + + , + ); + expect(window.__queryClient).toBe(getQueryClient(LOCAL_ENVIRONMENT_ID)); + + setActiveEnvironmentId("env-b"); + + await waitFor(() => { + expect(window.__queryClient).toBe(getQueryClient("env-b")); + }); + }); +}); diff --git a/frontend/src/providers/EnvironmentScopedProviders.tsx b/frontend/src/providers/EnvironmentScopedProviders.tsx new file mode 100644 index 0000000000..199a8daaab --- /dev/null +++ b/frontend/src/providers/EnvironmentScopedProviders.tsx @@ -0,0 +1,35 @@ +import { QueryClientProvider } from "@tanstack/react-query"; +import { useEffect, type ReactNode } from "react"; + +import { getQueryClient } from "@/lib/queryClient"; +import { useEnvironmentStore } from "@/stores/environmentStore"; +import { EventProvider } from "./EventProvider"; + +interface EnvironmentScopedProvidersProps { + children: ReactNode; +} + +export function EnvironmentScopedProviders({ + children, +}: EnvironmentScopedProvidersProps) { + const activeEnvironmentId = useEnvironmentStore( + (state) => state.activeEnvironmentId, + ); + const queryClient = getQueryClient(activeEnvironmentId); + + useEffect(() => { + // Single writer after initial client creation: Playwright always observes + // the client belonging to the provider subtree that is currently mounted. + if (typeof window !== "undefined" && !window.__TAURI_INTERNALS__) { + window.__queryClient = queryClient; + } + }, [queryClient]); + + return ( + + + {children} + + + ); +} diff --git a/frontend/src/providers/EventProvider.test.tsx b/frontend/src/providers/EventProvider.test.tsx index ad32e855a2..0d0776a3c1 100644 --- a/frontend/src/providers/EventProvider.test.tsx +++ b/frontend/src/providers/EventProvider.test.tsx @@ -77,6 +77,7 @@ import { import { useNotificationEvents } from "@/hooks/useNotificationEvents"; import { useNotificationToasts } from "@/hooks/useNotificationToasts"; import { useUsageStatsEvents } from "@/hooks/useUsageStatsEvents"; +import { createEventBus } from "@/lib/event-bus"; describe("EventProvider", () => { beforeEach(() => { @@ -173,6 +174,26 @@ describe("EventProvider", () => { screen.getByTestId("inner") ); }); + + it("creates the bus for an explicit environment", () => { + render( + +
Test
+
, + ); + + expect(createEventBus).toHaveBeenCalledWith("env-b"); + }); + + it("preserves the default bus creation call when no environment is provided", () => { + render( + +
Test
+
, + ); + + expect(createEventBus).toHaveBeenCalledWith(); + }); }); describe("useEventBus", () => { diff --git a/frontend/src/providers/EventProvider.tsx b/frontend/src/providers/EventProvider.tsx index 338cccf0ce..3e7be83a16 100644 --- a/frontend/src/providers/EventProvider.tsx +++ b/frontend/src/providers/EventProvider.tsx @@ -103,6 +103,7 @@ function GlobalEventListeners({ children }: { children: ReactNode }) { interface EventProviderProps { children: ReactNode; + environmentId?: string; } /** @@ -132,9 +133,15 @@ interface EventProviderProps { * } * ``` */ -export function EventProvider({ children }: EventProviderProps) { - // Create event bus once based on environment (Tauri or browser mode) - const eventBus = useMemo(() => createEventBus(), []); +export function EventProvider({ children, environmentId }: EventProviderProps) { + // Recreate the memoized bus when the environment-keyed provider subtree changes. + const eventBus = useMemo( + () => + environmentId === undefined + ? createEventBus() + : createEventBus(environmentId), + [environmentId], + ); // Expose event bus to window in web mode for Playwright testing useEffect(() => { From 4e3e3422cfc16c70f5b5fc3dcfcf89ce2b17965b Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:48:09 +0300 Subject: [PATCH 132/416] test: close remote multi-environment coverage gaps (round 2) Service error taxonomy, fetch/pairing validation, reconcile failure branches, memory repo direct tests, auth teardown/scope/Retry-After, capture control-closed overflow, tailscale fake-CLI fixture harness, and a local axum canned-server harness for the Hyper host client. --- .../remote_environment_service_tests.rs | 723 +++++++++++++++++- .../memory_remote_environment_repo_tests.rs | 126 +++ src-tauri/src/infrastructure/memory/mod.rs | 2 + .../src/infrastructure/remote_host_client.rs | 310 +++++++- .../src/infrastructure/tailscale_tests.rs | 210 ++++- src-tauri/src/remote_server/auth_tests.rs | 42 +- src-tauri/src/remote_server/capture_tests.rs | 27 + .../tests/fixtures/tailscale/launch-fail.sh | 1 + .../tailscale/serve-fail-long-stderr.sh | 3 + .../tests/fixtures/tailscale/serve-fail.sh | 3 + .../tests/fixtures/tailscale/serve-ok.sh | 2 + .../fixtures/tailscale/status-daemon-down.sh | 3 + .../fixtures/tailscale/status-running.sh | 3 + 13 files changed, 1416 insertions(+), 39 deletions(-) create mode 100644 src-tauri/src/infrastructure/memory/memory_remote_environment_repo_tests.rs create mode 100644 src-tauri/tests/fixtures/tailscale/launch-fail.sh create mode 100644 src-tauri/tests/fixtures/tailscale/serve-fail-long-stderr.sh create mode 100644 src-tauri/tests/fixtures/tailscale/serve-fail.sh create mode 100644 src-tauri/tests/fixtures/tailscale/serve-ok.sh create mode 100644 src-tauri/tests/fixtures/tailscale/status-daemon-down.sh create mode 100644 src-tauri/tests/fixtures/tailscale/status-running.sh diff --git a/src-tauri/src/application/remote_environment_service_tests.rs b/src-tauri/src/application/remote_environment_service_tests.rs index 07de68d40b..274baf23e0 100644 --- a/src-tauri/src/application/remote_environment_service_tests.rs +++ b/src-tauri/src/application/remote_environment_service_tests.rs @@ -69,6 +69,112 @@ fn fixture_with_host(host: MockRemoteHostClient) -> Fixture { } } +struct FailingRemoteEnvironmentRepository { + inner: Arc, + fail_list: bool, + fail_get: bool, + fail_set_status: bool, + fail_delete: bool, +} + +impl FailingRemoteEnvironmentRepository { + fn new(inner: Arc) -> Self { + Self { + inner, + fail_list: false, + fail_get: false, + fail_set_status: false, + fail_delete: false, + } + } + + fn database_error() -> crate::error::AppResult { + Err(crate::error::AppError::Database("boom".to_string())) + } +} + +#[async_trait] +impl crate::domain::repositories::RemoteEnvironmentRepository + for FailingRemoteEnvironmentRepository +{ + async fn upsert_paired( + &self, + params: crate::domain::repositories::UpsertPairedEnvironment, + ) -> crate::error::AppResult { + self.inner.upsert_paired(params).await + } + + async fn get( + &self, + id: &crate::domain::entities::remote_environment::RemoteEnvironmentId, + ) -> crate::error::AppResult> { + if self.fail_get { + Self::database_error() + } else { + self.inner.get(id).await + } + } + + async fn get_by_environment_id( + &self, + environment_id: &str, + ) -> crate::error::AppResult> { + self.inner.get_by_environment_id(environment_id).await + } + + async fn list(&self) -> crate::error::AppResult> { + if self.fail_list { + Self::database_error() + } else { + self.inner.list().await + } + } + + async fn set_status( + &self, + id: &crate::domain::entities::remote_environment::RemoteEnvironmentId, + status: RemoteEnvironmentStatus, + ) -> crate::error::AppResult<()> { + if self.fail_set_status { + Self::database_error() + } else { + self.inner.set_status(id, status).await + } + } + + async fn delete( + &self, + id: &crate::domain::entities::remote_environment::RemoteEnvironmentId, + ) -> crate::error::AppResult<()> { + if self.fail_delete { + Self::database_error() + } else { + self.inner.delete(id).await + } + } + + async fn touch_last_connected( + &self, + id: &crate::domain::entities::remote_environment::RemoteEnvironmentId, + timestamp: &str, + ) -> crate::error::AppResult<()> { + self.inner.touch_last_connected(id, timestamp).await + } +} + +fn service_with_repo( + repo: Arc, +) -> RemoteEnvironmentService { + RemoteEnvironmentService::new( + repo, + Arc::new(MemorySecretStore::new()), + Arc::new(MockRemoteHostClient::new( + descriptor("env-1"), + pair_response("env-1"), + )), + ) +} + // Trait methods on the concrete test doubles resolve through the imports the // service module already provides via `use super::*` (RemoteEnvironmentRepository, // SecretStore) — no extra trait imports needed here. @@ -77,6 +183,138 @@ fn fixture_with_host(host: MockRemoteHostClient) -> Fixture { // Pairing against the mock host // ============================================================================ +#[test] +fn every_remote_environment_error_has_its_stable_code() { + let cases = vec![ + (RemoteEnvironmentError::NotConnected, "NOT_CONNECTED"), + ( + RemoteEnvironmentError::NotActiveEnvironment { + requested: "a".into(), + active: "b".into(), + }, + "REMOTE_FORBIDDEN", + ), + ( + RemoteEnvironmentError::UnknownEnvironment("a".into()), + "REMOTE_COMMAND_UNAVAILABLE", + ), + ( + RemoteEnvironmentError::EnvironmentNotUsable("a".into(), "pending_add"), + "REMOTE_COMMAND_UNAVAILABLE", + ), + (RemoteEnvironmentError::LocalEnvironment, "REMOTE_FORBIDDEN"), + ( + RemoteEnvironmentError::InvalidUrl("bad".into()), + "INVALID_PAIRING_URL", + ), + ( + RemoteEnvironmentError::VersionSkew { + host_min_client: 2, + client: 1, + }, + "REMOTE_VERSION_MISMATCH", + ), + ( + RemoteEnvironmentError::IdentityMismatch { + descriptor: "a".into(), + response: "b".into(), + }, + "HOST_IDENTITY_MISMATCH", + ), + ( + RemoteEnvironmentError::PairRejected("bad".into()), + "PAIRING_REJECTED", + ), + ( + RemoteEnvironmentError::Unreachable("offline".into()), + "REMOTE_UNREACHABLE", + ), + ( + RemoteEnvironmentError::Transport { + code: ralphx_remote_protocol::ErrorCode::RemoteRequestIdReused, + message: "duplicate".into(), + }, + "REMOTE_REQUEST_ID_REUSED", + ), + ( + RemoteEnvironmentError::InvalidFetchRequest("bad".into()), + "REMOTE_COMMAND_UNAVAILABLE", + ), + ( + RemoteEnvironmentError::MissingCredential("a".into()), + "REMOTE_UNAUTHORIZED", + ), + ( + RemoteEnvironmentError::Secret(SecretStoreError::Unavailable("locked".into())), + "SECRET_STORE_UNAVAILABLE", + ), + ( + RemoteEnvironmentError::Db(crate::error::AppError::Database("boom".into())), + "DATABASE_ERROR", + ), + ]; + + for (error, expected) in cases { + assert_eq!(error.code(), expected, "{error:?}"); + } +} + +#[tokio::test] +async fn database_read_failures_fail_closed_or_propagate_by_contract() { + let list_inner = Arc::new(MemoryRemoteEnvironmentRepository::new()); + let mut list_repo = FailingRemoteEnvironmentRepository::new(list_inner); + list_repo.fail_list = true; + let list_service = service_with_repo(Arc::new(list_repo)); + assert_eq!( + list_service.reconcile_on_startup().await, + RemoteEnvironmentReconcileReport::default() + ); + + let get_inner = Arc::new(MemoryRemoteEnvironmentRepository::new()); + let mut get_repo = FailingRemoteEnvironmentRepository::new(get_inner); + get_repo.fail_get = true; + let get_service = service_with_repo(Arc::new(get_repo)); + assert!(matches!( + get_service.set_active_environment("env-id").await, + Err(RemoteEnvironmentError::Db(_)) + )); + assert!(matches!( + get_service + .fetch("env-id", RemoteFetchCall::get(REMOTE_HEALTH_PATH)) + .await, + Err(RemoteEnvironmentError::Db(_)) + )); +} + +#[tokio::test] +async fn reconcile_defers_when_a_required_row_delete_fails() { + for status in [ + RemoteEnvironmentStatus::PendingAdd, + RemoteEnvironmentStatus::PendingDelete, + ] { + let inner = Arc::new(MemoryRemoteEnvironmentRepository::new()); + let env = inner + .upsert_paired(crate::domain::repositories::UpsertPairedEnvironment { + environment_id: format!("env-{status:?}"), + name: "Mac".to_string(), + url: HOST_URL.to_string(), + scopes: vec![Scope::UiRead], + protocol_version: PROTOCOL_VERSION, + }) + .await + .expect("seed"); + inner.set_status(&env.id, status).await.expect("status"); + let mut repo = FailingRemoteEnvironmentRepository::new(Arc::clone(&inner)); + repo.fail_delete = true; + let service = service_with_repo(Arc::new(repo)); + + let report = service.reconcile_on_startup().await; + + assert_eq!(report.deferred, vec![env.id.as_str().to_string()]); + assert!(inner.get(&env.id).await.expect("get").is_some()); + } +} + #[tokio::test] async fn pair_success_lands_an_active_row_with_the_token_in_the_secret_store() { let f = fixture(); @@ -306,10 +544,7 @@ async fn two_paired_environments(f: &Fixture) -> (String, String) { .pair("https://mini.tailnet.ts.net", "rxp_code2", "Mac mini") .await .expect("pair B"); - ( - env_a.id.as_str().to_string(), - env_b.id.as_str().to_string(), - ) + (env_a.id.as_str().to_string(), env_b.id.as_str().to_string()) } #[tokio::test] @@ -342,7 +577,8 @@ async fn invoke_for_the_active_environment_dispatches_with_the_stored_bearer() { .set_active_environment(&a) .await .expect("activating A should succeed"); - f.host.script_invoke(200, r#"{"ok":true,"result":{"status":"ok"}}"#); + f.host + .script_invoke(200, r#"{"ok":true,"result":{"status":"ok"}}"#); let outcome = f .service @@ -405,7 +641,10 @@ async fn a_host_command_error_is_not_a_transport_error() { async fn a_bare_result_body_is_read_as_success() { let f = fixture(); let (a, _b) = two_paired_environments(&f).await; - f.service.set_active_environment(&a).await.expect("activate"); + f.service + .set_active_environment(&a) + .await + .expect("activate"); f.host.script_invoke(200, r#"[{"id":"task-1"}]"#); let outcome = f @@ -427,11 +666,19 @@ async fn a_bare_result_body_is_read_as_success() { #[tokio::test] async fn host_error_bodies_map_to_their_taxonomy_codes() { let cases: &[(u16, &str, &str)] = &[ - (404, "REMOTE_COMMAND_UNAVAILABLE", "REMOTE_COMMAND_UNAVAILABLE"), + ( + 404, + "REMOTE_COMMAND_UNAVAILABLE", + "REMOTE_COMMAND_UNAVAILABLE", + ), (403, "REMOTE_FORBIDDEN", "REMOTE_FORBIDDEN"), (401, "REMOTE_UNAUTHORIZED", "REMOTE_UNAUTHORIZED"), (426, "REMOTE_VERSION_MISMATCH", "REMOTE_VERSION_MISMATCH"), - (409, "REMOTE_REQUEST_IN_PROGRESS", "REMOTE_REQUEST_IN_PROGRESS"), + ( + 409, + "REMOTE_REQUEST_IN_PROGRESS", + "REMOTE_REQUEST_IN_PROGRESS", + ), (409, "REMOTE_REQUEST_ID_REUSED", "REMOTE_REQUEST_ID_REUSED"), (504, "REMOTE_TIMEOUT_UNKNOWN", "REMOTE_TIMEOUT_UNKNOWN"), (502, "REMOTE_UNREACHABLE", "REMOTE_UNREACHABLE"), @@ -439,7 +686,10 @@ async fn host_error_bodies_map_to_their_taxonomy_codes() { for (status, body_code, expected) in cases { let f = fixture(); let (a, _b) = two_paired_environments(&f).await; - f.service.set_active_environment(&a).await.expect("activate"); + f.service + .set_active_environment(&a) + .await + .expect("activate"); f.host.script_invoke( *status, format!(r#"{{"code":"{body_code}","message":"nope"}}"#), @@ -450,7 +700,11 @@ async fn host_error_bodies_map_to_their_taxonomy_codes() { .invoke(&a, "req-1", "list_tasks", serde_json::json!({})) .await .expect_err("a host refusal is a transport error"); - assert_eq!(error.code(), *expected, "status {status} / body {body_code}"); + assert_eq!( + error.code(), + *expected, + "status {status} / body {body_code}" + ); } } @@ -460,7 +714,10 @@ async fn host_error_bodies_map_to_their_taxonomy_codes() { async fn an_untyped_host_refusal_maps_by_status() { let f = fixture(); let (a, _b) = two_paired_environments(&f).await; - f.service.set_active_environment(&a).await.expect("activate"); + f.service + .set_active_environment(&a) + .await + .expect("activate"); f.host.script_invoke(403, "forbidden"); let error = f @@ -478,7 +735,10 @@ async fn an_untyped_host_refusal_maps_by_status() { async fn a_dispatch_timeout_is_an_unknown_outcome_not_unreachable() { let f = fixture(); let (a, _b) = two_paired_environments(&f).await; - f.service.set_active_environment(&a).await.expect("activate"); + f.service + .set_active_environment(&a) + .await + .expect("activate"); f.host .script_invoke_error(RemoteHostClientError::Timeout("no answer after 30s".into())); @@ -496,7 +756,10 @@ async fn a_dispatch_timeout_is_an_unknown_outcome_not_unreachable() { async fn a_missing_bearer_fails_closed_before_any_request() { let f = fixture(); let (a, _b) = two_paired_environments(&f).await; - f.service.set_active_environment(&a).await.expect("activate"); + f.service + .set_active_environment(&a) + .await + .expect("activate"); let env = f .repo .get(&crate::domain::entities::remote_environment::RemoteEnvironmentId::from_string(&a)) @@ -537,7 +800,10 @@ async fn unsafe_fetch_targets_are_refused_before_a_bearer_is_read() { for path in cases { let f = fixture(); let (a, _b) = two_paired_environments(&f).await; - f.service.set_active_environment(&a).await.expect("activate"); + f.service + .set_active_environment(&a) + .await + .expect("activate"); let error = f .service @@ -563,7 +829,10 @@ async fn unsafe_fetch_targets_are_refused_before_a_bearer_is_read() { async fn only_allowlisted_headers_are_forwarded() { let f = fixture(); let (a, _b) = two_paired_environments(&f).await; - f.service.set_active_environment(&a).await.expect("activate"); + f.service + .set_active_environment(&a) + .await + .expect("activate"); let error = f .service @@ -590,7 +859,10 @@ async fn only_allowlisted_headers_are_forwarded() { async fn a_non_success_fetch_status_is_returned_not_raised() { let f = fixture(); let (a, _b) = two_paired_environments(&f).await; - f.service.set_active_environment(&a).await.expect("activate"); + f.service + .set_active_environment(&a) + .await + .expect("activate"); f.host.script_fetch(422, r#"{"error":"bad input"}"#); let outcome = f @@ -609,7 +881,10 @@ async fn fetch_auth_refusals_lift_into_the_taxonomy() { for (status, expected) in [(401, "REMOTE_UNAUTHORIZED"), (403, "REMOTE_FORBIDDEN")] { let f = fixture(); let (a, _b) = two_paired_environments(&f).await; - f.service.set_active_environment(&a).await.expect("activate"); + f.service + .set_active_environment(&a) + .await + .expect("activate"); f.host.script_fetch(status, "no"); let error = f @@ -654,9 +929,7 @@ async fn descriptor_probe_for_a_background_environment_succeeds() { .service .fetch( &b, - RemoteFetchCall::get( - crate::infrastructure::remote_host_client::REMOTE_DESCRIPTOR_PATH, - ), + RemoteFetchCall::get(crate::infrastructure::remote_host_client::REMOTE_DESCRIPTOR_PATH), ) .await .expect("health probe for a background env must be allowed"); @@ -743,9 +1016,7 @@ async fn invoke_is_refused_while_the_active_environment_is_mid_re_pair() { .service .fetch( env.id.as_str(), - RemoteFetchCall::get( - crate::infrastructure::remote_host_client::REMOTE_DESCRIPTOR_PATH, - ), + RemoteFetchCall::get(crate::infrastructure::remote_host_client::REMOTE_DESCRIPTOR_PATH), ) .await; @@ -1106,10 +1377,7 @@ async fn reconciler_completes_a_pending_delete_with_revoke_then_secret_then_row( let report = f.service.reconcile_on_startup().await; - assert_eq!( - report.completed_removals, - vec![env.id.as_str().to_string()] - ); + assert_eq!(report.completed_removals, vec![env.id.as_str().to_string()]); assert!(f.host.recorded_calls().iter().any(|call| matches!( call, RecordedHostCall::Revoke { token, .. } if token == TOKEN @@ -1135,10 +1403,7 @@ async fn reconciler_deletes_a_pending_delete_row_whose_secret_is_already_gone() let report = f.service.reconcile_on_startup().await; - assert_eq!( - report.completed_removals, - vec![env.id.as_str().to_string()] - ); + assert_eq!(report.completed_removals, vec![env.id.as_str().to_string()]); assert!(f.repo.get(&env.id).await.expect("get").is_none()); // No orphaned valid bearer anywhere: nothing was in the secret store. } @@ -1195,3 +1460,399 @@ async fn connect_requires_a_registered_active_environment() { )); assert!(f.service.disconnect(env.id.as_str()).await.is_ok()); } + +#[test] +fn fetch_validation_rejects_every_unsafe_shape() { + for path in [ + "//host/path", + "/http://host/path", + "/a/../b", + "/two words", + "/x\ny", + ] { + assert!(matches!( + validate_remote_fetch_path(path), + Err(RemoteEnvironmentError::InvalidFetchRequest(_)) + )); + } + assert!(matches!( + validate_remote_fetch_method("TRACE"), + Err(RemoteEnvironmentError::InvalidFetchRequest(_)) + )); + for headers in [ + vec![("authorization".to_string(), "secret".to_string())], + vec![( + "content-type".to_string(), + "text/plain\ninjected".to_string(), + )], + ] { + assert!(matches!( + validate_remote_fetch_headers(&headers), + Err(RemoteEnvironmentError::InvalidFetchRequest(_)) + )); + } +} + +#[test] +fn transport_failures_map_to_stable_codes() { + let cases = [ + ( + RemoteHostClientError::Timeout("late".into()), + "REMOTE_TIMEOUT_UNKNOWN", + ), + ( + RemoteHostClientError::Unreachable("offline".into()), + "REMOTE_UNREACHABLE", + ), + ( + RemoteHostClientError::Rejected { + status: 422, + message: "reused".into(), + }, + "REMOTE_REQUEST_ID_REUSED", + ), + ( + RemoteHostClientError::InvalidResponse("bad".into()), + "REMOTE_VERSION_MISMATCH", + ), + ]; + for (error, expected) in cases { + assert_eq!(transport_error(error).code(), expected); + } +} + +#[test] +fn every_untyped_status_maps_to_the_expected_code() { + for (status, expected) in [ + (401, "REMOTE_UNAUTHORIZED"), + (403, "REMOTE_FORBIDDEN"), + (404, "REMOTE_COMMAND_UNAVAILABLE"), + (501, "REMOTE_COMMAND_UNAVAILABLE"), + (408, "REMOTE_TIMEOUT_UNKNOWN"), + (504, "REMOTE_TIMEOUT_UNKNOWN"), + (409, "REMOTE_REQUEST_IN_PROGRESS"), + (422, "REMOTE_REQUEST_ID_REUSED"), + (426, "REMOTE_VERSION_MISMATCH"), + (505, "REMOTE_VERSION_MISMATCH"), + (599, "REMOTE_UNREACHABLE"), + ] { + let error = transport_error(RemoteHostClientError::Rejected { + status, + message: "refused".into(), + }); + assert_eq!(error.code(), expected, "status {status}"); + } +} + +#[test] +fn invoke_envelopes_preserve_results_errors_and_null_defaults() { + for (body, expected) in [ + ( + r#"{"ok":false,"error":{"code":"bad"}}"#, + RemoteInvokeOutcome::CommandError { + error: serde_json::json!({"code": "bad"}), + }, + ), + ( + r#"{"ok":false}"#, + RemoteInvokeOutcome::CommandError { + error: serde_json::Value::Null, + }, + ), + ( + r#"{"ok":true,"result":{"id":1}}"#, + RemoteInvokeOutcome::Ok { + result: serde_json::json!({"id": 1}), + }, + ), + ( + r#"{"ok":true}"#, + RemoteInvokeOutcome::Ok { + result: serde_json::Value::Null, + }, + ), + ( + r#"{"plain":"body"}"#, + RemoteInvokeOutcome::Ok { + result: serde_json::json!({"plain": "body"}), + }, + ), + ] { + assert_eq!( + parse_invoke_response(RemoteHttpResponse { + status: 200, + body: body.to_string(), + }) + .expect("valid success response"), + expected + ); + } + + let error = parse_invoke_response(RemoteHttpResponse { + status: 200, + body: "not-json".to_string(), + }) + .expect_err("an invalid success body is a version mismatch"); + assert_eq!(error.code(), "REMOTE_VERSION_MISMATCH"); +} + +#[test] +fn pairing_url_and_pairing_wire_errors_cover_every_rejection() { + for url in ["not a url", "ftp://host/path", "http:/missing-host"] { + assert!( + validate_pairing_url(url).is_err(), + "{url:?} must not reach pairing" + ); + } + assert!(client_device_name().starts_with("RalphX Desktop ")); + + for (error, expected_code) in [ + ( + descriptor_error(RemoteHostClientError::Unreachable("offline".into())), + "REMOTE_UNREACHABLE", + ), + ( + descriptor_error(RemoteHostClientError::Timeout("late".into())), + "REMOTE_UNREACHABLE", + ), + ( + descriptor_error(RemoteHostClientError::Rejected { + status: 503, + message: "busy".into(), + }), + "REMOTE_UNREACHABLE", + ), + ( + descriptor_error(RemoteHostClientError::InvalidResponse("bad json".into())), + "REMOTE_UNREACHABLE", + ), + ( + pair_error(RemoteHostClientError::Unreachable("offline".into())), + "REMOTE_UNREACHABLE", + ), + ( + pair_error(RemoteHostClientError::Timeout("late".into())), + "REMOTE_UNREACHABLE", + ), + ( + pair_error(RemoteHostClientError::Rejected { + status: 409, + message: "used".into(), + }), + "PAIRING_REJECTED", + ), + ( + pair_error(RemoteHostClientError::InvalidResponse("bad json".into())), + "PAIRING_REJECTED", + ), + ] { + assert_eq!(error.code(), expected_code); + } +} + +#[tokio::test] +async fn active_environment_success_and_stub_guard_matrix() { + let f = fixture(); + assert!(matches!( + f.service.connect(LOCAL_ENVIRONMENT_ID).await, + Err(RemoteEnvironmentError::LocalEnvironment) + )); + assert!(matches!( + f.service.disconnect(LOCAL_ENVIRONMENT_ID).await, + Err(RemoteEnvironmentError::LocalEnvironment) + )); + assert!(matches!( + f.service.disconnect("missing").await, + Err(RemoteEnvironmentError::UnknownEnvironment(_)) + )); + + let pending = seed_husk(&f).await; + assert!(matches!( + f.service.connect(pending.id.as_str()).await, + Err(RemoteEnvironmentError::EnvironmentNotUsable(..)) + )); + + f.repo + .set_status(&pending.id, RemoteEnvironmentStatus::Active) + .await + .expect("activate row"); + f.service + .set_active_environment(pending.id.as_str()) + .await + .expect("active row may become authoritative"); + assert_eq!(f.service.active_environment_id().await, pending.id.as_str()); +} + +#[tokio::test] +async fn re_pair_succeeds_when_replaced_token_revoke_fails() { + let f = fixture(); + f.service + .pair(HOST_URL, "rxp_code", "Mac Studio") + .await + .expect("first pair"); + *f.host.pair_response.lock().expect("mock") = Ok(PairWireResponse { + device_token: "rxd_replacement".to_string(), + ..pair_response("env-1") + }); + *f.host.revoke_response.lock().expect("mock") = Err(RemoteHostClientError::Unreachable( + "old host offline".into(), + )); + + let repaired = f + .service + .pair(HOST_URL_DIRECT, "rxp_code2", "Mac Studio") + .await + .expect("best-effort cleanup cannot fail the completed re-pair"); + assert_eq!(repaired.status, RemoteEnvironmentStatus::Active); + assert_eq!( + f.secrets + .get_secret(&repaired.token_secret_ref) + .await + .expect("secret read") + .as_deref(), + Some("rxd_replacement") + ); +} + +#[tokio::test] +async fn reconciler_defers_when_activation_write_fails() { + let inner = Arc::new(MemoryRemoteEnvironmentRepository::new()); + let env = inner + .upsert_paired(crate::domain::repositories::UpsertPairedEnvironment { + environment_id: "env-pending".to_string(), + name: "Pending".to_string(), + url: HOST_URL.to_string(), + scopes: vec![Scope::UiRead], + protocol_version: PROTOCOL_VERSION, + }) + .await + .expect("seed"); + let secrets = Arc::new(MemorySecretStore::new()); + secrets + .put_secret(&env.token_secret_ref, TOKEN) + .await + .expect("seed secret"); + let repo = Arc::new(FailingRemoteEnvironmentRepository { + fail_set_status: true, + ..FailingRemoteEnvironmentRepository::new(Arc::clone(&inner)) + }); + let service = RemoteEnvironmentService::new( + repo, + secrets, + Arc::new(MockRemoteHostClient::new( + descriptor("env-pending"), + pair_response("env-pending"), + )), + ); + + let report = service.reconcile_on_startup().await; + assert_eq!(report.deferred, vec![env.id.as_str().to_string()]); + assert_eq!( + inner + .get(&env.id) + .await + .expect("row") + .expect("exists") + .status, + RemoteEnvironmentStatus::PendingAdd + ); +} + +#[tokio::test] +async fn reconciler_pending_delete_defers_each_destructive_failure() { + let inner = Arc::new(MemoryRemoteEnvironmentRepository::new()); + let repo = Arc::new(FailingRemoteEnvironmentRepository { + fail_delete: true, + ..FailingRemoteEnvironmentRepository::new(Arc::clone(&inner)) + }); + let secrets = Arc::new(MemorySecretStore::new()); + let host = Arc::new(MockRemoteHostClient::new( + descriptor("env-delete"), + pair_response("env-delete"), + )); + let service = RemoteEnvironmentService::new( + Arc::clone(&repo) as _, + Arc::clone(&secrets) as _, + Arc::clone(&host) as _, + ); + let env = inner + .upsert_paired(crate::domain::repositories::UpsertPairedEnvironment { + environment_id: "env-delete".to_string(), + name: "Delete".to_string(), + url: HOST_URL.to_string(), + scopes: vec![Scope::UiRead], + protocol_version: PROTOCOL_VERSION, + }) + .await + .expect("seed"); + inner + .set_status(&env.id, RemoteEnvironmentStatus::PendingDelete) + .await + .expect("pending delete"); + secrets + .put_secret(&env.token_secret_ref, TOKEN) + .await + .expect("seed secret"); + *host.revoke_response.lock().expect("mock") = + Err(RemoteHostClientError::Unreachable("offline".into())); + + let report = service.reconcile_on_startup().await; + assert_eq!(report.deferred, vec![env.id.as_str().to_string()]); + assert!(inner.get(&env.id).await.expect("row").is_some()); + assert!( + secrets + .get_secret(&env.token_secret_ref) + .await + .expect("secret read") + .is_none(), + "row deletion was attempted only after the secret was deleted" + ); + + let secretless_retry = service.reconcile_on_startup().await; + assert_eq!(secretless_retry.deferred, vec![env.id.as_str().to_string()]); +} + +#[tokio::test] +async fn reconciler_keeps_pending_delete_row_when_secret_delete_fails() { + let repo = Arc::new(MemoryRemoteEnvironmentRepository::new()); + let inner_secrets = Arc::new(MemorySecretStore::new()); + let secrets = Arc::new(FailingDeleteSecretStore { + inner: Arc::clone(&inner_secrets), + fail_delete: StdMutex::new(true), + }); + let host = Arc::new(MockRemoteHostClient::new( + descriptor("env-delete"), + pair_response("env-delete"), + )); + let service = RemoteEnvironmentService::new( + Arc::clone(&repo) as _, + Arc::clone(&secrets) as _, + Arc::clone(&host) as _, + ); + let env = repo + .upsert_paired(crate::domain::repositories::UpsertPairedEnvironment { + environment_id: "env-delete".to_string(), + name: "Delete".to_string(), + url: HOST_URL.to_string(), + scopes: vec![Scope::UiRead], + protocol_version: PROTOCOL_VERSION, + }) + .await + .expect("seed"); + repo.set_status(&env.id, RemoteEnvironmentStatus::PendingDelete) + .await + .expect("pending delete"); + inner_secrets + .put_secret(&env.token_secret_ref, TOKEN) + .await + .expect("seed secret"); + + let report = service.reconcile_on_startup().await; + assert_eq!(report.deferred, vec![env.id.as_str().to_string()]); + assert!(repo.get(&env.id).await.expect("row").is_some()); + assert!(inner_secrets + .get_secret(&env.token_secret_ref) + .await + .expect("secret read") + .is_some()); +} diff --git a/src-tauri/src/infrastructure/memory/memory_remote_environment_repo_tests.rs b/src-tauri/src/infrastructure/memory/memory_remote_environment_repo_tests.rs new file mode 100644 index 0000000000..7d862c6a0b --- /dev/null +++ b/src-tauri/src/infrastructure/memory/memory_remote_environment_repo_tests.rs @@ -0,0 +1,126 @@ +use ralphx_remote_protocol::{Scope, PROTOCOL_VERSION}; + +use super::memory_remote_environment_repo::MemoryRemoteEnvironmentRepository; +use crate::domain::entities::remote_environment::{RemoteEnvironmentId, RemoteEnvironmentStatus}; +use crate::domain::repositories::{RemoteEnvironmentRepository, UpsertPairedEnvironment}; + +fn paired(environment_id: &str, url: &str) -> UpsertPairedEnvironment { + UpsertPairedEnvironment { + environment_id: environment_id.to_string(), + name: environment_id.to_string(), + url: url.to_string(), + scopes: vec![Scope::UiRead], + protocol_version: PROTOCOL_VERSION, + } +} + +#[tokio::test] +async fn default_repository_is_empty_and_working() { + let repo = MemoryRemoteEnvironmentRepository::default(); + + assert!(repo.list().await.expect("list").is_empty()); + let inserted = repo + .upsert_paired(paired("env-default", "https://default.test")) + .await + .expect("insert"); + assert_eq!(repo.get(&inserted.id).await.expect("get"), Some(inserted)); +} + +#[tokio::test] +async fn environment_identity_lookup_covers_found_and_missing_rows() { + let repo = MemoryRemoteEnvironmentRepository::new(); + let inserted = repo + .upsert_paired(paired("env-lookup", "https://lookup.test")) + .await + .expect("insert"); + + assert_eq!( + repo.get_by_environment_id("env-lookup") + .await + .expect("lookup"), + Some(inserted) + ); + assert!(repo + .get_by_environment_id("missing") + .await + .expect("missing lookup") + .is_none()); +} + +#[tokio::test] +async fn list_orders_distinct_rows_by_created_at_then_id() { + let repo = MemoryRemoteEnvironmentRepository::new(); + repo.upsert_paired(paired("env-b", "https://b.test")) + .await + .expect("insert b"); + repo.upsert_paired(paired("env-a", "https://a.test")) + .await + .expect("insert a"); + + let rows = repo.list().await.expect("list"); + assert_eq!(rows.len(), 2); + assert!(rows.windows(2).all(|pair| { + (pair[0].created_at.as_str(), pair[0].id.as_str()) + <= (pair[1].created_at.as_str(), pair[1].id.as_str()) + })); +} + +#[tokio::test] +async fn touch_updates_an_existing_row_and_ignores_a_missing_row() { + let repo = MemoryRemoteEnvironmentRepository::new(); + let inserted = repo + .upsert_paired(paired("env-touch", "https://touch.test")) + .await + .expect("insert"); + let timestamp = "2026-07-28T10:11:12+00:00"; + + repo.touch_last_connected(&inserted.id, timestamp) + .await + .expect("touch existing"); + assert_eq!( + repo.get(&inserted.id) + .await + .expect("get") + .expect("row") + .last_connected_at + .as_deref(), + Some(timestamp) + ); + + let missing = RemoteEnvironmentId::from_string("missing"); + repo.touch_last_connected(&missing, timestamp) + .await + .expect("touch missing is idempotent"); + assert!(repo.get(&missing).await.expect("get missing").is_none()); +} + +#[tokio::test] +async fn re_pairing_the_base_url_does_not_add_a_candidate() { + let repo = MemoryRemoteEnvironmentRepository::new(); + let first = repo + .upsert_paired(paired("env-same", "https://same.test")) + .await + .expect("first insert"); + let second = repo + .upsert_paired(paired("env-same", "https://same.test")) + .await + .expect("second insert"); + + assert_eq!(second.id, first.id); + assert!(second.candidate_urls.is_empty()); + assert_eq!(second.status, RemoteEnvironmentStatus::PendingAdd); +} + +#[tokio::test] +async fn missing_row_operations_keep_their_documented_semantics() { + let repo = MemoryRemoteEnvironmentRepository::new(); + let missing = RemoteEnvironmentId::from_string("missing"); + + assert!(repo.get(&missing).await.expect("get").is_none()); + assert!(repo + .set_status(&missing, RemoteEnvironmentStatus::Active) + .await + .is_err()); + repo.delete(&missing).await.expect("delete is idempotent"); + assert!(repo.list().await.expect("list").is_empty()); +} diff --git a/src-tauri/src/infrastructure/memory/mod.rs b/src-tauri/src/infrastructure/memory/mod.rs index 543fbc620e..b6111292b3 100644 --- a/src-tauri/src/infrastructure/memory/mod.rs +++ b/src-tauri/src/infrastructure/memory/mod.rs @@ -67,6 +67,8 @@ pub mod memory_proposal_dependency_repo; pub mod memory_question_repo; pub mod memory_queued_message_repo; pub mod memory_remote_environment_repo; +#[cfg(test)] +mod memory_remote_environment_repo_tests; pub mod memory_review_issue_repo; pub mod memory_review_repo; pub mod memory_review_settings_repo; diff --git a/src-tauri/src/infrastructure/remote_host_client.rs b/src-tauri/src/infrastructure/remote_host_client.rs index 52563825b7..f4d918ac66 100644 --- a/src-tauri/src/infrastructure/remote_host_client.rs +++ b/src-tauri/src/infrastructure/remote_host_client.rs @@ -697,6 +697,94 @@ impl RemoteHostClient for MockRemoteHostClient { #[cfg(test)] mod tests { use super::*; + use std::sync::Arc; + + use axum::body::to_bytes; + use axum::extract::{Request as AxumRequest, State}; + use axum::routing::any; + use axum::Router; + + #[derive(Clone)] + struct CannedResponse { + status: StatusCode, + body: &'static str, + seen: Arc>>, + } + + #[derive(Debug)] + struct SeenRequest { + method: Method, + path: String, + authorization: Option, + custom_header: Option, + body: String, + } + + async fn canned_handler( + State(state): State, + request: AxumRequest, + ) -> (StatusCode, String) { + let method = request.method().clone(); + let path = request.uri().path().to_string(); + let authorization = request + .headers() + .get("authorization") + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + let custom_header = request + .headers() + .get("x-test-header") + .and_then(|value| value.to_str().ok()) + .map(str::to_string); + let body = to_bytes(request.into_body(), 64 * 1024) + .await + .expect("request body") + .to_vec(); + state.seen.lock().expect("seen requests").push(SeenRequest { + method, + path, + authorization, + custom_header, + body: String::from_utf8(body).expect("utf8 request body"), + }); + (state.status, state.body.to_string()) + } + + async fn spawn_canned_server( + status: StatusCode, + body: &'static str, + ) -> ( + String, + Arc>>, + tokio::task::JoinHandle<()>, + ) { + let seen = Arc::new(Mutex::new(Vec::new())); + let state = CannedResponse { + status, + body, + seen: Arc::clone(&seen), + }; + let app = Router::new() + .fallback(any(canned_handler)) + .with_state(state); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind server"); + let address = listener.local_addr().expect("server address"); + let task = tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve canned host"); + }); + (format!("http://{address}"), seen, task) + } + + fn pair_wire_request() -> PairWireRequest { + PairWireRequest { + pairing_code: "rxp_code".to_string(), + device_name: "Desktop".to_string(), + client_version: "1.0.0".to_string(), + requested_scopes: vec![Scope::UiRead], + } + } // C-11: parity tests use REAL serialization shapes — explicit camelCase keys, // protocol scope strings — not mock-convenient shapes. @@ -731,8 +819,7 @@ mod tests { "environmentId": "env-1", "protocolVersion": 1 }"#; - let response: PairWireResponse = - serde_json::from_str(raw).expect("response should parse"); + let response: PairWireResponse = serde_json::from_str(raw).expect("response should parse"); assert_eq!(response.device_token, "rxd_live_secret"); assert_eq!(response.device_id, "device-1"); assert_eq!(response.scopes, vec![Scope::UiRead, Scope::UiOperate]); @@ -781,4 +868,223 @@ mod tests { "http://100.101.102.103:3849/.well-known/ralphx/environment" ); } + + #[tokio::test] + async fn unavailable_client_returns_its_reason_from_every_surface() { + let client = UnavailableRemoteHostClient::new("TLS roots missing"); + let invoke = InvokeWireRequest { + request_id: "request-1".to_string(), + cmd: "health_check".to_string(), + args: serde_json::json!({}), + }; + let fetch = RemoteFetchRequest { + path: "/api/tasks".to_string(), + method: "GET".to_string(), + headers: vec![], + body: None, + }; + + let errors = [ + client.fetch_descriptor("http://host").await.unwrap_err(), + client + .pair("http://host", &pair_wire_request()) + .await + .unwrap_err(), + client + .validate_token("http://host", "token") + .await + .unwrap_err(), + client + .revoke_token("http://host", "token") + .await + .unwrap_err(), + client + .invoke("http://host", "token", &invoke) + .await + .unwrap_err(), + client + .fetch("http://host", "token", &fetch) + .await + .unwrap_err(), + ]; + assert!(errors.into_iter().all(|error| matches!( + error, + RemoteHostClientError::Unreachable(reason) if reason == "TLS roots missing" + ))); + } + + #[tokio::test] + async fn hyper_client_parses_descriptor_and_pair_successes() { + let descriptor_json = r#"{"environmentId":"env-1","appVersion":"1.0.0","protocolVersion":1,"minClientProtocol":1,"platform":"macos"}"#; + let (base_url, seen, task) = spawn_canned_server(StatusCode::OK, descriptor_json).await; + let client = HyperRemoteHostClient::new().expect("client"); + let descriptor = client + .fetch_descriptor(&base_url) + .await + .expect("descriptor"); + assert_eq!(descriptor.environment_id, "env-1"); + assert_eq!(seen.lock().expect("seen")[0].path, REMOTE_DESCRIPTOR_PATH); + task.abort(); + + let pair_json = r#"{"deviceToken":"token","deviceId":"device-1","scopes":["ui:read"],"environmentId":"env-1","protocolVersion":1}"#; + let (base_url, seen, task) = spawn_canned_server(StatusCode::OK, pair_json).await; + let response = client + .pair(&base_url, &pair_wire_request()) + .await + .expect("pair"); + assert_eq!(response.device_token, "token"); + let seen = seen.lock().expect("seen"); + assert_eq!(seen[0].method, Method::POST); + assert_eq!(seen[0].path, REMOTE_PAIR_PATH); + assert!(seen[0].body.contains("pairingCode")); + task.abort(); + } + + #[tokio::test] + async fn hyper_client_classifies_rejections_and_invalid_json() { + let client = HyperRemoteHostClient::new().expect("client"); + let (base_url, _, task) = + spawn_canned_server(StatusCode::BAD_REQUEST, "bad pairing code").await; + assert!(matches!( + client.pair(&base_url, &pair_wire_request()).await, + Err(RemoteHostClientError::Rejected { status: 400, message }) + if message == "bad pairing code" + )); + task.abort(); + + let (base_url, _, task) = spawn_canned_server(StatusCode::OK, "not-json").await; + assert!(matches!( + client.fetch_descriptor(&base_url).await, + Err(RemoteHostClientError::InvalidResponse(_)) + )); + assert!(matches!( + client.pair(&base_url, &pair_wire_request()).await, + Err(RemoteHostClientError::InvalidResponse(_)) + )); + task.abort(); + } + + #[tokio::test] + async fn hyper_client_validates_and_revokes_with_bearer_status_semantics() { + let client = HyperRemoteHostClient::new().expect("client"); + for (status, expected) in [ + (StatusCode::OK, true), + (StatusCode::UNAUTHORIZED, false), + (StatusCode::FORBIDDEN, false), + ] { + let (base_url, seen, task) = spawn_canned_server(status, "session").await; + assert_eq!( + client + .validate_token(&base_url, "secret") + .await + .expect("classified validation"), + expected + ); + assert_eq!( + seen.lock().expect("seen")[0].authorization.as_deref(), + Some("Bearer secret") + ); + task.abort(); + } + let (base_url, _, task) = + spawn_canned_server(StatusCode::INTERNAL_SERVER_ERROR, "broken").await; + assert!(matches!( + client.validate_token(&base_url, "secret").await, + Err(RemoteHostClientError::Rejected { status: 500, .. }) + )); + task.abort(); + + for status in [ + StatusCode::NO_CONTENT, + StatusCode::UNAUTHORIZED, + StatusCode::FORBIDDEN, + ] { + let (base_url, _, task) = spawn_canned_server(status, "").await; + client + .revoke_token(&base_url, "secret") + .await + .expect("completed revoke"); + task.abort(); + } + let (base_url, _, task) = spawn_canned_server(StatusCode::NOT_FOUND, "missing route").await; + assert!(matches!( + client.revoke_token(&base_url, "secret").await, + Err(RemoteHostClientError::Rejected { status: 404, .. }) + )); + task.abort(); + } + + #[tokio::test] + async fn invoke_and_fetch_preserve_non_success_status_and_request_shape() { + let client = HyperRemoteHostClient::new().expect("client"); + let (base_url, seen, task) = spawn_canned_server(StatusCode::FORBIDDEN, "denied").await; + let invoke = InvokeWireRequest { + request_id: "request-1".to_string(), + cmd: "list_tasks".to_string(), + args: serde_json::json!({"projectId": "p-1"}), + }; + let response = client + .invoke(&base_url, "secret", &invoke) + .await + .expect("invoke response"); + assert_eq!( + response, + RemoteHttpResponse { + status: 403, + body: "denied".to_string() + } + ); + assert_eq!( + seen.lock().expect("seen")[0].authorization.as_deref(), + Some("Bearer secret") + ); + task.abort(); + + let (base_url, seen, task) = + spawn_canned_server(StatusCode::INTERNAL_SERVER_ERROR, "failed").await; + let request = RemoteFetchRequest { + path: "/api/tasks/task-1".to_string(), + method: "PUT".to_string(), + headers: vec![("x-test-header".to_string(), "custom".to_string())], + body: Some("payload".to_string()), + }; + let response = client + .fetch(&base_url, "secret", &request) + .await + .expect("fetch response"); + assert_eq!(response.status, 500); + assert_eq!(response.body, "failed"); + let seen = seen.lock().expect("seen"); + assert_eq!(seen[0].method, Method::PUT); + assert_eq!(seen[0].path, "/api/tasks/task-1"); + assert_eq!(seen[0].authorization.as_deref(), Some("Bearer secret")); + assert_eq!(seen[0].custom_header.as_deref(), Some("custom")); + assert_eq!(seen[0].body, "payload"); + task.abort(); + } + + #[tokio::test] + async fn hyper_client_maps_invalid_method_and_refused_connection() { + let client = HyperRemoteHostClient::new().expect("client"); + let request = RemoteFetchRequest { + path: "/api/tasks".to_string(), + method: "bad method".to_string(), + headers: vec![], + body: None, + }; + assert!(matches!( + client.fetch("http://127.0.0.1:1", "secret", &request).await, + Err(RemoteHostClientError::InvalidResponse(_)) + )); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind"); + let address = listener.local_addr().expect("address"); + drop(listener); + assert!(matches!( + client.fetch_descriptor(&format!("http://{address}")).await, + Err(RemoteHostClientError::Unreachable(_)) + )); + } } diff --git a/src-tauri/src/infrastructure/tailscale_tests.rs b/src-tauri/src/infrastructure/tailscale_tests.rs index 2f81c10f25..0451b1e8ab 100644 --- a/src-tauri/src/infrastructure/tailscale_tests.rs +++ b/src-tauri/src/infrastructure/tailscale_tests.rs @@ -1,13 +1,18 @@ +use std::ffi::OsStr; use std::net::{IpAddr, Ipv4Addr}; use std::sync::{Arc, Mutex}; use async_trait::async_trait; use super::tailscale::{ - parse_status, serve_acquire_args, serve_release_args, TailscaleCommandRunner, + parse_status, probe_magicdns_reachability, serve_acquire_args, serve_release_args, + RealTailscaleCommandRunner, TailscaleCommandRunner, TailscaleSelfAddressProvider, TailscaleServeError, }; -use crate::remote_server::settings::{is_tailnet_cgnat_ipv4, TailnetProviderError}; +use crate::infrastructure::tool_paths::TEST_ENV_MUTEX; +use crate::remote_server::settings::{ + is_tailnet_cgnat_ipv4, TailnetProviderError, TailnetSelfAddressProvider, +}; const RUNNING_STATUS: &str = r#"{ "Version": "1.66.1", @@ -58,6 +63,70 @@ const LOGGED_OUT_STATUS_WITH_NULL_IPS: &str = r#"{ "CurrentTailnet": null }"#; +struct EnvGuard { + key: &'static str, + original: Option, +} + +impl EnvGuard { + fn set_os(key: &'static str, value: impl AsRef) -> Self { + let original = std::env::var_os(key); + std::env::set_var(key, value); + Self { key, original } + } +} + +impl Drop for EnvGuard { + fn drop(&mut self) { + match &self.original { + Some(value) => std::env::set_var(self.key, value), + None => std::env::remove_var(self.key), + } + } +} + +fn install_fake_tailscale(script: &str) -> (tempfile::TempDir, EnvGuard) { + let temp_dir = tempfile::tempdir().expect("temp dir"); + let binary = temp_dir.path().join("tailscale"); + std::fs::write(&binary, script).expect("write fake tailscale"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = std::fs::metadata(&binary) + .expect("fake tailscale metadata") + .permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&binary, permissions).expect("mark fake tailscale executable"); + } + let path = EnvGuard::set_os("PATH", temp_dir.path()); + (temp_dir, path) +} + +const STATUS_RUNNING_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/tailscale/status-running.sh" +)); +const STATUS_DAEMON_DOWN_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/tailscale/status-daemon-down.sh" +)); +const SERVE_OK_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/tailscale/serve-ok.sh" +)); +const SERVE_FAIL_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/tailscale/serve-fail.sh" +)); +const SERVE_FAIL_LONG_STDERR_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/tailscale/serve-fail-long-stderr.sh" +)); +const LAUNCH_FAIL_SCRIPT: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/tailscale/launch-fail.sh" +)); + #[derive(Clone, Default)] struct RecordingTailscaleCommandRunner { calls: Arc>>>, @@ -174,3 +243,140 @@ fn cgnat_validation_covers_both_boundaries_and_nearby_non_tailnet_ranges() { ); } } + +#[tokio::test] +async fn real_runner_reads_status_stdout_from_the_cli() { + let _lock = TEST_ENV_MUTEX.lock().expect("env mutex"); + let (_temp, _path) = install_fake_tailscale(STATUS_RUNNING_SCRIPT); + + let stdout = RealTailscaleCommandRunner + .run_status() + .await + .expect("status succeeds"); + + assert!(stdout.contains(r#""BackendState":"Running""#)); + assert_eq!( + parse_status(&stdout) + .expect("valid fixture") + .magicdns_name(), + Some("mac.tail.ts.net") + ); +} + +#[tokio::test] +async fn real_runner_reports_status_exit_and_stderr() { + let _lock = TEST_ENV_MUTEX.lock().expect("env mutex"); + let (_temp, _path) = install_fake_tailscale(STATUS_DAEMON_DOWN_SCRIPT); + + let error = RealTailscaleCommandRunner + .run_status() + .await + .expect_err("non-zero status fails"); + + assert!(matches!( + error, + TailnetProviderError::Unavailable(message) + if message.contains("exit status: 1") && message.contains("failed to connect") + )); +} + +#[tokio::test] +async fn concrete_self_address_provider_reads_and_parses_the_cli() { + let _lock = TEST_ENV_MUTEX.lock().expect("env mutex"); + let (_temp, _path) = install_fake_tailscale(STATUS_RUNNING_SCRIPT); + + let addresses = TailscaleSelfAddressProvider + .self_addresses() + .await + .expect("provider should parse the real runner output"); + + assert_eq!( + addresses, + vec![IpAddr::V4(Ipv4Addr::new(100, 101, 102, 103))] + ); +} + +#[tokio::test] +async fn concrete_self_address_provider_propagates_daemon_failure() { + let _lock = TEST_ENV_MUTEX.lock().expect("env mutex"); + let (_temp, _path) = install_fake_tailscale(STATUS_DAEMON_DOWN_SCRIPT); + + assert!(matches!( + TailscaleSelfAddressProvider.self_addresses().await, + Err(TailnetProviderError::Unavailable(message)) + if message.contains("failed to connect") + )); +} + +#[tokio::test] +async fn real_runner_executes_both_serve_success_paths() { + let _lock = TEST_ENV_MUTEX.lock().expect("env mutex"); + let (_temp, _path) = install_fake_tailscale(SERVE_OK_SCRIPT); + let runner = RealTailscaleCommandRunner; + + runner.run_serve_acquire(3849).await.expect("acquire"); + runner.run_serve_release().await.expect("release"); +} + +#[tokio::test] +async fn real_runner_preserves_serve_failure_stderr() { + let _lock = TEST_ENV_MUTEX.lock().expect("env mutex"); + let (_temp, _path) = install_fake_tailscale(SERVE_FAIL_SCRIPT); + let runner = RealTailscaleCommandRunner; + + for error in [ + runner + .run_serve_acquire(3849) + .await + .expect_err("acquire fails"), + runner.run_serve_release().await.expect_err("release fails"), + ] { + assert!(matches!( + error, + TailscaleServeError::Exit(message) + if message.contains("exit status: 1") + && message.contains("Serve is not enabled") + )); + } +} + +#[tokio::test] +async fn serve_failure_stderr_is_bounded_to_four_hundred_characters() { + let _lock = TEST_ENV_MUTEX.lock().expect("env mutex"); + let (_temp, _path) = install_fake_tailscale(SERVE_FAIL_LONG_STDERR_SCRIPT); + + let error = RealTailscaleCommandRunner + .run_serve_release() + .await + .expect_err("fixture exits unsuccessfully"); + + let TailscaleServeError::Exit(message) = error else { + panic!("expected an exit failure"); + }; + let (_, snippet) = message + .rsplit_once(": ") + .expect("non-empty stderr should be appended"); + assert_eq!(snippet.chars().count(), 400); + assert!(!message.contains("EXCLUDED_SUFFIX")); +} + +#[tokio::test] +async fn bad_interpreter_maps_launch_failures_for_status_and_serve() { + let _lock = TEST_ENV_MUTEX.lock().expect("env mutex"); + let (_temp, _path) = install_fake_tailscale(LAUNCH_FAIL_SCRIPT); + + assert!(matches!( + RealTailscaleCommandRunner.run_status().await, + Err(TailnetProviderError::Unavailable(message)) + if message.contains("tailscale status could not be launched") + )); + assert!(matches!( + RealTailscaleCommandRunner.run_serve_release().await, + Err(TailscaleServeError::Launch(message)) if !message.is_empty() + )); +} + +#[tokio::test] +async fn whitespace_magicdns_name_is_rejected_without_network_io() { + assert!(!probe_magicdns_reachability(" ").await); +} diff --git a/src-tauri/src/remote_server/auth_tests.rs b/src-tauri/src/remote_server/auth_tests.rs index b026f9db9b..6bb1c45ac2 100644 --- a/src-tauri/src/remote_server/auth_tests.rs +++ b/src-tauri/src/remote_server/auth_tests.rs @@ -15,10 +15,10 @@ use tower::ServiceExt; use super::auth::{ device_token_prefix, expiry_timestamp, generate_device_token, generate_pairing_code, - generate_ws_ticket, now_timestamp, scope_label, strip_trust_headers, RemoteAuthContext, - RemoteAuthRejection, PAIRING_CODE_TTL_SECS, RALPHX_HEADER_NAMESPACE, - REMOTE_DEVICE_TOKEN_PREFIX, REMOTE_PAIRING_CODE_PREFIX, REMOTE_WS_TICKET_PREFIX, - STRIPPED_TRUST_HEADERS, WS_TICKET_TTL_SECS, + generate_ws_ticket, now_timestamp, require_scope, scope_label, strip_trust_headers, + RemoteAuthContext, RemoteAuthRejection, RemoteIdentity, PAIRING_CODE_TTL_SECS, + RALPHX_HEADER_NAMESPACE, REMOTE_DEVICE_TOKEN_PREFIX, REMOTE_PAIRING_CODE_PREFIX, + REMOTE_WS_TICKET_PREFIX, STRIPPED_TRUST_HEADERS, WS_TICKET_TTL_SECS, }; use super::endpoints::RemoteRouterState; use super::session_registry::{RemoteSessionAdmission, RemoteSessionRegistry}; @@ -367,6 +367,40 @@ fn every_remote_scope_has_the_protocol_label() { assert_eq!(scope_label(Scope::UiElevated), "ui:elevated"); } +#[test] +fn too_many_concurrent_requests_sets_the_retry_after_header() { + let response = RemoteAuthRejection::TooManyConcurrentRequests.into_response(); + + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!(response.headers()[header::RETRY_AFTER], "1"); +} + +#[test] +fn require_scope_returns_the_exact_missing_scope() { + let identity = RemoteIdentity { + device_id: RemoteDeviceId::new(), + device_name: "read-only device".to_string(), + scopes: RemoteScopeSet::from_scopes([Scope::UiRead]), + }; + + assert_eq!( + require_scope(&identity, Scope::UiAgent), + Err(RemoteAuthRejection::InsufficientScope(Scope::UiAgent)) + ); + assert!(require_scope(&identity, Scope::UiRead).is_ok()); +} + +#[test] +fn host_local_context_uses_serve_exposure() { + let conn = rusqlite::Connection::open_in_memory().expect("in-memory database should open"); + run_migrations(&conn).expect("migrations should apply"); + + let context = + RemoteAuthContext::host_local(DbConnection::new(conn), RemoteSessionRegistry::new()); + + assert_eq!(context.exposure_mode, RemoteExposureMode::Serve); +} + #[tokio::test] async fn audit_store_failures_are_reported_to_authorizing_callers() { let mut context = in_memory_auth_context(); diff --git a/src-tauri/src/remote_server/capture_tests.rs b/src-tauri/src/remote_server/capture_tests.rs index 14dd1ec6fb..e45109cabb 100644 --- a/src-tauri/src/remote_server/capture_tests.rs +++ b/src-tauri/src/remote_server/capture_tests.rs @@ -119,6 +119,33 @@ fn full_durable_channel_drops_without_blocking_the_emit_thread() { assert!(receivers.durable.try_recv().is_err()); } +#[test] +fn full_durable_channel_still_drops_when_control_receiver_is_closed() { + let registrar = RecordingRegistrar::default(); + let (feed, mut receivers) = CaptureFeed::channels(1); + let (_replacement, closed_control) = tokio::sync::mpsc::unbounded_channel(); + let original_control = std::mem::replace(&mut receivers.control, closed_control); + drop(original_control); + drop(receivers.control_sender); + RemoteEventCapture::install_with_registrar(registrar.clone(), feed); + + registrar.emit("notification:created", r#"{"sequence":1}"#); + registrar.emit("notification:created", r#"{"sequence":2}"#); + + assert_eq!( + receivers + .durable + .try_recv() + .expect("first event remains queued") + .payload, + r#"{"sequence":1}"# + ); + assert!( + receivers.durable.try_recv().is_err(), + "overflow event is dropped" + ); +} + #[test] fn disconnected_capture_channels_drop_without_panicking() { let registrar = RecordingRegistrar::default(); diff --git a/src-tauri/tests/fixtures/tailscale/launch-fail.sh b/src-tauri/tests/fixtures/tailscale/launch-fail.sh new file mode 100644 index 0000000000..0894967978 --- /dev/null +++ b/src-tauri/tests/fixtures/tailscale/launch-fail.sh @@ -0,0 +1 @@ +#!/nonexistent-interpreter-for-ralphx-tests diff --git a/src-tauri/tests/fixtures/tailscale/serve-fail-long-stderr.sh b/src-tauri/tests/fixtures/tailscale/serve-fail-long-stderr.sh new file mode 100644 index 0000000000..fa120a5104 --- /dev/null +++ b/src-tauri/tests/fixtures/tailscale/serve-fail-long-stderr.sh @@ -0,0 +1,3 @@ +#!/bin/sh +printf '%s' '012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789EXCLUDED_SUFFIX' >&2 +exit 1 diff --git a/src-tauri/tests/fixtures/tailscale/serve-fail.sh b/src-tauri/tests/fixtures/tailscale/serve-fail.sh new file mode 100644 index 0000000000..7f69d07e7e --- /dev/null +++ b/src-tauri/tests/fixtures/tailscale/serve-fail.sh @@ -0,0 +1,3 @@ +#!/bin/sh +printf '%s' 'Serve is not enabled on your tailnet.' >&2 +exit 1 diff --git a/src-tauri/tests/fixtures/tailscale/serve-ok.sh b/src-tauri/tests/fixtures/tailscale/serve-ok.sh new file mode 100644 index 0000000000..039e4d0069 --- /dev/null +++ b/src-tauri/tests/fixtures/tailscale/serve-ok.sh @@ -0,0 +1,2 @@ +#!/bin/sh +exit 0 diff --git a/src-tauri/tests/fixtures/tailscale/status-daemon-down.sh b/src-tauri/tests/fixtures/tailscale/status-daemon-down.sh new file mode 100644 index 0000000000..b3e71b7e2c --- /dev/null +++ b/src-tauri/tests/fixtures/tailscale/status-daemon-down.sh @@ -0,0 +1,3 @@ +#!/bin/sh +printf '%s' 'failed to connect to local tailscaled; it does not appear to be running.' >&2 +exit 1 diff --git a/src-tauri/tests/fixtures/tailscale/status-running.sh b/src-tauri/tests/fixtures/tailscale/status-running.sh new file mode 100644 index 0000000000..af11c27109 --- /dev/null +++ b/src-tauri/tests/fixtures/tailscale/status-running.sh @@ -0,0 +1,3 @@ +#!/bin/sh +printf '%s' '{"Version":"1.66.1","BackendState":"Running","Self":{"DNSName":"mac.tail.ts.net.","TailscaleIPs":["100.101.102.103"]}}' +exit 0 From acebac0ba0aa85e651178754d7715d330c89401e Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:49:44 +0300 Subject: [PATCH 133/416] feat(remote): environment runtime composition root and stream-send classification --- frontend/package.json | 2 +- frontend/src/App.tsx | 3 + frontend/src/lib/queryClient.test.ts | 19 +- frontend/src/lib/queryClient.ts | 10 + .../lib/remote/environment-runtime.test.ts | 195 ++++++++ .../src/lib/remote/environment-runtime.ts | 422 ++++++++++++++++++ .../local-only-backend-events.generated.ts | 15 + .../src/lib/remote/local-only-commands.ts | 6 + frontend/src/lib/remote/network-event-bus.ts | 11 +- scripts/check-local-only-event-mirror.mjs | 54 +++ 10 files changed, 726 insertions(+), 11 deletions(-) create mode 100644 frontend/src/lib/remote/environment-runtime.test.ts create mode 100644 frontend/src/lib/remote/environment-runtime.ts create mode 100644 frontend/src/lib/remote/local-only-backend-events.generated.ts create mode 100644 scripts/check-local-only-event-mirror.mjs diff --git a/frontend/package.json b/frontend/package.json index bb956f8c5b..e29e81e6d9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,7 +16,7 @@ "test:check-warnings": "./scripts/check-vitest-warnings.sh", "test:visual": "playwright test tests/visual --workers=1", "test:coverage": "vitest run --coverage --testTimeout=15000 --retry=1", - "pretypecheck": "node ../scripts/check-raw-tauri-event-listen.mjs .. && node ../scripts/check-remote-transport-drift.mjs --self-test && node ../scripts/check-remote-transport-drift.mjs ..", + "pretypecheck": "node ../scripts/check-raw-tauri-event-listen.mjs .. && node ../scripts/check-remote-transport-drift.mjs --self-test && node ../scripts/check-remote-transport-drift.mjs .. && node ../scripts/check-local-only-event-mirror.mjs ..", "typecheck": "tsc --noEmit", "lint": "eslint src", "lint:fix": "eslint src --fix", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b2ffd287c7..110cc3a11f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -55,6 +55,7 @@ import { useHarnessProviders } from "@/hooks/useHarnessProviders"; import { useTicketingCacheEvents } from "@/hooks/useTicketingEvents"; import { useAutomationEvents } from "@/hooks/useAutomations"; import { cn } from "@/lib/utils"; +import { initializeEnvironmentRuntime } from "@/lib/remote/environment-runtime"; import { openTaskInAgents, navigateToIdeationSession, @@ -1250,6 +1251,8 @@ function AppContent({ backgroundSettled }: { backgroundSettled: boolean }) { function App({ startupStatus }: { startupStatus?: StartupStatus }) { const backgroundSettled = startupStatus?.backgroundComplete ?? true; + useEffect(() => initializeEnvironmentRuntime(), []); + return ( diff --git a/frontend/src/lib/queryClient.test.ts b/frontend/src/lib/queryClient.test.ts index 8f47473c66..f90bb03172 100644 --- a/frontend/src/lib/queryClient.test.ts +++ b/frontend/src/lib/queryClient.test.ts @@ -4,7 +4,11 @@ import { resetTransportEnvironmentId, setTransportEnvironmentId, } from "@/lib/remote/active-environment"; -import { getQueryClient, resetQueryClient } from "./queryClient"; +import { + getQueryClient, + removeQueryClient, + resetQueryClient, +} from "./queryClient"; afterEach(() => { resetQueryClient(); @@ -43,4 +47,17 @@ describe("getQueryClient", () => { expect(getQueryClient("env-a")).not.toBe(environmentA); expect(getQueryClient("env-b")).not.toBe(environmentB); }); + + it("clears and forgets only the removed environment client", () => { + const environmentA = getQueryClient("env-a"); + const environmentB = getQueryClient("env-b"); + environmentA.setQueryData(["key"], "a"); + environmentB.setQueryData(["key"], "b"); + + removeQueryClient("env-a"); + + expect(environmentA.getQueryData(["key"])).toBeUndefined(); + expect(getQueryClient("env-a")).not.toBe(environmentA); + expect(getQueryClient("env-b")).toBe(environmentB); + }); }); diff --git a/frontend/src/lib/queryClient.ts b/frontend/src/lib/queryClient.ts index ea7e2bcffa..9e08ba375f 100644 --- a/frontend/src/lib/queryClient.ts +++ b/frontend/src/lib/queryClient.ts @@ -77,6 +77,16 @@ export function getQueryClient( return queryClient; } +/** Clears and forgets an environment cache after its registry row is removed. */ +export function removeQueryClient(environmentId: string): void { + const queryClient = queryClients.get(environmentId); + if (queryClient === undefined) { + return; + } + queryClient.clear(); + queryClients.delete(environmentId); +} + /** * Reset the query client (for testing) */ diff --git a/frontend/src/lib/remote/environment-runtime.test.ts b/frontend/src/lib/remote/environment-runtime.test.ts new file mode 100644 index 0000000000..c8f527e853 --- /dev/null +++ b/frontend/src/lib/remote/environment-runtime.test.ts @@ -0,0 +1,195 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { RemoteEnvironmentSummary } from "@/api/remote-environments"; +import { createEventBus, type EventBus } from "@/lib/event-bus"; +import { resetQueryClient } from "@/lib/queryClient"; +import { + LOCAL_ENVIRONMENT_ID, + useEnvironmentStore, +} from "@/stores/environmentStore"; +import { useUiStore } from "@/stores/uiStore"; + +import type { RemoteStreamTarget } from "./stream-relay"; + +const { supervisors } = vi.hoisted(() => ({ + supervisors: [] as Array<{ + deps: { + environmentId: string; + refreshScopes: () => Promise; + applyScopes: (scopes: readonly string[]) => void; + beginStream: (outcome: { + environmentId: string; + hostEnvironmentId: string; + streamEpoch: string; + maxSeq: number; + heartbeatSecs: number; + protocolVersion: number; + }) => Promise; + }; + starts: number; + stops: number; + visibility: boolean[]; + networks: boolean[]; + }>, +})); + +vi.mock("./supervisor", async (importOriginal) => { + const actual = await importOriginal>(); + class FakeConnectionSupervisor { + readonly record; + + constructor(deps: (typeof supervisors)[number]["deps"]) { + this.record = { + deps, + starts: 0, + stops: 0, + visibility: [], + networks: [], + }; + supervisors.push(this.record); + } + + start(): void { + this.record.starts += 1; + } + stop(): void { + this.record.stops += 1; + } + streamLost(): void {} + noteFrameActivity(): void {} + visibilityChanged(hidden: boolean): void { + this.record.visibility.push(hidden); + } + networkChanged(online: boolean): void { + this.record.networks.push(online); + } + } + return { ...actual, ConnectionSupervisor: FakeConnectionSupervisor }; +}); + +vi.mock("./network-fetch", () => ({ + networkFetch: vi.fn(), +})); + +function summary(id: string): RemoteEnvironmentSummary { + return { + id, + environmentId: `host-${id}`, + name: id, + baseUrl: `https://${id}.example.test`, + candidateUrls: [], + scopes: ["ui:read"], + protocolVersion: 1, + status: "active", + createdAt: "2026-07-28T00:00:00Z", + lastConnectedAt: null, + }; +} + +function setFlag(enabled: boolean): void { + const flags = useUiStore.getState().featureFlags; + useUiStore.setState({ + featureFlags: { ...flags, remoteEnvironments: enabled }, + }); +} + +function resetStores(): void { + useEnvironmentStore.setState({ + activeEnvironmentId: LOCAL_ENVIRONMENT_ID, + environments: [ + { id: LOCAL_ENVIRONMENT_ID, name: "This Mac", kind: "local" }, + ], + connectionStates: { [LOCAL_ENVIRONMENT_ID]: "connected" }, + }); + setFlag(false); +} + +let teardown: (() => void) | null = null; + +beforeEach(() => { + supervisors.length = 0; + resetStores(); + resetQueryClient(); +}); + +afterEach(() => { + teardown?.(); + teardown = null; + resetStores(); + resetQueryClient(); +}); + +describe("environment runtime composition", () => { + it("is idempotent and teardown removes app adapters", async () => { + const addDocument = vi.spyOn(document, "addEventListener"); + const removeDocument = vi.spyOn(document, "removeEventListener"); + const addWindow = vi.spyOn(window, "addEventListener"); + const removeWindow = vi.spyOn(window, "removeEventListener"); + const { initializeEnvironmentRuntime } = await import("./environment-runtime"); + + teardown = initializeEnvironmentRuntime(); + expect(initializeEnvironmentRuntime()).toBe(teardown); + expect(addDocument).toHaveBeenCalledWith("visibilitychange", expect.any(Function)); + expect(addWindow).toHaveBeenCalledWith("online", expect.any(Function)); + expect(addWindow).toHaveBeenCalledWith("offline", expect.any(Function)); + + teardown(); + teardown = null; + expect(removeDocument).toHaveBeenCalledWith( + "visibilitychange", + expect.any(Function) + ); + expect(removeWindow).toHaveBeenCalledWith("online", expect.any(Function)); + expect(removeWindow).toHaveBeenCalledWith("offline", expect.any(Function)); + }); + + it("creates no supervisors while disabled and reconciles add/remove/disable", async () => { + const { initializeEnvironmentRuntime } = await import("./environment-runtime"); + useEnvironmentStore.getState().setEnvironments([summary("env-b"), summary("env-c")]); + teardown = initializeEnvironmentRuntime(); + expect(supervisors).toHaveLength(0); + + setFlag(true); + expect(supervisors).toHaveLength(2); + expect(supervisors.map((item) => item.deps.environmentId)).toEqual([ + "env-b", + "env-c", + ]); + expect(supervisors.every((item) => item.starts > 0)).toBe(true); + + useEnvironmentStore.getState().setEnvironments([summary("env-c")]); + expect(supervisors[0]?.stops).toBeGreaterThan(0); + + setFlag(false); + expect(supervisors[1]?.stops).toBeGreaterThan(0); + }); + + it("installs the active remote bus synchronously during a store switch", async () => { + const { initializeEnvironmentRuntime } = await import("./environment-runtime"); + useEnvironmentStore.getState().setEnvironments([summary("env-b")]); + setFlag(true); + teardown = initializeEnvironmentRuntime(); + + useEnvironmentStore.setState({ activeEnvironmentId: "env-b" }); + const bus = createEventBus("env-b"); + + expect((bus as EventBus & RemoteStreamTarget).environmentId()).toBe("env-b"); + expect(supervisors[0]?.starts).toBeGreaterThan(1); + }); + + it("uses pairing scopes in background without a session fetch and records them", async () => { + const { getConfirmedScopes, initializeEnvironmentRuntime } = await import( + "./environment-runtime" + ); + useEnvironmentStore.getState().setEnvironments([summary("env-b")]); + setFlag(true); + teardown = initializeEnvironmentRuntime(); + const runtime = supervisors[0]; + + const scopes = await runtime?.deps.refreshScopes(); + runtime?.deps.applyScopes(scopes ?? []); + + expect(scopes).toEqual(["ui:read"]); + expect(getConfirmedScopes("env-b")).toEqual(["ui:read"]); + }); +}); diff --git a/frontend/src/lib/remote/environment-runtime.ts b/frontend/src/lib/remote/environment-runtime.ts new file mode 100644 index 0000000000..20abf313f7 --- /dev/null +++ b/frontend/src/lib/remote/environment-runtime.ts @@ -0,0 +1,422 @@ +/** + * App-lifetime composition root for registered remote environments. + * + * The environment-store isolation subscriber is installed at module import time; this + * runtime subscribes later, at App initialization. Zustand therefore runs isolation + * before the synchronous activation below builds the bus React will consume. + */ + +import { invoke as primitiveInvoke } from "#tauri-core-primitive"; + +import { + createEventBus, + registerRemoteEventBusFactory, + resetRemoteEventBusFactory, + type EventBus, +} from "@/lib/event-bus"; +import { getQueryClient, removeQueryClient } from "@/lib/queryClient"; +import { + LOCAL_ENVIRONMENT_ID, + useEnvironmentStore, + type EnvironmentEntry, +} from "@/stores/environmentStore"; +import { useUiStore } from "@/stores/uiStore"; + +import { NetworkEventBus } from "./network-event-bus"; +import { networkFetch } from "./network-fetch"; +import { attachRemoteStreamRelay, type RemoteStreamTarget } from "./stream-relay"; +import { + type RemoteClientFrame, + type RemoteConnectOutcome, + type RemoteServerFrame, +} from "./stream-frames"; +import { + ConnectionSupervisor, + type EnvironmentDescriptorView, +} from "./supervisor"; + +const CLIENT_PROTOCOL_VERSION = 1; +const CLIENT_MIN_PROTOCOL = 1; +const DESCRIPTOR_PATH = "/.well-known/ralphx/environment"; +const SESSION_PATH = "/remote/v1/session"; +const HEALTH_PATH = "/health"; + +interface RuntimeEntry { + entry: EnvironmentEntry; + supervisor: ConnectionSupervisor; + socketLive: boolean; + bus: NetworkEventBus | null; + detachRelay: (() => void) | null; + relayKind: "full" | "health" | null; +} + +const confirmedScopes = new Map(); +let activeTeardown: (() => void) | null = null; + +export function getConfirmedScopes(environmentId: string): readonly string[] | null { + return confirmedScopes.get(environmentId) ?? null; +} + +function asRecord(value: unknown): Record | null { + return typeof value === "object" && value !== null + ? (value as Record) + : null; +} + +function parseDescriptor(value: unknown): EnvironmentDescriptorView { + const record = asRecord(value); + if ( + record === null || + typeof record.environmentId !== "string" || + typeof record.protocolVersion !== "number" || + typeof record.minClientProtocol !== "number" + ) { + throw new Error("Remote environment descriptor has an invalid shape."); + } + return { + environmentId: record.environmentId, + protocolVersion: record.protocolVersion, + minClientProtocol: record.minClientProtocol, + }; +} + +function parseScopes(value: unknown): readonly string[] { + const record = asRecord(value); + if ( + record === null || + !Array.isArray(record.scopes) || + !record.scopes.every((scope) => typeof scope === "string") + ) { + throw new Error("Remote session introspection has an invalid scope set."); + } + return record.scopes; +} + +function parseConnectOutcome(value: unknown): RemoteConnectOutcome { + const record = asRecord(value); + if ( + record === null || + typeof record.environmentId !== "string" || + typeof record.hostEnvironmentId !== "string" || + typeof record.streamEpoch !== "string" || + typeof record.maxSeq !== "number" || + typeof record.heartbeatSecs !== "number" || + typeof record.protocolVersion !== "number" + ) { + throw new Error("Remote connect outcome has an invalid shape."); + } + return { + environmentId: record.environmentId, + hostEnvironmentId: record.hostEnvironmentId, + streamEpoch: record.streamEpoch, + maxSeq: record.maxSeq, + heartbeatSecs: record.heartbeatSecs, + protocolVersion: record.protocolVersion, + }; +} + +async function sendFrame( + environmentId: string, + frame: RemoteClientFrame +): Promise { + await primitiveInvoke("remote_stream_send", { + input: { id: environmentId, frame }, + }); +} + +function detachedBus(environmentId: string, localBus: EventBus): NetworkEventBus { + return new NetworkEventBus({ + environmentId, + localBus, + sendFrame: async () => {}, + hydrate: async () => {}, + sweep: () => {}, + onRestartRequired: () => {}, + }); +} + +export function initializeEnvironmentRuntime(): () => void { + if (activeTeardown !== null) { + return activeTeardown; + } + + const localBus = createEventBus(LOCAL_ENVIRONMENT_ID); + const runtimes = new Map(); + let enabled = useUiStore.getState().featureFlags.remoteEnvironments; + let activeEnvironmentId = useEnvironmentStore.getState().activeEnvironmentId; + + const detachRelay = (runtime: RuntimeEntry): void => { + runtime.detachRelay?.(); + runtime.detachRelay = null; + runtime.relayKind = null; + }; + + const streamClosed = (runtime: RuntimeEntry): void => { + runtime.socketLive = false; + runtime.supervisor.streamLost(); + }; + + const attachHealthRelay = (runtime: RuntimeEntry): void => { + if (runtime.relayKind === "health") { + return; + } + detachRelay(runtime); + const target: RemoteStreamTarget = { + environmentId: () => runtime.entry.id, + handleFrame: (frame: RemoteServerFrame) => { + if (frame.type === "heartbeat") { + void sendFrame(runtime.entry.id, { + type: "heartbeatAck", + t: frame.t, + }); + } + // Full background projection is a v1 non-goal: every non-heartbeat frame drops. + }, + handleStreamClosed: () => { + runtime.socketLive = false; + }, + }; + runtime.detachRelay = attachRemoteStreamRelay({ + localBus, + target, + onFrameActivity: () => runtime.supervisor.noteFrameActivity(), + onStreamClosed: () => streamClosed(runtime), + }); + runtime.relayKind = "health"; + }; + + const buildActiveBus = (runtime: RuntimeEntry): void => { + detachRelay(runtime); + const environmentId = runtime.entry.id; + const bus = new NetworkEventBus({ + environmentId, + localBus, + sendFrame: (frame) => sendFrame(environmentId, frame), + hydrate: async () => { + await getQueryClient(environmentId).invalidateQueries(); + }, + sweep: () => { + void getQueryClient(environmentId).invalidateQueries(); + }, + onRestartRequired: () => runtime.supervisor.streamLost(), + }); + runtime.bus = bus; + runtime.detachRelay = attachRemoteStreamRelay({ + localBus, + target: bus, + onFrameActivity: () => runtime.supervisor.noteFrameActivity(), + onStreamClosed: () => streamClosed(runtime), + onUndecodableFrame: () => runtime.supervisor.streamLost(), + }); + runtime.relayKind = "full"; + }; + + const createRuntime = (entry: EnvironmentEntry): RuntimeEntry => { + let runtime: RuntimeEntry; + const environmentId = entry.id; + const supervisor = new ConnectionSupervisor({ + environmentId, + expectedHostEnvironmentId: entry.remote?.environmentId ?? environmentId, + clientProtocolVersion: CLIENT_PROTOCOL_VERSION, + clientMinProtocol: CLIENT_MIN_PROTOCOL, + fetchDescriptor: async () => { + const response = await networkFetch(environmentId, DESCRIPTOR_PATH); + if (!response.ok) { + throw new Error(`Descriptor request failed with HTTP ${response.status}.`); + } + return parseDescriptor(await response.json()); + }, + openStream: async () => { + const outcome = parseConnectOutcome( + await primitiveInvoke("remote_connect", { input: { id: environmentId } }) + ); + runtime.socketLive = true; + return outcome; + }, + releaseStream: async () => { + runtime.socketLive = false; + await primitiveInvoke("remote_disconnect", { input: { id: environmentId } }); + }, + probe: async () => { + const response = await networkFetch(environmentId, HEALTH_PATH); + if (!response.ok) { + throw new Error(`Health probe failed with HTTP ${response.status}.`); + } + }, + refreshScopes: async () => { + if (useEnvironmentStore.getState().activeEnvironmentId !== environmentId) { + // PR 3.3 owns background introspection. Until then, never make an + // active-env-bound session request: retain confirmed or pairing-time scopes. + return ( + confirmedScopes.get(environmentId) ?? + runtime.entry.remote?.scopes ?? + [] + ); + } + const response = await networkFetch(environmentId, SESSION_PATH); + if (!response.ok) { + throw new Error(`Session introspection failed with HTTP ${response.status}.`); + } + return parseScopes(await response.json()); + }, + applyScopes: (scopes) => { + confirmedScopes.set(environmentId, [...scopes]); + }, + beginStream: async (outcome) => { + if (useEnvironmentStore.getState().activeEnvironmentId !== environmentId) { + return; + } + await runtime.bus?.beginStream(outcome); + }, + hasLiveSocket: () => runtime.socketLive, + onStateChange: (state) => { + useEnvironmentStore.getState().setConnectionState(environmentId, state); + }, + }); + runtime = { + entry, + supervisor, + socketLive: false, + bus: null, + detachRelay: null, + relayKind: null, + }; + return runtime; + }; + + const activate = (environmentId: string): void => { + const previous = runtimes.get(activeEnvironmentId); + if (previous !== undefined && previous.entry.id !== environmentId) { + previous.bus = null; + attachHealthRelay(previous); + } + activeEnvironmentId = environmentId; + const runtime = runtimes.get(environmentId); + if (runtime === undefined) { + return; + } + buildActiveBus(runtime); + // A fresh supervisor attempt pairs the fresh bus with the next hello H barrier. + runtime.supervisor.stop(); + runtime.supervisor.start(); + }; + + const quiesce = (): void => { + for (const [environmentId, runtime] of runtimes) { + runtime.supervisor.stop(); + detachRelay(runtime); + runtime.bus = null; + confirmedScopes.delete(environmentId); + } + runtimes.clear(); + }; + + const reconcile = (): void => { + const state = useEnvironmentStore.getState(); + const remoteEntries = state.environments.filter( + (entry) => entry.kind === "remote" + ); + const wanted = new Set(remoteEntries.map((entry) => entry.id)); + + for (const [environmentId, runtime] of runtimes) { + if (!wanted.has(environmentId)) { + runtime.supervisor.stop(); + detachRelay(runtime); + runtimes.delete(environmentId); + confirmedScopes.delete(environmentId); + removeQueryClient(environmentId); + } + } + for (const entry of remoteEntries) { + const existing = runtimes.get(entry.id); + if (existing !== undefined) { + existing.entry = entry; + continue; + } + const runtime = createRuntime(entry); + runtimes.set(entry.id, runtime); + if (entry.id === state.activeEnvironmentId) { + buildActiveBus(runtime); + } else { + attachHealthRelay(runtime); + } + runtime.supervisor.start(); + } + }; + + registerRemoteEventBusFactory((environmentId, fallbackLocalBus) => { + const runtime = runtimes.get(environmentId); + return enabled && + environmentId === activeEnvironmentId && + runtime?.bus !== null && + runtime?.bus !== undefined + ? runtime.bus + : detachedBus(environmentId, fallbackLocalBus); + }); + + const unsubscribeUi = useUiStore.subscribe((state, previous) => { + const next = state.featureFlags.remoteEnvironments; + if (next === previous.featureFlags.remoteEnvironments) { + return; + } + enabled = next; + if (!enabled) { + quiesce(); + return; + } + reconcile(); + activate(useEnvironmentStore.getState().activeEnvironmentId); + }); + + const unsubscribeEnvironment = useEnvironmentStore.subscribe((state, previous) => { + if (!enabled) { + return; + } + if (state.environments !== previous.environments) { + reconcile(); + } + if (state.activeEnvironmentId !== previous.activeEnvironmentId) { + activate(state.activeEnvironmentId); + } + }); + + const visibilityChanged = (): void => { + for (const runtime of runtimes.values()) { + runtime.supervisor.visibilityChanged(document.hidden); + } + }; + const online = (): void => { + for (const runtime of runtimes.values()) { + runtime.supervisor.networkChanged(true); + } + }; + const offline = (): void => { + for (const runtime of runtimes.values()) { + runtime.supervisor.networkChanged(false); + } + }; + document.addEventListener("visibilitychange", visibilityChanged); + window.addEventListener("online", online); + window.addEventListener("offline", offline); + + if (enabled) { + reconcile(); + activate(activeEnvironmentId); + } + + const teardown = (): void => { + if (activeTeardown !== teardown) { + return; + } + unsubscribeUi(); + unsubscribeEnvironment(); + document.removeEventListener("visibilitychange", visibilityChanged); + window.removeEventListener("online", online); + window.removeEventListener("offline", offline); + quiesce(); + resetRemoteEventBusFactory(); + activeTeardown = null; + }; + activeTeardown = teardown; + return teardown; +} diff --git a/frontend/src/lib/remote/local-only-backend-events.generated.ts b/frontend/src/lib/remote/local-only-backend-events.generated.ts new file mode 100644 index 0000000000..57936f3785 --- /dev/null +++ b/frontend/src/lib/remote/local-only-backend-events.generated.ts @@ -0,0 +1,15 @@ +// GENERATED — do not edit; run node scripts/check-local-only-event-mirror.mjs --update + +export const LOCAL_ONLY_BACKEND_EVENTS = [ + "gh-auth:login_prompt", + "ralphx://check-for-updates", + "ralphx://show-release-notes", + "remote:session_closed", + "remote:session_connected", + "remote:stream_closed", + "remote:stream_frame", +] as const; + +export const LOCAL_ONLY_BACKEND_EVENT_NAMES: ReadonlySet = new Set( + LOCAL_ONLY_BACKEND_EVENTS +); diff --git a/frontend/src/lib/remote/local-only-commands.ts b/frontend/src/lib/remote/local-only-commands.ts index 77bc44bd85..999ec9086f 100644 --- a/frontend/src/lib/remote/local-only-commands.ts +++ b/frontend/src/lib/remote/local-only-commands.ts @@ -53,6 +53,12 @@ export const LOCAL_ONLY_COMMANDS: readonly LocalOnlyCommand[] = [ disposition: "run-locally", reason: "Closes this client's outbound connection to a host (§6.5).", }, + { + command: "remote_stream_send", + disposition: "run-locally", + reason: + "Writes a client control frame to THIS client's proxy socket; routing it remotely recurses.", + }, // --- This client's environment registry (§6.1/§6.4). --- { diff --git a/frontend/src/lib/remote/network-event-bus.ts b/frontend/src/lib/remote/network-event-bus.ts index e901a4a5bc..a8b141bb83 100644 --- a/frontend/src/lib/remote/network-event-bus.ts +++ b/frontend/src/lib/remote/network-event-bus.ts @@ -45,6 +45,7 @@ */ import type { EventBus, EventHandler, Unsubscribe } from "../event-bus"; +import { LOCAL_ONLY_BACKEND_EVENT_NAMES } from "./local-only-backend-events.generated"; import { type RemoteClientFrame, type RemoteConnectOutcome, @@ -67,15 +68,7 @@ import { * protocol crate's table, so a new Local-only backend name cannot be added host-side * without the client learning about it. */ -export const LOCAL_ONLY_BACKEND_EVENT_NAMES: ReadonlySet = new Set([ - "ralphx://check-for-updates", - "ralphx://show-release-notes", - "gh-auth:login_prompt", - "remote:session_connected", - "remote:session_closed", - "remote:stream_frame", - "remote:stream_closed", -]); +export { LOCAL_ONLY_BACKEND_EVENT_NAMES }; export function isLocalOnlyBackendEvent(name: string): boolean { return LOCAL_ONLY_BACKEND_EVENT_NAMES.has(name); diff --git a/scripts/check-local-only-event-mirror.mjs b/scripts/check-local-only-event-mirror.mjs new file mode 100644 index 0000000000..d478cf7dba --- /dev/null +++ b/scripts/check-local-only-event-mirror.mjs @@ -0,0 +1,54 @@ +#!/usr/bin/env node + +import fs from "node:fs"; +import path from "node:path"; + +const args = process.argv.slice(2); +const update = args.includes("--update"); +const positional = args.find((arg) => !arg.startsWith("--")); +const repoRoot = path.resolve(positional ?? process.cwd()); +const snapshotPath = path.join( + repoRoot, + "src-tauri/crates/ralphx-remote-protocol/tests/snapshots/event-classifications.json" +); +const outputPath = path.join( + repoRoot, + "frontend/src/lib/remote/local-only-backend-events.generated.ts" +); + +const classifications = JSON.parse(fs.readFileSync(snapshotPath, "utf8")); +if (!Array.isArray(classifications)) { + throw new Error(`Expected an array in ${snapshotPath}`); +} + +const names = classifications + .filter((row) => row.delivery === "localOnly" && row.origin === "backend") + .map((row) => row.name) + .sort(); +const quoted = names.map((name) => ` ${JSON.stringify(name)},`).join("\n"); +const expected = `// GENERATED — do not edit; run node scripts/check-local-only-event-mirror.mjs --update + +export const LOCAL_ONLY_BACKEND_EVENTS = [ +${quoted} +] as const; + +export const LOCAL_ONLY_BACKEND_EVENT_NAMES: ReadonlySet = new Set( + LOCAL_ONLY_BACKEND_EVENTS +); +`; + +if (update) { + fs.writeFileSync(outputPath, expected); + console.log(`Updated ${path.relative(repoRoot, outputPath)} (${names.length} events).`); + process.exit(0); +} + +const actual = fs.existsSync(outputPath) ? fs.readFileSync(outputPath, "utf8") : ""; +if (actual !== expected) { + console.error( + "Local-only backend event mirror is stale. Run: " + + "node scripts/check-local-only-event-mirror.mjs --update" + ); + process.exit(1); +} +console.log(`Local-only backend event mirror is current (${names.length} events).`); From c4a208683b5705cf06b33ced5e75bcb4bf2229c3 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 10:52:27 +0300 Subject: [PATCH 134/416] docs(remote): regenerate the remote-commands manifest after the 2.3 merge --- docs/generated/remote-commands.json | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/generated/remote-commands.json b/docs/generated/remote-commands.json index 798eb4cb6b..7ede9a9b5a 100644 --- a/docs/generated/remote-commands.json +++ b/docs/generated/remote-commands.json @@ -338,6 +338,13 @@ "id": "application/ready_task_scheduler.rs::application/ready_task_scheduler.rs:::::spawn_ready_task_scheduler_if_needed@57e1eb6d86c1770f", "kind": "tokio::spawn" }, + { + "authorityBearing": false, + "enclosingFunction": "application/remote_event_relay.rs::RemoteEventRelay::connect", + "file": "application/remote_event_relay.rs", + "id": "application/remote_event_relay.rs::application/remote_event_relay.rs::RemoteEventRelay::connect@ea05994e664c8c1f", + "kind": "tokio::spawn" + }, { "authorityBearing": true, "enclosingFunction": "application/server_boot.rs:::::start_server_boot", @@ -1318,6 +1325,16 @@ "reason": "remote environment authority", "registered": false }, + { + "capabilities": [ + "hostManagement" + ], + "class": "elevated", + "command": "remote_stream_send", + "module": "remote_environment_commands", + "reason": "remote environment authority", + "registered": false + }, { "capabilities": [ "hostManagement" From fe59e36818791bce0a5b3e37720f59955446a267 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:01:36 +0300 Subject: [PATCH 135/416] feat(remote): environment switcher in the app top bar --- .../src/components/layout/AppTopBar.test.tsx | 54 +++- frontend/src/components/layout/AppTopBar.tsx | 12 +- .../layout/EnvironmentSwitcher.test.tsx | 236 ++++++++++++++ .../components/layout/EnvironmentSwitcher.tsx | 290 ++++++++++++++++++ .../layout/environment-switcher-status.ts | 45 +++ .../ProjectSelector/ProjectDropdown.tsx | 16 +- .../ProjectSelector/ProjectSelector.tsx | 7 + .../integration/environment-switcher.spec.ts | 19 ++ .../components/environment-switcher.page.ts | 57 ++++ 9 files changed, 728 insertions(+), 8 deletions(-) create mode 100644 frontend/src/components/layout/EnvironmentSwitcher.test.tsx create mode 100644 frontend/src/components/layout/EnvironmentSwitcher.tsx create mode 100644 frontend/src/components/layout/environment-switcher-status.ts create mode 100644 frontend/tests/integration/environment-switcher.spec.ts create mode 100644 frontend/tests/pages/components/environment-switcher.page.ts diff --git a/frontend/src/components/layout/AppTopBar.test.tsx b/frontend/src/components/layout/AppTopBar.test.tsx index 6a8f014adc..ac3469f45b 100644 --- a/frontend/src/components/layout/AppTopBar.test.tsx +++ b/frontend/src/components/layout/AppTopBar.test.tsx @@ -1,4 +1,5 @@ import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ReactNode } from "react"; @@ -15,8 +16,38 @@ vi.mock("@/api/notifications", () => ({ // ProjectSelector and ThemeSelector are heavy children; stub them so the test // focuses on AppTopBar's breadcrumb + project-selector gating logic. vi.mock("@/components/projects/ProjectSelector", () => ({ - ProjectSelector: () => ( -
Project Selector
+ ProjectSelector: ({ + open, + onOpenChange, + }: { + open?: boolean; + onOpenChange?: (open: boolean) => void; + }) => ( + + ), +})); + +vi.mock("./EnvironmentSwitcher", () => ({ + EnvironmentSwitcher: ({ + open, + onOpenChange, + }: { + open?: boolean; + onOpenChange?: (open: boolean) => void; + }) => ( + ), })); @@ -164,6 +195,25 @@ describe("AppTopBar (ticketing, GitHub, and Granola views)", () => { expect(screen.getByTestId("project-selector-stub")).toBeInTheDocument(); }); + it("places environment immediately before project and keeps one chrome menu open", async () => { + renderTopBar({ + currentView: "ticketing", + showProjectSelector: true, + onNewProject: vi.fn(), + }); + const environment = screen.getByTestId("environment-switcher-stub"); + const projectSelector = screen.getByTestId("project-selector-stub"); + + expect( + environment.compareDocumentPosition(projectSelector) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + await userEvent.click(environment); + expect(environment).toHaveAttribute("aria-expanded", "true"); + await userEvent.click(projectSelector); + expect(environment).toHaveAttribute("aria-expanded", "false"); + expect(projectSelector).toHaveAttribute("aria-expanded", "true"); + }); + it("shows the project selector on the GitHub view when enabled", () => { renderTopBar({ currentView: "github", diff --git a/frontend/src/components/layout/AppTopBar.tsx b/frontend/src/components/layout/AppTopBar.tsx index 6d39ca2658..9ae6fb1cb5 100644 --- a/frontend/src/components/layout/AppTopBar.tsx +++ b/frontend/src/components/layout/AppTopBar.tsx @@ -17,6 +17,7 @@ import { useThemeStore, type FontScale } from "@/stores/themeStore"; import type { AppView } from "@/types/app-view"; import type { ChatConversation } from "@/types/chat-conversation"; +import { EnvironmentSwitcher } from "./EnvironmentSwitcher"; import { ThemeSelector } from "./ThemeSelector"; interface AppTopBarProps { @@ -459,7 +460,9 @@ export function AppTopBar({ !cachedAgentConversation, staleTime: 30 * 1000, }); - const [activeMenu, setActiveMenu] = useState<"theme" | "font" | null>(null); + const [activeMenu, setActiveMenu] = useState< + "environment" | "project" | "theme" | "font" | null + >(null); const agentConversation = currentView === "agents" && selectedAgentConversationId ? cachedAgentConversation ?? selectedAgentConversationSummary.data ?? null @@ -573,12 +576,19 @@ export function AppTopBar({ + setActiveMenu(open ? "environment" : null)} + /> + {shouldShowProjectSelector && onNewProject && ( setActiveMenu(open ? "project" : null)} /> )} diff --git a/frontend/src/components/layout/EnvironmentSwitcher.test.tsx b/frontend/src/components/layout/EnvironmentSwitcher.test.tsx new file mode 100644 index 0000000000..71dcbb27dc --- /dev/null +++ b/frontend/src/components/layout/EnvironmentSwitcher.test.tsx @@ -0,0 +1,236 @@ +import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { remoteEnvironmentsApi } from "@/api/remote-environments"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { + LOCAL_ENVIRONMENT_ID, + type EnvironmentConnectionState, + useEnvironmentStore, +} from "@/stores/environmentStore"; +import { useUiStore } from "@/stores/uiStore"; +import type { RemoteEnvironmentSummary } from "@/api/remote-environments"; + +import { EnvironmentSwitcher } from "./EnvironmentSwitcher"; +import { ENVIRONMENT_STATUS_DOT } from "./environment-switcher-status"; + +vi.mock("@/api/remote-environments", () => ({ + remoteEnvironmentsApi: { + setActiveEnvironment: vi.fn(), + }, +})); + +const ALL_STATES: EnvironmentConnectionState[] = [ + "idle", + "connecting", + "connected", + "backoff", + "offline", + "blocked", + "suspended", +]; + +function remote(id: string, name: string): RemoteEnvironmentSummary { + return { + id, + environmentId: `host-${id}`, + name, + baseUrl: `https://${id}.test`, + candidateUrls: [], + scopes: ["ui:read", "ui:operate"], + protocolVersion: 1, + status: "active", + createdAt: "2026-07-28T00:00:00Z", + lastConnectedAt: null, + }; +} + +function seed(states: EnvironmentConnectionState[] = ["connected"]): void { + const summaries = states.map((_, index) => remote(`env-${index}`, `Remote ${index}`)); + useEnvironmentStore.getState().setEnvironments(summaries); + states.forEach((state, index) => { + useEnvironmentStore.getState().setConnectionState(`env-${index}`, state); + }); +} + +function renderSwitcher(props: Partial[0]> = {}) { + return render( + + + , + ); +} + +async function openSwitcher(): Promise { + await userEvent.click(screen.getByRole("button", { name: "Switch environment" })); +} + +describe("EnvironmentSwitcher", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(remoteEnvironmentsApi.setActiveEnvironment).mockResolvedValue(null); + useUiStore.setState({ + featureFlags: { + ...useUiStore.getState().featureFlags, + remoteEnvironments: true, + }, + }); + useEnvironmentStore.setState({ + activeEnvironmentId: LOCAL_ENVIRONMENT_ID, + environments: [{ id: LOCAL_ENVIRONMENT_ID, name: "This Mac", kind: "local" }], + connectionStates: { [LOCAL_ENVIRONMENT_ID]: "connected" }, + }); + }); + + it("renders null when the flag is off or the registry contains only local", () => { + const { rerender } = renderSwitcher(); + expect(screen.queryByRole("button", { name: "Switch environment" })).not.toBeInTheDocument(); + + seed(); + useUiStore.setState({ + featureFlags: { + ...useUiStore.getState().featureFlags, + remoteEnvironments: false, + }, + }); + rerender( + + + , + ); + expect(screen.queryByRole("button", { name: "Switch environment" })).not.toBeInTheDocument(); + }); + + it("exports one typed dot description for all seven supervisor states and local", async () => { + seed(ALL_STATES); + renderSwitcher(); + await openSwitcher(); + + expect(Object.keys(ENVIRONMENT_STATUS_DOT)).toEqual(ALL_STATES); + expect(screen.getByTestId("environment-option-local").querySelector("[data-status]")).toHaveAttribute( + "data-status", + "connected", + ); + ALL_STATES.forEach((state, index) => { + const dot = screen + .getByTestId(`environment-option-env-${index}`) + .querySelector("[data-status]"); + expect(dot).toHaveAttribute("data-status", state); + expect(dot).toHaveTextContent(ENVIRONMENT_STATUS_DOT[state].glyph); + }); + }); + + it("marks the active row and exposes trigger and status tooltips", async () => { + seed(["backoff", "blocked"]); + renderSwitcher(); + + const trigger = screen.getByRole("button", { name: "Switch environment" }); + expect(trigger).toHaveTextContent("This Mac"); + await userEvent.hover(trigger); + expect(await screen.findByRole("tooltip")).toHaveTextContent("Switch environment"); + await userEvent.unhover(trigger); + + await openSwitcher(); + expect(screen.getByRole("option", { name: /This Mac/ })).toHaveAttribute( + "aria-selected", + "true", + ); + expect( + screen.getByRole("option", { name: /This Mac/ }).querySelector( + '[aria-label="Active environment"]', + ), + ).toBeInTheDocument(); + + const reconnecting = screen.getByRole("option", { name: /Remote 0/ }); + await userEvent.hover(reconnecting); + expect(await screen.findByRole("tooltip")).toHaveTextContent("Reconnecting…"); + }); + + it("updates the shell and closes synchronously before the switch promise settles", async () => { + seed(["connecting"]); + let resolveSwitch: (() => void) | undefined; + let deferredWorkStarted = false; + vi.mocked(remoteEnvironmentsApi.setActiveEnvironment).mockImplementation( + () => { + queueMicrotask(() => { + deferredWorkStarted = true; + }); + return new Promise((resolve) => { + resolveSwitch = () => resolve(null); + }); + }, + ); + renderSwitcher(); + await openSwitcher(); + + fireEvent.click(screen.getByRole("option", { name: /Remote 0/ })); + + expect(screen.getByRole("button", { name: "Switch environment" })).toHaveTextContent( + "Remote 0", + ); + expect(screen.queryByRole("listbox", { name: "Environments" })).not.toBeInTheDocument(); + expect(useEnvironmentStore.getState().activeEnvironmentId).toBe("env-0"); + expect(remoteEnvironmentsApi.setActiveEnvironment).toHaveBeenCalledWith("env-0"); + expect(deferredWorkStarted).toBe(false); + + await act(async () => { + resolveSwitch?.(); + }); + expect(deferredWorkStarted).toBe(true); + }); + + it("clicking the active row only closes the popover", async () => { + seed(); + renderSwitcher(); + await openSwitcher(); + + await userEvent.click(screen.getByRole("option", { name: /This Mac/ })); + + expect(screen.queryByRole("listbox", { name: "Environments" })).not.toBeInTheDocument(); + expect(remoteEnvironmentsApi.setActiveEnvironment).not.toHaveBeenCalled(); + }); + + it("supports listbox navigation, selection, escape, and trigger focus return", async () => { + seed(["connected", "offline"]); + renderSwitcher(); + const trigger = screen.getByRole("button", { name: "Switch environment" }); + + trigger.focus(); + await userEvent.keyboard("{ArrowDown}"); + await waitFor(() => { + expect(screen.getByRole("option", { name: /This Mac/ })).toHaveFocus(); + }); + await userEvent.keyboard("{End}"); + expect(screen.getByRole("option", { name: /Remote 1/ })).toHaveFocus(); + await userEvent.keyboard("{Home}"); + expect(screen.getByRole("option", { name: /This Mac/ })).toHaveFocus(); + await userEvent.keyboard("{ArrowDown}{Enter}"); + expect(useEnvironmentStore.getState().activeEnvironmentId).toBe("env-0"); + expect(trigger).toHaveFocus(); + + await userEvent.keyboard("{ArrowDown}{Escape}"); + expect(screen.queryByRole("listbox", { name: "Environments" })).not.toBeInTheDocument(); + expect(trigger).toHaveFocus(); + }); + + it("follows the store when a failed optimistic switch reverts", async () => { + seed(["connected"]); + vi.mocked(remoteEnvironmentsApi.setActiveEnvironment).mockRejectedValue( + new Error("refused"), + ); + renderSwitcher(); + await openSwitcher(); + + await act(async () => { + fireEvent.click(screen.getByRole("option", { name: /Remote 0/ })); + await Promise.resolve(); + }); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Switch environment" })).toHaveTextContent( + "This Mac", + ); + }); + }); +}); diff --git a/frontend/src/components/layout/EnvironmentSwitcher.tsx b/frontend/src/components/layout/EnvironmentSwitcher.tsx new file mode 100644 index 0000000000..7ecc2baa98 --- /dev/null +++ b/frontend/src/components/layout/EnvironmentSwitcher.tsx @@ -0,0 +1,290 @@ +import { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent, +} from "react"; +import { Check, ChevronDown } from "lucide-react"; + +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { + LOCAL_ENVIRONMENT_ID, + type EnvironmentConnectionState, + type EnvironmentEntry, + useEnvironmentStore, +} from "@/stores/environmentStore"; +import { useUiStore } from "@/stores/uiStore"; + +import { ENVIRONMENT_STATUS_DOT } from "./environment-switcher-status"; + +interface EnvironmentDotProps { + environmentId: string; + state: EnvironmentConnectionState; +} + +function EnvironmentDot({ environmentId, state }: EnvironmentDotProps) { + const config = ENVIRONMENT_STATUS_DOT[state]; + return ( + + ); +} + +interface EnvironmentRowProps { + environment: EnvironmentEntry; + state: EnvironmentConnectionState; + selected: boolean; + optionRef: (node: HTMLButtonElement | null) => void; + onSelect: (id: string) => void; + onKeyDown: (event: KeyboardEvent, id: string) => void; +} + +const EnvironmentRow = memo(function EnvironmentRow({ + environment, + state, + selected, + optionRef, + onSelect, + onKeyDown, +}: EnvironmentRowProps) { + const row = ( + + ); + const reason = environment.kind === "remote" ? ENVIRONMENT_STATUS_DOT[state].reason : null; + + if (!reason) return row; + + return ( + + {row} + {reason} + + ); +}); + +export interface EnvironmentSwitcherProps { + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +export const EnvironmentSwitcher = memo(function EnvironmentSwitcher({ + open, + onOpenChange, +}: EnvironmentSwitcherProps) { + const [internalOpen, setInternalOpen] = useState(false); + const resolvedOpen = open ?? internalOpen; + const setOpen = useCallback( + (nextOpen: boolean) => { + if (open === undefined) setInternalOpen(nextOpen); + onOpenChange?.(nextOpen); + }, + [onOpenChange, open], + ); + const enabled = useUiStore((state) => state.featureFlags.remoteEnvironments); + const environments = useEnvironmentStore((state) => state.environments); + const activeEnvironmentId = useEnvironmentStore((state) => state.activeEnvironmentId); + const connectionStates = useEnvironmentStore((state) => state.connectionStates); + const setActiveEnvironment = useEnvironmentStore((state) => state.setActiveEnvironment); + const optionRefs = useRef(new Map()); + const triggerRef = useRef(null); + + const activeEnvironment = useMemo( + () => + environments.find((environment) => environment.id === activeEnvironmentId) ?? + environments[0], + [activeEnvironmentId, environments], + ); + const activeState = + activeEnvironment?.kind === "local" + ? "connected" + : (connectionStates[activeEnvironment?.id ?? LOCAL_ENVIRONMENT_ID] ?? "idle"); + + useEffect(() => { + if (!resolvedOpen) return; + optionRefs.current.get(activeEnvironmentId)?.focus(); + }, [activeEnvironmentId, resolvedOpen]); + + const closeAndRestoreFocus = useCallback(() => { + setOpen(false); + queueMicrotask(() => triggerRef.current?.focus()); + }, [setOpen]); + + const handleSelect = useCallback( + (id: string) => { + setOpen(false); + if (id !== activeEnvironmentId) { + void setActiveEnvironment(id).catch(() => undefined); + } + queueMicrotask(() => triggerRef.current?.focus()); + }, + [activeEnvironmentId, setActiveEnvironment, setOpen], + ); + + const handleOptionKeyDown = useCallback( + (event: KeyboardEvent, id: string) => { + const index = environments.findIndex((environment) => environment.id === id); + let nextIndex: number | null = null; + switch (event.key) { + case "ArrowDown": + nextIndex = (index + 1) % environments.length; + break; + case "ArrowUp": + nextIndex = (index - 1 + environments.length) % environments.length; + break; + case "Home": + nextIndex = 0; + break; + case "End": + nextIndex = environments.length - 1; + break; + case "Enter": + case " ": + event.preventDefault(); + handleSelect(id); + return; + case "Escape": + event.preventDefault(); + closeAndRestoreFocus(); + return; + default: + return; + } + event.preventDefault(); + const next = environments[nextIndex]; + if (next) optionRefs.current.get(next.id)?.focus(); + }, + [closeAndRestoreFocus, environments, handleSelect], + ); + + if (!enabled || environments.length <= 1 || !activeEnvironment) return null; + + return ( + + + + + + + + Switch environment + + { + event.preventDefault(); + optionRefs.current.get(activeEnvironmentId)?.focus(); + }} + onCloseAutoFocus={(event) => { + event.preventDefault(); + triggerRef.current?.focus(); + }} + > +
+ Environments +
+
+ {environments.map((environment) => { + const state = + environment.kind === "local" + ? "connected" + : (connectionStates[environment.id] ?? "idle"); + return ( + { + if (node) optionRefs.current.set(environment.id, node); + else optionRefs.current.delete(environment.id); + }} + onSelect={handleSelect} + onKeyDown={handleOptionKeyDown} + /> + ); + })} +
+
+
+ ); +}); diff --git a/frontend/src/components/layout/environment-switcher-status.ts b/frontend/src/components/layout/environment-switcher-status.ts new file mode 100644 index 0000000000..2e6f4c44b0 --- /dev/null +++ b/frontend/src/components/layout/environment-switcher-status.ts @@ -0,0 +1,45 @@ +import type { EnvironmentConnectionState } from "@/stores/environmentStore"; + +interface EnvironmentStatusDotConfig { + glyph: "●" | "◐" | "⊘" | "○"; + color: string; + reason: string | null; +} + +export const ENVIRONMENT_STATUS_DOT = { + idle: { + glyph: "○", + color: "var(--text-muted, #8e8e93)", + reason: "Disconnected", + }, + connecting: { + glyph: "●", + color: "var(--status-warning, #e8a33d)", + reason: "Connecting…", + }, + connected: { + glyph: "●", + color: "var(--status-success, #2eb867)", + reason: null, + }, + backoff: { + glyph: "●", + color: "var(--status-warning, #e8a33d)", + reason: "Reconnecting…", + }, + offline: { + glyph: "○", + color: "var(--text-muted, #8e8e93)", + reason: "Disconnected", + }, + blocked: { + glyph: "⊘", + color: "var(--status-error, #e5484d)", + reason: "Blocked: protocol version", + }, + suspended: { + glyph: "◐", + color: "var(--text-muted, #8e8e93)", + reason: "Suspended", + }, +} as const satisfies Record; diff --git a/frontend/src/components/projects/ProjectSelector/ProjectDropdown.tsx b/frontend/src/components/projects/ProjectSelector/ProjectDropdown.tsx index f747090041..9515d9ff65 100644 --- a/frontend/src/components/projects/ProjectSelector/ProjectDropdown.tsx +++ b/frontend/src/components/projects/ProjectSelector/ProjectDropdown.tsx @@ -28,6 +28,8 @@ export interface ProjectDropdownProps { allProjectsDescription?: string; placeholder?: string; onNewProject?: () => void; + open?: boolean; + onOpenChange?: (open: boolean) => void; className?: string; align?: "start" | "center" | "end"; variant?: ProjectDropdownVariant; @@ -105,6 +107,8 @@ export function ProjectDropdown({ allProjectsDescription = "Aggregate metrics across every project", placeholder = "Select Project", onNewProject, + open: controlledOpen, + onOpenChange, className, align = "center", variant = "navbar", @@ -118,7 +122,8 @@ export function ProjectDropdown({ projectOptionTestId = (project) => `project-option-${project.id}`, allProjectsTestId = "project-option-all-projects", }: ProjectDropdownProps) { - const [open, setOpen] = useState(false); + const [internalOpen, setInternalOpen] = useState(false); + const open = controlledOpen ?? internalOpen; const [searchQuery, setSearchQuery] = useState(""); const [visibleCount, setVisibleCount] = useState(pageSize); const query = searchQuery.trim().toLowerCase(); @@ -145,7 +150,8 @@ export function ProjectDropdown({ }, [pageSize, query, projects.length]); const handleOpenChange = (nextOpen: boolean) => { - setOpen(nextOpen); + if (controlledOpen === undefined) setInternalOpen(nextOpen); + onOpenChange?.(nextOpen); if (!nextOpen) { setSearchQuery(""); setVisibleCount(pageSize); @@ -154,7 +160,7 @@ export function ProjectDropdown({ const handleSelect = (nextValue: string | null) => { onValueChange(nextValue); - setOpen(false); + handleOpenChange(false); setSearchQuery(""); setVisibleCount(pageSize); }; @@ -200,7 +206,7 @@ export function ProjectDropdown({ onKeyDown={(event) => { if (event.key === "ArrowDown") { event.preventDefault(); - setOpen(true); + handleOpenChange(true); } }} > @@ -312,7 +318,7 @@ export function ProjectDropdown({ className="flex w-full items-center gap-2 rounded-md px-3 py-2 text-left text-sm font-medium text-[var(--text-secondary)] hover:bg-[var(--bg-hover)] hover:text-[var(--text-primary)]" onClick={() => { onNewProject(); - setOpen(false); + handleOpenChange(false); }} data-testid={newProjectTestId} > diff --git a/frontend/src/components/projects/ProjectSelector/ProjectSelector.tsx b/frontend/src/components/projects/ProjectSelector/ProjectSelector.tsx index 35a2f007bb..b63fea44ee 100644 --- a/frontend/src/components/projects/ProjectSelector/ProjectSelector.tsx +++ b/frontend/src/components/projects/ProjectSelector/ProjectSelector.tsx @@ -26,6 +26,9 @@ export interface ProjectSelectorProps { className?: string; /** Dropdown alignment - defaults to center */ align?: "start" | "center" | "end"; + /** Controlled top-bar menu state. */ + open?: boolean; + onOpenChange?: (open: boolean) => void; } export function ProjectSelector({ @@ -33,6 +36,8 @@ export function ProjectSelector({ onBeforeProjectChange, className = "", align = "center", + open, + onOpenChange, }: ProjectSelectorProps) { // Store state (selection only) const activeProjectId = useProjectStore((s) => s.activeProjectId); @@ -87,6 +92,8 @@ export function ProjectSelector({ onNewProject={onNewProject} className={className} align={align} + {...(open !== undefined ? { open } : {})} + {...(onOpenChange ? { onOpenChange } : {})} variant="navbar" placeholder="Select Project" testId="project-selector-trigger" diff --git a/frontend/tests/integration/environment-switcher.spec.ts b/frontend/tests/integration/environment-switcher.spec.ts new file mode 100644 index 0000000000..d160124966 --- /dev/null +++ b/frontend/tests/integration/environment-switcher.spec.ts @@ -0,0 +1,19 @@ +import { expect, test } from "@playwright/test"; + +import { dismissProviderCliUpdateToasts, setupApp } from "../fixtures/setup.fixtures"; +import { EnvironmentSwitcherPage } from "../pages/components/environment-switcher.page"; + +test.describe("Environment switcher journey", () => { + test("switches from local to remote and back in the top bar", async ({ page }) => { + await dismissProviderCliUpdateToasts(page); + await setupApp(page); + const switcher = new EnvironmentSwitcherPage(page); + await switcher.seedTwoEnvironmentRegistry(); + + await expect(switcher.trigger).toContainText("This Mac"); + await switcher.open(); + await switcher.switchTo("Studio-Mac"); + await switcher.open(); + await switcher.switchTo("This Mac"); + }); +}); diff --git a/frontend/tests/pages/components/environment-switcher.page.ts b/frontend/tests/pages/components/environment-switcher.page.ts new file mode 100644 index 0000000000..c4a319734b --- /dev/null +++ b/frontend/tests/pages/components/environment-switcher.page.ts @@ -0,0 +1,57 @@ +import { expect, type Locator, type Page } from "@playwright/test"; + +import { BasePage } from "../base.page"; + +export class EnvironmentSwitcherPage extends BasePage { + readonly trigger: Locator; + readonly listbox: Locator; + + constructor(page: Page) { + super(page); + this.trigger = page.getByRole("button", { name: "Switch environment" }); + this.listbox = page.getByRole("listbox", { name: "Environments" }); + } + + async seedTwoEnvironmentRegistry(): Promise { + await this.page.evaluate(async () => { + const [{ useEnvironmentStore }, { useUiStore }] = await Promise.all([ + import("/src/stores/environmentStore"), + import("/src/stores/uiStore"), + ]); + useUiStore.getState().setFeatureFlags({ + ...useUiStore.getState().featureFlags, + remoteEnvironments: true, + }); + useEnvironmentStore.getState().setEnvironments([ + { + id: "studio-mac", + environmentId: "studio-host", + name: "Studio-Mac", + baseUrl: "https://studio-mac.test", + candidateUrls: [], + scopes: ["ui:read", "ui:operate"], + protocolVersion: 1, + status: "active", + createdAt: "2026-07-28T00:00:00Z", + lastConnectedAt: null, + }, + ]); + useEnvironmentStore.getState().setConnectionState("studio-mac", "connected"); + }); + } + + async open(): Promise { + await this.trigger.click(); + await expect(this.listbox).toBeVisible(); + } + + option(name: string): Locator { + return this.listbox.getByRole("option", { name: new RegExp(name) }); + } + + async switchTo(name: string): Promise { + await this.option(name).click(); + await expect(this.listbox).toBeHidden(); + await expect(this.trigger).toContainText(name); + } +} From a089cdcf52108d84e427a2080726968b46a0e9c3 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:42:56 +0300 Subject: [PATCH 136/416] fix(remote): close detector-(a) census/loop-root/exemption gaps (item 6) Fail-closed generate_handler! census parser (multi-command/inline-comment/ cfg-attributed lines now parse correctly instead of silently dropping), method-form spawn/listen loop-root discovery, re-verified Failed/Cancelled/ Archived transition dispositions against on-enter/reconciliation behavior, reanalyze_project promoted into the detector-(a) floor via a fixed spawn_project_analyzer sink, and list_projects/get_project demoted off the Read registry since they transitively spawn git. Ledger suite green (9/9, serial), authority_audit/registry/invoke focused suites green, rustfmt clean. --- docs/generated/remote-commands.json | 78 ++++++-- .../src/remote_server/authority_audit.rs | 134 ++++++++++---- .../remote_server/authority_audit_tests.rs | 166 ++++++++++++++++++ .../src/remote_server/capability_ledger.rs | 81 +++++++-- .../remote_server/capability_ledger_tests.rs | 77 +++++--- src-tauri/src/remote_server/registry.rs | 19 +- 6 files changed, 456 insertions(+), 99 deletions(-) diff --git a/docs/generated/remote-commands.json b/docs/generated/remote-commands.json index 7ede9a9b5a..99713e59bc 100644 --- a/docs/generated/remote-commands.json +++ b/docs/generated/remote-commands.json @@ -26,6 +26,8 @@ "stop_execution_plan", "resume_task", "retry_branch_update", + "create_project", + "reanalyze_project", "resume_deferred_git_startup", "update_github_pr_enabled", "copy_agent_conversation_plan", @@ -108,23 +110,52 @@ "authority_reducing_exemptions": [ { "command": "pause_task", + "direction": "authority-reducing", + "kind": "command", + "rationale": "transitions only to Paused", "scope": "ui:operate" }, { "command": "block_task", + "direction": "authority-reducing", + "kind": "command", + "rationale": "transitions only to Blocked", "scope": "ui:operate" }, { "command": "stop_task", + "direction": "authority-reducing", + "kind": "command", + "rationale": "transitions only to Stopped", "scope": "ui:operate" }, { "command": "pause_tasks_in_group", + "direction": "authority-reducing", + "kind": "command", + "rationale": "transitions only to Paused", "scope": "ui:operate" }, { "command": "deny_permission_request", + "direction": "authority-reducing", + "kind": "command", + "rationale": "denies a live tool call", "scope": "ui:operate" + }, + { + "direction": "authority-reducing", + "kind": "transition-target", + "rationale": "domain/state_machine/transition_handler/mod.rs on_exit stops pollers for Cancelled; on_enter_states/mod.rs has no Cancelled entry action", + "scope": "transition-target", + "target": "Cancelled" + }, + { + "direction": "authority-reducing", + "kind": "transition-target", + "rationale": "domain/state_machine/transition_handler/on_enter_states/mod.rs has no Archived entry action and application reconciliation does not scan Archived tasks", + "scope": "transition-target", + "target": "Archived" } ], "background_loop_inventory": [ @@ -282,6 +313,20 @@ "id": "application/chat_service/launch_reservation.rs::application/chat_service/launch_reservation.rs::LaunchReservationGuard::new@eb4d1acdcacd6079", "kind": "tokio::spawn" }, + { + "authorityBearing": false, + "enclosingFunction": "application/desktop_notification.rs:::::send_actionable", + "file": "application/desktop_notification.rs", + "id": "application/desktop_notification.rs::application/desktop_notification.rs:::::send_actionable@460f4228198e486d", + "kind": "method::spawn" + }, + { + "authorityBearing": false, + "enclosingFunction": "application/harness_runtime_registry.rs:::::probe_standard_harnesses_with", + "file": "application/harness_runtime_registry.rs", + "id": "application/harness_runtime_registry.rs::application/harness_runtime_registry.rs:::::probe_standard_harnesses_with@59d10e2433c6a7d0", + "kind": "method::spawn" + }, { "authorityBearing": false, "enclosingFunction": "application/mcp_policy_service.rs::McpPolicyService::resolve_claude_cleanup_cli", @@ -772,6 +817,13 @@ "id": "commands/unified_chat_commands/mod.rs::commands/unified_chat_commands/mod.rs:::::spawn_deferred_agent_workspace_repair_message@33cef224af17ad49", "kind": "async_runtime::spawn" }, + { + "authorityBearing": false, + "enclosingFunction": "commands/workspace_open_commands/mod.rs:::::warm_workspace_open_target_cache", + "file": "commands/workspace_open_commands/mod.rs", + "id": "commands/workspace_open_commands/mod.rs::commands/workspace_open_commands/mod.rs:::::warm_workspace_open_target_cache@57e0acfe73eecbdc", + "kind": "method::spawn" + }, { "authorityBearing": false, "enclosingFunction": "domain/state_machine/transition_handler/cleanup_helpers.rs:::::os_thread_timeout", @@ -2456,20 +2508,24 @@ "registered": false }, { - "capabilities": [], - "class": "read", + "capabilities": [ + "spawnsProcess" + ], + "class": "elevated", "command": "list_projects", "module": "project_commands", - "reason": "project read", - "registered": true + "reason": "project git/gh and deferred shell authority", + "registered": false }, { - "capabilities": [], - "class": "read", + "capabilities": [ + "spawnsProcess" + ], + "class": "elevated", "command": "get_project", "module": "project_commands", - "reason": "project read", - "registered": true + "reason": "project git/gh and deferred shell authority", + "registered": false }, { "capabilities": [ @@ -2563,12 +2619,12 @@ }, { "capabilities": [ - "spawnsProcess" + "agentControl" ], - "class": "elevated", + "class": "agentControl", "command": "reanalyze_project", "module": "project_commands", - "reason": "project git/gh and deferred shell authority", + "reason": "spawns the project-analyzer agent", "registered": false }, { diff --git a/src-tauri/src/remote_server/authority_audit.rs b/src-tauri/src/remote_server/authority_audit.rs index ea83c1e1f3..5558896cfa 100644 --- a/src-tauri/src/remote_server/authority_audit.rs +++ b/src-tauri/src/remote_server/authority_audit.rs @@ -68,6 +68,11 @@ pub const STEER_SINKS: &[&str] = &["send_message", "send_stdin_message", "write_ /// renamed field or a trait object still trips it. pub const AGENT_SPAWN_SINK: &str = ""; +/// Fixed project-analyzer process entry point. This is intentionally an exact free-function +/// name match: reaching it means the command starts an agent process, while unrelated process +/// helpers remain outside detector (a). +pub const AGENT_PROCESS_SPAWN_SINKS: &[&str] = &["spawn_project_analyzer"]; + /// `InternalStatus` targets that ARM or re-enter scheduling → classify. pub const ARMING_TRANSITION_TARGETS: &[&str] = &[ "Ready", @@ -77,23 +82,19 @@ pub const ARMING_TRANSITION_TARGETS: &[&str] = &[ "QaTesting", "QaPrep", "PendingReview", + "Failed", ]; /// `InternalStatus` targets that only halt/park → authority-reducing, exempt. -pub const HALTING_TRANSITION_TARGETS: &[&str] = &[ - "Paused", - "Blocked", - "Stopped", - "Failed", - "Cancelled", - "Archived", -]; +pub const HALTING_TRANSITION_TARGETS: &[&str] = + &["Paused", "Blocked", "Stopped", "Cancelled", "Archived"]; fn all_cut_sinks() -> BTreeSet<&'static str> { TRANSITION_SINKS .iter() .chain(SCHEDULER_SINKS.iter()) .chain(STEER_SINKS.iter()) + .chain(AGENT_PROCESS_SPAWN_SINKS.iter()) .copied() .collect() } @@ -161,8 +162,9 @@ impl CallGraph { .iter() .find(|(path, _)| path == "commands/registry.rs") .map(|(_, source)| { - parse_registered_command_names(source) + parse_registered_commands(source) .into_iter() + .map(|(command, _)| command) .collect::>() }) .unwrap_or_default(); @@ -599,7 +601,7 @@ fn is_background_spawn_path(path: &syn::Path) -> Option<&'static str> { /// `.spawn("worker", id)` / `.spawn_background("qa-prep", id)` — an `AgentSpawner` call /// recognised by shape (string-literal agent type first) rather than by receiver name. -fn is_agent_spawn_method( +pub(super) fn is_agent_spawn_method( method: &str, args: &syn::punctuated::Punctuated, ) -> bool { @@ -615,6 +617,27 @@ fn is_agent_spawn_method( ) } +/// Runtime-handle spawn methods whose first argument is executable code. Requiring a closure +/// or async block makes this mutually exclusive with the string-literal-first AgentSpawner +/// shape. +pub(super) fn is_method_background_spawn( + method: &str, + args: &syn::punctuated::Punctuated, +) -> bool { + matches!(method, "spawn" | "spawn_blocking") + && matches!( + args.first(), + Some(syn::Expr::Closure(_) | syn::Expr::Async(_)) + ) +} + +fn is_method_listener( + method: &str, + args: &syn::punctuated::Punctuated, +) -> bool { + method == "listen" && args.iter().any(|arg| matches!(arg, syn::Expr::Closure(_))) +} + fn internal_status_targets( args: &syn::punctuated::Punctuated, ) -> BTreeSet { @@ -792,6 +815,10 @@ impl<'ast, 'a> Visit<'ast> for FileVisitor<'a> { self.record_token(name.clone()); if name == "listen_any" || name == "listen_global" { self.record_loop_root("listen_any", &node.args); + } else if is_method_background_spawn(&name, &node.args) { + self.record_loop_root(&format!("method::{name}"), &node.args); + } else if is_method_listener(&name, &node.args) { + self.record_loop_root("listen", &node.args); } if all_cut_sinks().contains(name.as_str()) { let targets = transition_targets(&name, &node.args); @@ -912,32 +939,71 @@ fn collect_rs_files(root: &Path, dir: &Path, out: &mut Vec<(String, String)>) { pub fn registered_command_names() -> Vec { let source = std::fs::read_to_string(crate_src_root().join("commands/registry.rs")) .expect("commands/registry.rs must be readable"); - parse_registered_command_names(&source) + parse_registered_commands(&source) + .into_iter() + .map(|(command, _)| command) + .collect() } -pub fn parse_registered_command_names(source: &str) -> Vec { +pub fn parse_registered_commands(source: &str) -> Vec<(String, String)> { + const MARKER: &str = "tauri::generate_handler!["; let start = source - .find("tauri::generate_handler![") + .find(MARKER) .expect("registry.rs must contain generate_handler!"); - let body = &source[start..]; - let mut names = Vec::new(); - for line in body.lines() { - let line = line.trim(); - if line.is_empty() || line.starts_with("//") || line.starts_with("#[") { - continue; - } - if line.starts_with(']') { - break; - } - let line = line.trim_start_matches("tauri::generate_handler![").trim(); - let candidate = line.trim_end_matches(',').trim(); - if candidate.is_empty() { - continue; - } - let leaf = candidate.rsplit("::").next().unwrap_or(candidate); - if leaf.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && !leaf.is_empty() { - names.push(leaf.to_string()); - } - } - names + let body = &source[start + MARKER.len()..]; + let uncommented = body + .lines() + .map(|line| line.split_once("//").map_or(line, |(code, _)| code)) + .collect::>() + .join("\n"); + let mut bracket_depth = 1usize; + let end = uncommented + .char_indices() + .find_map(|(index, ch)| match ch { + '[' => { + bracket_depth += 1; + None + } + ']' => { + bracket_depth -= 1; + (bracket_depth == 0).then_some(index) + } + _ => None, + }) + .expect("generate_handler! body must have a closing bracket"); + + uncommented[..end] + .split(',') + .filter_map(|raw_segment| { + let mut segment = raw_segment.trim(); + while segment.starts_with("#[") { + let attribute_end = segment.find(']').unwrap_or_else(|| { + panic!("malformed command census segment `{segment}`: unterminated attribute") + }); + segment = segment[attribute_end + 1..].trim(); + } + if segment.is_empty() { + return None; + } + let parts = segment.split("::").collect::>(); + let valid_ident = |part: &&str| { + let mut chars = part.chars(); + chars + .next() + .is_some_and(|first| first == '_' || first.is_ascii_alphabetic()) + && chars.all(|ch| ch == '_' || ch.is_ascii_alphanumeric()) + }; + if parts.is_empty() || !parts.iter().all(valid_ident) { + panic!("malformed command census segment `{segment}`"); + } + let (command, module) = match parts.as_slice() { + [command] => ((*command).to_string(), "root".to_string()), + ["commands", module, .., command] => { + ((*command).to_string(), (*module).to_string()) + } + _ => panic!("malformed command census segment `{segment}`"), + }; + Some((command, module)) + }) + .collect() } diff --git a/src-tauri/src/remote_server/authority_audit_tests.rs b/src-tauri/src/remote_server/authority_audit_tests.rs index 9170866896..89f5082e8c 100644 --- a/src-tauri/src/remote_server/authority_audit_tests.rs +++ b/src-tauri/src/remote_server/authority_audit_tests.rs @@ -1,5 +1,171 @@ use super::authority_audit::*; use std::collections::{BTreeSet, HashMap, VecDeque}; +use syn::visit::Visit; + +#[test] +fn registered_command_parser_handles_layout_variants() { + let source = r#" + pub fn handlers() { + tauri::generate_handler![ + commands::alpha::one, commands::beta::two, + commands::gamma::three, // inline explanation + #[cfg(debug_assertions)] + commands::delta::four, + greet, + ] + } + "#; + + assert_eq!( + parse_registered_commands(source), + vec![ + ("one".to_string(), "alpha".to_string()), + ("two".to_string(), "beta".to_string()), + ("three".to_string(), "gamma".to_string()), + ("four".to_string(), "delta".to_string()), + ("greet".to_string(), "root".to_string()), + ] + ); +} + +#[test] +fn registered_command_parser_panics_with_the_malformed_segment() { + let source = r#" + tauri::generate_handler![ + commands::alpha::good, + commands::broken::bad(), + ] + "#; + + let panic = std::panic::catch_unwind(|| parse_registered_commands(source)) + .expect_err("malformed census segment must fail closed"); + let message = panic + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .expect("panic has a string message"); + assert!( + message.contains("commands::broken::bad()"), + "panic must name the malformed segment: {message}" + ); +} + +#[test] +fn method_spawn_and_listen_shapes_are_inventory_roots_with_body_authority() { + let source = r#" + fn roots(handle: Handle, app: App) { + handle.spawn(async { send_message(); }); + handle.spawn_blocking(|| send_message()); + app.listen("event", move |_| { send_message(); }); + } + fn inert(handle: Handle, app: App) { + handle.spawn(async { inspect(); }); + handle.spawn_blocking(|| inspect()); + app.listen("event", move |_| { inspect(); }); + } + "#; + let graph = CallGraph::build(&[("synthetic.rs".to_string(), source.to_string())]); + + let authoritative = graph + .loop_roots + .iter() + .filter(|root| root.enclosing_fn.ends_with("::roots")) + .collect::>(); + assert_eq!( + authoritative.len(), + 3, + "spawn, spawn_blocking, and listen must each be discovered" + ); + assert!( + authoritative + .iter() + .all(|root| closure_is_arming(&graph.loop_closure(root))), + "each send_message body must be authority-bearing" + ); + + let inert = graph + .loop_roots + .iter() + .filter(|root| root.enclosing_fn.ends_with("::inert")) + .collect::>(); + assert_eq!( + inert.len(), + 3, + "inert forms must still be present in the inventory" + ); + assert!( + inert + .iter() + .all(|root| !closure_is_arming(&graph.loop_closure(root))), + "inert bodies must not acquire authority" + ); +} + +#[test] +fn agent_spawner_shape_is_mutually_exclusive_with_method_loop_spawn() { + let source = r#"fn shapes(spawner: Spawner, handle: Handle, id: Id) { + spawner.spawn("worker", id); + handle.spawn(async { send_message(); }); + }"#; + let file = syn::parse_file(source).unwrap(); + let mut calls = Vec::new(); + struct MethodCollector<'a>(&'a mut Vec); + impl<'ast> syn::visit::Visit<'ast> for MethodCollector<'_> { + fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) { + self.0.push(node.clone()); + syn::visit::visit_expr_method_call(self, node); + } + } + MethodCollector(&mut calls).visit_file(&file); + + assert!(is_agent_spawn_method( + &calls[0].method.to_string(), + &calls[0].args + )); + assert!(!is_method_background_spawn( + &calls[0].method.to_string(), + &calls[0].args + )); + assert!(!is_agent_spawn_method( + &calls[1].method.to_string(), + &calls[1].args + )); + assert!(is_method_background_spawn( + &calls[1].method.to_string(), + &calls[1].args + )); + + let graph = CallGraph::build(&[("synthetic.rs".to_string(), source.to_string())]); + assert_eq!( + graph.loop_roots.len(), + 1, + "AgentSpawner string-first call must not be double-counted as a loop" + ); +} + +#[test] +fn failed_rearms_while_cancelled_and_archived_remain_halting() { + let hit = |target: &str| SinkHit { + sink: "transition_task".to_string(), + targets: BTreeSet::from([target.to_string()]), + }; + + assert_eq!( + verdict_for(&hit("Failed")), + HitVerdict::Arming, + "Failed is scanned by execution reconciliation and auto-retries to Ready" + ); + assert_eq!( + verdict_for(&hit("Cancelled")), + HitVerdict::Halting, + "Cancelled stops pollers and has no on-enter spawn action" + ); + assert_eq!( + verdict_for(&hit("Archived")), + HitVerdict::Halting, + "Archived has no task on-enter action or reconciliation scan" + ); +} fn trace(graph: &CallGraph, root: &str) -> Option> { let sinks: BTreeSet = TRANSITION_SINKS diff --git a/src-tauri/src/remote_server/capability_ledger.rs b/src-tauri/src/remote_server/capability_ledger.rs index 27e12907f3..c3ac4cdcc3 100644 --- a/src-tauri/src/remote_server/capability_ledger.rs +++ b/src-tauri/src/remote_server/capability_ledger.rs @@ -26,6 +26,15 @@ pub struct CommandOverride { pub policy: LedgerPolicy, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AuthorityReducingExemption { + pub subject: &'static str, + pub kind: &'static str, + pub direction: &'static str, + pub scope: &'static str, + pub rationale: &'static str, +} + const NONE: &[Capability] = &[]; const AGENT: &[Capability] = &[Capability::AgentControl]; const PROCESS: &[Capability] = &[Capability::SpawnsProcess]; @@ -233,14 +242,6 @@ pub const COMMAND_OVERRIDES: &[CommandOverride] = &[ command: "health_check", policy: policy(RiskClass::Read, NONE, "pure health read"), }, - CommandOverride { - command: "list_projects", - policy: policy(RiskClass::Read, NONE, "project read"), - }, - CommandOverride { - command: "get_project", - policy: policy(RiskClass::Read, NONE, "project read"), - }, CommandOverride { command: "list_tasks", policy: policy(RiskClass::Read, NONE, "task read"), @@ -322,6 +323,14 @@ pub const COMMAND_OVERRIDES: &[CommandOverride] = &[ "authority-restoring transition", ), }, + CommandOverride { + command: "reanalyze_project", + policy: policy( + RiskClass::AgentControl, + AGENT, + "spawns the project-analyzer agent", + ), + }, // Declared memberships not inferable from transition/process sinks. CommandOverride { command: "resolve_permission_request", @@ -337,12 +346,56 @@ pub const COMMAND_OVERRIDES: &[CommandOverride] = &[ }, ]; -pub const AUTHORITY_REDUCING_EXEMPTIONS: &[&str] = &[ - "pause_task", - "block_task", - "stop_task", - "pause_tasks_in_group", - "deny_permission_request", +pub const AUTHORITY_REDUCING_EXEMPTIONS: &[AuthorityReducingExemption] = &[ + AuthorityReducingExemption { + subject: "pause_task", + kind: "command", + direction: "authority-reducing", + scope: "ui:operate", + rationale: "transitions only to Paused", + }, + AuthorityReducingExemption { + subject: "block_task", + kind: "command", + direction: "authority-reducing", + scope: "ui:operate", + rationale: "transitions only to Blocked", + }, + AuthorityReducingExemption { + subject: "stop_task", + kind: "command", + direction: "authority-reducing", + scope: "ui:operate", + rationale: "transitions only to Stopped", + }, + AuthorityReducingExemption { + subject: "pause_tasks_in_group", + kind: "command", + direction: "authority-reducing", + scope: "ui:operate", + rationale: "transitions only to Paused", + }, + AuthorityReducingExemption { + subject: "deny_permission_request", + kind: "command", + direction: "authority-reducing", + scope: "ui:operate", + rationale: "denies a live tool call", + }, + AuthorityReducingExemption { + subject: "Cancelled", + kind: "transition-target", + direction: "authority-reducing", + scope: "transition-target", + rationale: "domain/state_machine/transition_handler/mod.rs on_exit stops pollers for Cancelled; on_enter_states/mod.rs has no Cancelled entry action", + }, + AuthorityReducingExemption { + subject: "Archived", + kind: "transition-target", + direction: "authority-reducing", + scope: "transition-target", + rationale: "domain/state_machine/transition_handler/on_enter_states/mod.rs has no Archived entry action and application reconciliation does not scan Archived tasks", + }, ]; pub const DECLARED_MEMBERSHIPS: &[(&str, &str)] = &[ diff --git a/src-tauri/src/remote_server/capability_ledger_tests.rs b/src-tauri/src/remote_server/capability_ledger_tests.rs index e195e6680f..44c6f4abe8 100644 --- a/src-tauri/src/remote_server/capability_ledger_tests.rs +++ b/src-tauri/src/remote_server/capability_ledger_tests.rs @@ -3,7 +3,7 @@ use std::collections::{BTreeMap, BTreeSet}; use ralphx_remote_protocol::{class_permits, Capability, RiskClass}; use super::authority_audit::{ - closure_is_arming, load_production_sources, parse_registered_command_names, CallGraph, + closure_is_arming, load_production_sources, parse_registered_commands, CallGraph, }; use super::capability_ledger::{ policy_for, AUTHORITY_REDUCING_EXEMPTIONS, COMMAND_OVERRIDES, DECLARED_MEMBERSHIPS, @@ -16,24 +16,7 @@ fn registry_source() -> &'static str { } fn census() -> Vec<(String, String)> { - let names = parse_registered_command_names(registry_source()); - let mut modules = Vec::new(); - let start = registry_source() - .find("tauri::generate_handler![") - .expect("registry has generate_handler"); - for line in registry_source()[start..].lines() { - let candidate = line.trim().trim_end_matches(','); - if candidate.starts_with(']') { - break; - } - if candidate == "greet" { - modules.push("root".to_string()); - } else if let Some(path) = candidate.strip_prefix("commands::") { - modules.push(path.split("::").next().expect("command module").to_string()); - } - } - assert_eq!(names.len(), modules.len(), "registry census parser drifted"); - names.into_iter().zip(modules).collect() + parse_registered_commands(registry_source()) } fn generated_manifest() -> serde_json::Value { @@ -75,7 +58,20 @@ fn generated_manifest() -> serde_json::Value { .collect::>(); let authority_reducing_exemptions = AUTHORITY_REDUCING_EXEMPTIONS .iter() - .map(|command| serde_json::json!({ "command": command, "scope": "ui:operate" })) + .map(|exemption| { + let mut row = serde_json::json!({ + "kind": exemption.kind, + "direction": exemption.direction, + "scope": exemption.scope, + "rationale": exemption.rationale, + }); + row[if exemption.kind == "command" { + "command" + } else { + "target" + }] = serde_json::json!(exemption.subject); + row + }) .collect::>(); let declared_memberships = DECLARED_MEMBERSHIPS .iter() @@ -176,8 +172,10 @@ fn capability_ledger_is_exhaustive_and_internally_consistent() { #[test] fn detector_a_is_a_floor_for_agent_control() { let graph = CallGraph::build(&load_production_sources()); + let mut floor = BTreeSet::new(); for (command, module) in census() { if closure_is_arming(&graph.closure([command.clone()])) { + floor.insert(command.clone()); let row = policy_for(&command, &module).expect("census is ledgered"); assert!( matches!(row.class, RiskClass::AgentControl | RiskClass::Elevated), @@ -185,6 +183,13 @@ fn detector_a_is_a_floor_for_agent_control() { ); } } + assert!( + floor.contains("reanalyze_project"), + "project-analyzer spawn sink must mechanically place reanalyze_project in detector (a)" + ); + let row = policy_for("reanalyze_project", "project_commands").unwrap(); + assert_eq!(row.class, RiskClass::AgentControl); + assert_eq!(row.capabilities, &[Capability::AgentControl]); } #[test] @@ -210,7 +215,6 @@ fn extended_deny_surface_is_not_remotely_registrable_as_read_or_operate() { "login_gh_with_browser", "update_custom_analysis", "change_project_git_mode", - "reanalyze_project", "resolve_merge_conflict", "cleanup_task_branch", "get_task_file_changes", @@ -240,16 +244,28 @@ fn extended_deny_surface_is_not_remotely_registrable_as_read_or_operate() { #[test] fn exemptions_and_declared_memberships_are_exact() { let rows = census().into_iter().collect::>(); - for command in AUTHORITY_REDUCING_EXEMPTIONS + for exemption in AUTHORITY_REDUCING_EXEMPTIONS .iter() - .filter(|command| **command != "deny_permission_request") + .filter(|entry| entry.kind == "command" && entry.subject != "deny_permission_request") { - let module = rows.get(*command).expect("exemption command exists"); + let command = exemption.subject; + let module = rows.get(command).expect("exemption command exists"); assert_eq!( policy_for(command, module).unwrap().class, RiskClass::Operate ); } + for target in ["Cancelled", "Archived"] { + let exemption = AUTHORITY_REDUCING_EXEMPTIONS + .iter() + .find(|entry| entry.kind == "transition-target" && entry.subject == target) + .unwrap_or_else(|| panic!("missing transition-target exemption for {target}")); + assert_eq!(exemption.direction, "authority-reducing"); + assert!( + exemption.rationale.contains(".rs"), + "{target} rationale must carry file-anchored evidence" + ); + } assert_eq!( policy_for("unblock_task", "task_commands").unwrap().class, RiskClass::AgentControl @@ -262,6 +278,19 @@ fn exemptions_and_declared_memberships_are_exact() { assert_eq!(question.reason, DECLARED_MEMBERSHIPS[1].1); } +#[test] +fn spawning_project_getters_are_elevated_and_not_registered() { + for command in ["list_projects", "get_project"] { + let row = policy_for(command, "project_commands").unwrap(); + assert_eq!(row.class, RiskClass::Elevated); + assert_eq!(row.capabilities, &[Capability::SpawnsProcess]); + assert!( + find_spec(command).is_none(), + "{command} must not remain on the Read registry" + ); + } +} + #[test] fn representative_capability_stripping_cannot_lower_membership() { for (command, module, capability) in [ diff --git a/src-tauri/src/remote_server/registry.rs b/src-tauri/src/remote_server/registry.rs index 3b1745352a..1c61031a83 100644 --- a/src-tauri/src/remote_server/registry.rs +++ b/src-tauri/src/remote_server/registry.rs @@ -419,8 +419,9 @@ pub fn update_task_authz(args: &Value) -> Scope { // PR 1.3 registers the `Read` class only. Every entry below was individually checked against // the capability ledger: none spawns a process, and none is one of the "getter that shells out" -// commands (`get_git_branches`, `get_task_file_changes`/`get_file_diff`, -// `get_codex_cli_diagnostics`, `build_agent_issue_report`) which are NOT `Read`. Mutating +// commands (`list_projects`/`get_project`, `get_git_branches`, +// `get_task_file_changes`/`get_file_diff`, `get_codex_cli_diagnostics`, +// `build_agent_issue_report`) which are NOT `Read`. Mutating // classes land in PR 1.5 (`ui:agent` suite) and PR 3.1 (full coverage). crate::remote_commands! { "health_check" => crate::commands::health::health_check { @@ -430,20 +431,6 @@ crate::remote_commands! { call: sync, result: infallible, }, - "list_projects" => crate::commands::project_commands::list_projects { - class: Read, - caps: [], - params: [(app_state)], - call: async, - result: fallible, - }, - "get_project" => crate::commands::project_commands::get_project { - class: Read, - caps: [], - params: [(arg id: String), (app_state)], - call: async, - result: fallible, - }, "list_tasks" => crate::commands::task_commands::query::list_tasks { class: Read, caps: [], From 1f80b1ec150adfb631835a308df884871a65db6b Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:02:15 +0300 Subject: [PATCH 137/416] test: align coverage-round-2 fixtures with the event-relay constructor --- .../src/application/remote_environment_service_tests.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src-tauri/src/application/remote_environment_service_tests.rs b/src-tauri/src/application/remote_environment_service_tests.rs index 3b1ac82265..3c038e6ff3 100644 --- a/src-tauri/src/application/remote_environment_service_tests.rs +++ b/src-tauri/src/application/remote_environment_service_tests.rs @@ -195,6 +195,7 @@ fn service_with_repo( descriptor("env-1"), pair_response("env-1"), )), + test_relay(), ) } @@ -1969,6 +1970,7 @@ async fn reconciler_defers_when_activation_write_fails() { descriptor("env-pending"), pair_response("env-pending"), )), + test_relay(), ); let report = service.reconcile_on_startup().await; @@ -2000,6 +2002,7 @@ async fn reconciler_pending_delete_defers_each_destructive_failure() { Arc::clone(&repo) as _, Arc::clone(&secrets) as _, Arc::clone(&host) as _, + test_relay(), ); let env = inner .upsert_paired(crate::domain::repositories::UpsertPairedEnvironment { @@ -2054,6 +2057,7 @@ async fn reconciler_keeps_pending_delete_row_when_secret_delete_fails() { Arc::clone(&repo) as _, Arc::clone(&secrets) as _, Arc::clone(&host) as _, + test_relay(), ); let env = repo .upsert_paired(crate::domain::repositories::UpsertPairedEnvironment { @@ -2081,6 +2085,8 @@ async fn reconciler_keeps_pending_delete_row_when_secret_delete_fails() { .await .expect("secret read") .is_some()); +} + #[tokio::test] async fn stream_send_reaches_the_live_socket() { let f = fixture(); From f8c7c1c18eb2e88536132cd9ac98dd956c05ccef Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:21:37 +0300 Subject: [PATCH 138/416] feat(remote): detector-(b) spawn-triggering surface + agent-consumed content surface (items 1-2) Detector (b): seven state-surface entries derived from the settled loop inventory's read sites (ready-task, watchdog PendingReview freshness, automation Active, workspace-bridge linkage, external_events cursor, auto-publish, auto-review), matched via distinctive closure-token markers against the full 539-command census. Canonical writers (inject_task, resume_automation, finalize_automation, workspace-linkage/bridge setters) fall out mechanically; brakes and pure reads are asserted absent in both directions. agent_control_floor becomes the union of detector-(a) and detector-(b), now 108 members. Content surface: derived from the live MCP tool registry intersected with worker-class agent grants (ralphx-execution-{worker,coder,reviewer,merger}) plus the worker prompt-builder read sites; all calibration tools fall out of the scan. Writers (task_step/artifact/proposal/review-note mutators, move_task's note, conditional update_task, HTTP add_task_note) tagged MutatesAgentConsumedContent. Both manifest tables populated only via the gated regen test; coverage.detectorB and coverage.agentConsumedContent flip to "complete" in the same regen. Strip-row negative tests prove neither surface can silently evaporate. Ledger suite green (13/13, serial), audit/registry/invoke focused suites green, rustfmt clean. --- docs/generated/remote-commands.json | 434 ++++++++++++++++-- .../src/remote_server/authority_audit.rs | 43 +- .../src/remote_server/capability_ledger.rs | 142 ++++++ .../remote_server/capability_ledger_tests.rs | 371 ++++++++++++++- 4 files changed, 945 insertions(+), 45 deletions(-) diff --git a/docs/generated/remote-commands.json b/docs/generated/remote-commands.json index 99713e59bc..dca215a4c4 100644 --- a/docs/generated/remote-commands.json +++ b/docs/generated/remote-commands.json @@ -1,5 +1,257 @@ { - "agent_consumed_content_surface": [], + "agent_consumed_content_surface": { + "reads": [ + { + "grantedTo": [ + "ralphx-execution-worker", + "ralphx-execution-coder" + ], + "reads": "agent_tasks", + "tool": "get_agent_task" + }, + { + "grantedTo": [ + "ralphx-execution-worker", + "ralphx-execution-coder", + "ralphx-execution-reviewer" + ], + "reads": "artifacts/artifact_versions/artifact_relations", + "tool": "get_artifact" + }, + { + "grantedTo": [ + "ralphx-execution-worker", + "ralphx-execution-coder", + "ralphx-execution-reviewer" + ], + "reads": "artifacts/artifact_versions/artifact_relations", + "tool": "get_artifact_version" + }, + { + "grantedTo": [ + "ralphx-execution-worker", + "ralphx-execution-coder", + "ralphx-execution-reviewer", + "ralphx-execution-merger" + ], + "reads": "memory_entries", + "tool": "get_memories_for_paths" + }, + { + "grantedTo": [ + "ralphx-execution-worker", + "ralphx-execution-coder", + "ralphx-execution-reviewer", + "ralphx-execution-merger" + ], + "reads": "memory_entries", + "tool": "get_memory" + }, + { + "grantedTo": [ + "ralphx-execution-worker", + "ralphx-execution-coder", + "ralphx-execution-reviewer", + "ralphx-execution-merger" + ], + "reads": "project_analysis", + "tool": "get_project_analysis" + }, + { + "grantedTo": [ + "ralphx-execution-worker", + "ralphx-execution-coder", + "ralphx-execution-reviewer" + ], + "reads": "artifacts/artifact_versions/artifact_relations", + "tool": "get_related_artifacts" + }, + { + "grantedTo": [ + "ralphx-execution-worker", + "ralphx-execution-coder", + "ralphx-execution-reviewer" + ], + "reads": "review_notes/task_issues", + "tool": "get_review_notes" + }, + { + "grantedTo": [ + "ralphx-execution-worker", + "ralphx-execution-coder" + ], + "reads": "task_steps", + "tool": "get_step_context" + }, + { + "grantedTo": [ + "ralphx-execution-worker" + ], + "reads": "task_steps", + "tool": "get_sub_steps" + }, + { + "grantedTo": [ + "ralphx-execution-worker", + "ralphx-execution-coder", + "ralphx-execution-reviewer", + "ralphx-execution-merger" + ], + "reads": "worker TaskContext/prompt projection", + "tool": "get_task_context" + }, + { + "grantedTo": [ + "ralphx-execution-reviewer" + ], + "reads": "task/worktree diff", + "tool": "get_task_diff" + }, + { + "grantedTo": [ + "ralphx-execution-reviewer" + ], + "reads": "task/worktree diff", + "tool": "get_task_diff_stat" + }, + { + "grantedTo": [ + "ralphx-execution-worker", + "ralphx-execution-coder", + "ralphx-execution-reviewer" + ], + "reads": "review_notes/task_issues", + "tool": "get_task_issues" + }, + { + "grantedTo": [ + "ralphx-execution-worker", + "ralphx-execution-coder", + "ralphx-execution-reviewer" + ], + "reads": "task_steps", + "tool": "get_task_steps" + }, + { + "grantedTo": [ + "ralphx-execution-reviewer" + ], + "reads": "validation_runs", + "tool": "get_task_validation_summary" + }, + { + "grantedTo": [ + "ralphx-execution-worker", + "ralphx-execution-coder" + ], + "reads": "agent_tasks", + "tool": "list_agent_tasks" + }, + { + "grantedTo": [ + "ralphx-execution-worker", + "ralphx-execution-coder", + "ralphx-execution-reviewer", + "ralphx-execution-merger" + ], + "reads": "memory_entries", + "tool": "search_memories" + }, + { + "grantedTo": [ + "ralphx-execution-worker", + "ralphx-execution-coder", + "ralphx-execution-reviewer" + ], + "reads": "artifacts/artifact_versions/artifact_relations", + "tool": "search_project_artifacts" + } + ], + "writers": [ + { + "surface": "tauri-command", + "writer": "create_task_step", + "writes": "task_steps" + }, + { + "surface": "tauri-command", + "writer": "update_task_step", + "writes": "task_steps" + }, + { + "surface": "tauri-command", + "writer": "create_artifact", + "writes": "artifacts (any kind)" + }, + { + "surface": "tauri-command", + "writer": "update_artifact", + "writes": "artifacts (any kind)" + }, + { + "surface": "tauri-command", + "writer": "add_artifact_relation", + "writes": "artifact_relations" + }, + { + "surface": "tauri-command", + "writer": "update_task_proposal", + "writes": "task proposals" + }, + { + "surface": "tauri-command", + "writer": "approve_review", + "writes": "review feedback" + }, + { + "surface": "tauri-command", + "writer": "reject_review", + "writes": "review feedback" + }, + { + "surface": "tauri-command", + "writer": "request_changes", + "writes": "review feedback" + }, + { + "surface": "tauri-command", + "writer": "reject_fix_task", + "writes": "review notes/fix feedback" + }, + { + "surface": "tauri-command", + "writer": "approve_task_for_review", + "writes": "review notes" + }, + { + "surface": "tauri-command", + "writer": "request_task_changes_for_review", + "writes": "review notes/feedback" + }, + { + "surface": "tauri-command", + "writer": "request_task_changes_from_reviewing", + "writes": "review notes/feedback" + }, + { + "conditional": "note", + "surface": "tauri-command", + "writer": "move_task", + "writes": "task restart note" + }, + { + "conditional": "title,description — discharged by update_task_authz", + "surface": "tauri-command", + "writer": "update_task", + "writes": "task title/description" + }, + { + "surface": "http-handler", + "writer": "add_task_note", + "writes": "task.description" + } + ] + }, "agent_control_floor": [ "remote_fetch", "complete_atlassian_oauth_local_callback", @@ -11,6 +263,7 @@ "refresh_agent_conversation_jira_issue", "assign_agent_conversation_jira_issue_to_me", "resume_automation", + "finalize_automation", "resume_automation_run", "create_persona_draft", "update_persona_draft", @@ -18,6 +271,7 @@ "assign_agent_conversation_linear_issue", "refresh_agent_conversation_linear_issue", "answer_user_question", + "inject_task", "move_task", "unblock_task", "resume_tasks_in_group", @@ -41,6 +295,7 @@ "request_task_changes_for_review", "request_task_changes_from_reviewing", "re_review_task_from_escalated", + "update_review_settings", "resume_execution", "restart_task", "recover_task_execution", @@ -1134,9 +1389,9 @@ } ], "coverage": { - "agentConsumedContent": "pending", + "agentConsumedContent": "complete", "detectorA": "complete", - "detectorB": "pending" + "detectorB": "complete" }, "declared_memberships": [ { @@ -1825,22 +2080,22 @@ }, { "capabilities": [ - "agentControl" + "seedsSpawnTriggeringState" ], "class": "agentControl", "command": "resume_automation", "module": "automation_commands", - "reason": "conservative-module-default: may steer or arm autonomous work", + "reason": "detector-b: restores Active automation consumed by the automation scheduler", "registered": false }, { "capabilities": [ - "agentControl" + "seedsSpawnTriggeringState" ], "class": "agentControl", "command": "finalize_automation", "module": "automation_commands", - "reason": "conservative-module-default: may steer or arm autonomous work", + "reason": "detector-b: completes automation arming state consumed by the automation scheduler", "registered": false }, { @@ -2161,22 +2416,23 @@ }, { "capabilities": [ - "agentControl" + "seedsSpawnTriggeringState" ], "class": "agentControl", "command": "inject_task", "module": "task_commands", - "reason": "conservative-module-default: may steer or arm autonomous work", + "reason": "detector-b: seeds internal_status=Ready consumed by the ready-task scheduler", "registered": false }, { "capabilities": [ - "agentControl" + "agentControl", + "mutatesAgentConsumedContent" ], "class": "agentControl", "command": "move_task", "module": "task_commands", - "reason": "conservative-module-default: may steer or arm autonomous work", + "reason": "detector-a plus content-surface: restart note is worker-consumed", "registered": false }, { @@ -2419,12 +2675,12 @@ }, { "capabilities": [ - "agentControl" + "mutatesAgentConsumedContent" ], "class": "agentControl", "command": "create_task_step", "module": "task_step_commands", - "reason": "conservative-module-default: may steer or arm autonomous work", + "reason": "content-surface: creates worker-consumed task step", "registered": false }, { @@ -2439,12 +2695,12 @@ }, { "capabilities": [ - "agentControl" + "mutatesAgentConsumedContent" ], "class": "agentControl", "command": "update_task_step", "module": "task_step_commands", - "reason": "conservative-module-default: may steer or arm autonomous work", + "reason": "content-surface: updates worker-consumed task step", "registered": false }, { @@ -2989,32 +3245,32 @@ }, { "capabilities": [ - "agentControl" + "mutatesAgentConsumedContent" ], "class": "agentControl", "command": "approve_review", "module": "review_commands", - "reason": "conservative-module-default: may steer or arm autonomous work", + "reason": "content-surface: writes worker-consumed review feedback", "registered": false }, { "capabilities": [ - "agentControl" + "mutatesAgentConsumedContent" ], "class": "agentControl", "command": "request_changes", "module": "review_commands", - "reason": "conservative-module-default: may steer or arm autonomous work", + "reason": "content-surface: writes worker-consumed review feedback", "registered": false }, { "capabilities": [ - "agentControl" + "mutatesAgentConsumedContent" ], "class": "agentControl", "command": "reject_review", "module": "review_commands", - "reason": "conservative-module-default: may steer or arm autonomous work", + "reason": "content-surface: writes worker-consumed review feedback", "registered": false }, { @@ -3029,12 +3285,12 @@ }, { "capabilities": [ - "agentControl" + "mutatesAgentConsumedContent" ], "class": "agentControl", "command": "reject_fix_task", "module": "review_commands", - "reason": "conservative-module-default: may steer or arm autonomous work", + "reason": "content-surface: writes worker-consumed fix feedback", "registered": false }, { @@ -3049,32 +3305,32 @@ }, { "capabilities": [ - "agentControl" + "mutatesAgentConsumedContent" ], "class": "agentControl", "command": "approve_task_for_review", "module": "review_commands", - "reason": "conservative-module-default: may steer or arm autonomous work", + "reason": "content-surface: writes worker-consumed review note", "registered": false }, { "capabilities": [ - "agentControl" + "mutatesAgentConsumedContent" ], "class": "agentControl", "command": "request_task_changes_for_review", "module": "review_commands", - "reason": "conservative-module-default: may steer or arm autonomous work", + "reason": "content-surface: writes worker-consumed review feedback", "registered": false }, { "capabilities": [ - "agentControl" + "mutatesAgentConsumedContent" ], "class": "agentControl", "command": "request_task_changes_from_reviewing", "module": "review_commands", - "reason": "conservative-module-default: may steer or arm autonomous work", + "reason": "content-surface: writes worker-consumed review feedback", "registered": false }, { @@ -3719,12 +3975,12 @@ }, { "capabilities": [ - "agentControl" + "mutatesAgentConsumedContent" ], "class": "agentControl", "command": "update_task_proposal", "module": "ideation_commands", - "reason": "conservative-module-default: may steer or arm autonomous work", + "reason": "content-surface: updates worker-consumed task proposal", "registered": false }, { @@ -4629,22 +4885,22 @@ }, { "capabilities": [ - "agentControl" + "mutatesAgentConsumedContent" ], "class": "agentControl", "command": "create_artifact", "module": "artifact_commands", - "reason": "conservative-module-default: may steer or arm autonomous work", + "reason": "content-surface: creates worker-consumed artifact of any kind", "registered": false }, { "capabilities": [ - "agentControl" + "mutatesAgentConsumedContent" ], "class": "agentControl", "command": "update_artifact", "module": "artifact_commands", - "reason": "conservative-module-default: may steer or arm autonomous work", + "reason": "content-surface: updates worker-consumed artifact of any kind", "registered": false }, { @@ -4729,12 +4985,12 @@ }, { "capabilities": [ - "agentControl" + "mutatesAgentConsumedContent" ], "class": "agentControl", "command": "add_artifact_relation", "module": "artifact_commands", - "reason": "conservative-module-default: may steer or arm autonomous work", + "reason": "content-surface: changes worker-consumed artifact relations", "registered": false }, { @@ -6519,7 +6775,109 @@ } ], "schemaVersion": 1, - "spawn_triggering_state_surface": [], + "spawn_triggering_state_surface": [ + { + "armedValue": "Ready", + "id": "ready-task", + "readByLoops": [ + "application/ready_task_scheduler.rs::application/ready_task_scheduler.rs:::::spawn_ready_task_scheduler_if_needed@57e1eb6d86c1770f" + ], + "surface": "tasks.internal_status", + "writers": [ + "inject_task", + "move_task", + "restart_task" + ] + }, + { + "armedValue": "PendingReview with no fresh/running reviewer", + "id": "pending-review-freshness", + "readByLoops": [ + "application/startup_background.rs::application/startup_background.rs:::::spawn_watchdog@8c28974ee8ca859d" + ], + "surface": "tasks.internal_status + task_status_history.entered_at + agent_runs.status", + "writers": [ + "re_review_task_from_escalated", + "request_task_changes_from_reviewing" + ] + }, + { + "armedValue": "Active", + "id": "automation-active", + "readByLoops": [ + "application/startup_background.rs::application/startup_background.rs:::::spawn_automation_scheduler@c034c5fc2b8fe7b8" + ], + "surface": "automations.status", + "writers": [ + "finalize_automation", + "resume_automation" + ] + }, + { + "armedValue": "linked active plan/edit workspace", + "id": "workspace-bridge", + "readByLoops": [ + "application/startup_background.rs::application/startup_background.rs:::::spawn_agent_workspace_bridge_dispatcher@11ddd3248e57299b" + ], + "surface": "agent_conversation_workspaces.linked_ideation_session_id/status/mode", + "writers": [ + "activate_agent_plan_direct_implementation", + "activate_agent_task_pipeline", + "close_agent_workspace_pr", + "commit_agent_conversation_workspace_locally", + "copy_agent_conversation_plan", + "import_agent_conversation_plan", + "publish_agent_conversation_workspace", + "reconcile_agent_conversation_workspace_publication", + "resume_deferred_git_startup", + "set_agent_conversation_workspace_pr_supervision", + "start_agent_conversation", + "start_ralphx_work_from_ticket", + "start_research", + "switch_agent_conversation_mode", + "update_agent_conversation_workspace_from_base" + ] + }, + { + "armedValue": "unconsumed row", + "id": "external-event-cursor", + "readByLoops": [ + "application/startup_background.rs::application/startup_background.rs:::::spawn_agent_workspace_bridge_dispatcher@11ddd3248e57299b" + ], + "surface": "external_events rows/cursor", + "writers": [ + "create_cross_project_session", + "create_ideation_session", + "import_ideation_session", + "move_task", + "restart_task", + "resume_deferred_git_startup", + "set_tasks_feature_enabled" + ] + }, + { + "armedValue": "enabled and publishable/needs_agent", + "id": "workspace-auto-publish", + "readByLoops": [ + "commands/agent_workspace_auto_publish.rs::commands/agent_workspace_auto_publish.rs:::::start_agent_workspace_auto_publish_freshness_scan@3a8d62e625ea5914" + ], + "surface": "agent_conversation_workspaces.auto_publish_enabled/publication_push_status", + "writers": [ + "set_agent_conversation_workspace_auto_publish" + ] + }, + { + "armedValue": "true", + "id": "workspace-auto-review", + "readByLoops": [ + "commands/agent_workspace_auto_review.rs::commands/agent_workspace_auto_review.rs:::::spawn_auto_review_for_workspace@a952be79d060c28f" + ], + "surface": "review_settings.require_workspace_review", + "writers": [ + "update_review_settings" + ] + } + ], "worker_task_view_allowlist": [ "id", "project_id", diff --git a/src-tauri/src/remote_server/authority_audit.rs b/src-tauri/src/remote_server/authority_audit.rs index 5558896cfa..1a11200bf1 100644 --- a/src-tauri/src/remote_server/authority_audit.rs +++ b/src-tauri/src/remote_server/authority_audit.rs @@ -155,6 +155,47 @@ pub struct Closure { pub sink_hits: BTreeSet, } +/// Persisted state read by an authority-bearing background loop as a spawn/steer predicate. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct StateSurfaceEntry { + pub id: &'static str, + pub surface: &'static str, + pub armed_value: &'static str, + pub read_by_loops: &'static [&'static str], + pub writer_markers: &'static [&'static str], +} + +/// Detector-(b)'s mechanically matched command writers. +pub fn spawn_triggering_writers( + graph: &CallGraph, + commands: impl IntoIterator, + surface: &[StateSurfaceEntry], +) -> BTreeSet { + commands + .into_iter() + .filter(|command| { + let tokens = &graph.closure([command.clone()]).tokens; + surface.iter().any(|entry| { + entry + .writer_markers + .iter() + .any(|marker| tokens.contains(*marker)) + }) + }) + .collect() +} + +/// Derived from the read sites reached by the settled authority-bearing loop inventory. +pub const SPAWN_TRIGGERING_STATE_SURFACE: &[StateSurfaceEntry] = &[ + StateSurfaceEntry { id: "ready-task", surface: "tasks.internal_status", armed_value: "Ready", read_by_loops: &["application/ready_task_scheduler.rs::application/ready_task_scheduler.rs:::::spawn_ready_task_scheduler_if_needed@57e1eb6d86c1770f"], writer_markers: &["inject_task", "restart_terminal_task_to_ready"] }, + StateSurfaceEntry { id: "pending-review-freshness", surface: "tasks.internal_status + task_status_history.entered_at + agent_runs.status", armed_value: "PendingReview with no fresh/running reviewer", read_by_loops: &["application/startup_background.rs::application/startup_background.rs:::::spawn_watchdog@8c28974ee8ca859d"], writer_markers: &["re_review_task_from_escalated", "request_task_changes_from_reviewing"] }, + StateSurfaceEntry { id: "automation-active", surface: "automations.status", armed_value: "Active", read_by_loops: &["application/startup_background.rs::application/startup_background.rs:::::spawn_automation_scheduler@c034c5fc2b8fe7b8"], writer_markers: &["resume_automation_smart", "finalize_automation"] }, + StateSurfaceEntry { id: "workspace-bridge", surface: "agent_conversation_workspaces.linked_ideation_session_id/status/mode", armed_value: "linked active plan/edit workspace", read_by_loops: &["application/startup_background.rs::application/startup_background.rs:::::spawn_agent_workspace_bridge_dispatcher@11ddd3248e57299b"], writer_markers: &["activate_agent_plan_direct_implementation", "activate_agent_task_pipeline", "close_agent_workspace_pr", "commit_agent_conversation_workspace_locally", "copy_agent_conversation_plan", "import_agent_conversation_plan", "publish_agent_conversation_workspace", "reconcile_agent_conversation_workspace_publication", "resume_deferred_git_startup", "set_agent_conversation_workspace_pr_supervision", "start_agent_conversation", "start_ralphx_work_from_ticket", "switch_agent_conversation_mode", "update_agent_conversation_workspace_from_base"] }, + StateSurfaceEntry { id: "external-event-cursor", surface: "external_events rows/cursor", armed_value: "unconsumed row", read_by_loops: &["application/startup_background.rs::application/startup_background.rs:::::spawn_agent_workspace_bridge_dispatcher@11ddd3248e57299b"], writer_markers: &["insert_event"] }, + StateSurfaceEntry { id: "workspace-auto-publish", surface: "agent_conversation_workspaces.auto_publish_enabled/publication_push_status", armed_value: "enabled and publishable/needs_agent", read_by_loops: &["commands/agent_workspace_auto_publish.rs::commands/agent_workspace_auto_publish.rs:::::start_agent_workspace_auto_publish_freshness_scan@3a8d62e625ea5914"], writer_markers: &["set_agent_conversation_workspace_auto_publish_for_state"] }, + StateSurfaceEntry { id: "workspace-auto-review", surface: "review_settings.require_workspace_review", armed_value: "true", read_by_loops: &["commands/agent_workspace_auto_review.rs::commands/agent_workspace_auto_review.rs:::::spawn_auto_review_for_workspace@a952be79d060c28f"], writer_markers: &["update_review_settings"] }, +]; + impl CallGraph { pub fn build(files: &[(String, String)]) -> Self { let mut graph = CallGraph::default(); @@ -424,7 +465,7 @@ impl<'a> FileVisitor<'a> { .cloned() .unwrap_or_else(|| ":".to_string()); let name = format!("{}::{}::{}", self.file, owner, bare_name); - self.graph.node_mut(&name); + self.graph.node_mut(&name).tokens.insert(bare_name.clone()); self.graph .definitions .entry(bare_name) diff --git a/src-tauri/src/remote_server/capability_ledger.rs b/src-tauri/src/remote_server/capability_ledger.rs index c3ac4cdcc3..5dcae9c92e 100644 --- a/src-tauri/src/remote_server/capability_ledger.rs +++ b/src-tauri/src/remote_server/capability_ledger.rs @@ -37,6 +37,12 @@ pub struct AuthorityReducingExemption { const NONE: &[Capability] = &[]; const AGENT: &[Capability] = &[Capability::AgentControl]; +const SEEDS_STATE: &[Capability] = &[Capability::SeedsSpawnTriggeringState]; +const MUTATES_CONTENT: &[Capability] = &[Capability::MutatesAgentConsumedContent]; +const AGENT_AND_CONTENT: &[Capability] = &[ + Capability::AgentControl, + Capability::MutatesAgentConsumedContent, +]; const PROCESS: &[Capability] = &[Capability::SpawnsProcess]; const CREDENTIALS: &[Capability] = &[Capability::TouchesCredentials]; const PTY: &[Capability] = &[Capability::PtyControl]; @@ -236,6 +242,142 @@ pub const MODULE_DEFAULTS: &[ModuleDefault] = &[ /// Narrow decisions which differ from their module default. pub const COMMAND_OVERRIDES: &[CommandOverride] = &[ + CommandOverride { + command: "inject_task", + policy: policy( + RiskClass::AgentControl, + SEEDS_STATE, + "detector-b: seeds internal_status=Ready consumed by the ready-task scheduler", + ), + }, + CommandOverride { + command: "resume_automation", + policy: policy( + RiskClass::AgentControl, + SEEDS_STATE, + "detector-b: restores Active automation consumed by the automation scheduler", + ), + }, + CommandOverride { + command: "finalize_automation", + policy: policy( + RiskClass::AgentControl, + SEEDS_STATE, + "detector-b: completes automation arming state consumed by the automation scheduler", + ), + }, + CommandOverride { + command: "create_task_step", + policy: policy( + RiskClass::AgentControl, + MUTATES_CONTENT, + "content-surface: creates worker-consumed task step", + ), + }, + CommandOverride { + command: "update_task_step", + policy: policy( + RiskClass::AgentControl, + MUTATES_CONTENT, + "content-surface: updates worker-consumed task step", + ), + }, + CommandOverride { + command: "create_artifact", + policy: policy( + RiskClass::AgentControl, + MUTATES_CONTENT, + "content-surface: creates worker-consumed artifact of any kind", + ), + }, + CommandOverride { + command: "update_artifact", + policy: policy( + RiskClass::AgentControl, + MUTATES_CONTENT, + "content-surface: updates worker-consumed artifact of any kind", + ), + }, + CommandOverride { + command: "add_artifact_relation", + policy: policy( + RiskClass::AgentControl, + MUTATES_CONTENT, + "content-surface: changes worker-consumed artifact relations", + ), + }, + CommandOverride { + command: "update_task_proposal", + policy: policy( + RiskClass::AgentControl, + MUTATES_CONTENT, + "content-surface: updates worker-consumed task proposal", + ), + }, + CommandOverride { + command: "approve_review", + policy: policy( + RiskClass::AgentControl, + MUTATES_CONTENT, + "content-surface: writes worker-consumed review feedback", + ), + }, + CommandOverride { + command: "reject_review", + policy: policy( + RiskClass::AgentControl, + MUTATES_CONTENT, + "content-surface: writes worker-consumed review feedback", + ), + }, + CommandOverride { + command: "request_changes", + policy: policy( + RiskClass::AgentControl, + MUTATES_CONTENT, + "content-surface: writes worker-consumed review feedback", + ), + }, + CommandOverride { + command: "reject_fix_task", + policy: policy( + RiskClass::AgentControl, + MUTATES_CONTENT, + "content-surface: writes worker-consumed fix feedback", + ), + }, + CommandOverride { + command: "approve_task_for_review", + policy: policy( + RiskClass::AgentControl, + MUTATES_CONTENT, + "content-surface: writes worker-consumed review note", + ), + }, + CommandOverride { + command: "request_task_changes_for_review", + policy: policy( + RiskClass::AgentControl, + MUTATES_CONTENT, + "content-surface: writes worker-consumed review feedback", + ), + }, + CommandOverride { + command: "request_task_changes_from_reviewing", + policy: policy( + RiskClass::AgentControl, + MUTATES_CONTENT, + "content-surface: writes worker-consumed review feedback", + ), + }, + CommandOverride { + command: "move_task", + policy: policy( + RiskClass::AgentControl, + AGENT_AND_CONTENT, + "detector-a plus content-surface: restart note is worker-consumed", + ), + }, // Audited read-only registrations plus the two Wry-monomorphic reads which cannot yet be // registered through `remote_commands!` (facade runtime genericity; deferred to PR 3.1). CommandOverride { diff --git a/src-tauri/src/remote_server/capability_ledger_tests.rs b/src-tauri/src/remote_server/capability_ledger_tests.rs index 44c6f4abe8..892f01dc71 100644 --- a/src-tauri/src/remote_server/capability_ledger_tests.rs +++ b/src-tauri/src/remote_server/capability_ledger_tests.rs @@ -3,7 +3,8 @@ use std::collections::{BTreeMap, BTreeSet}; use ralphx_remote_protocol::{class_permits, Capability, RiskClass}; use super::authority_audit::{ - closure_is_arming, load_production_sources, parse_registered_commands, CallGraph, + closure_is_arming, load_production_sources, parse_registered_commands, repo_root, + spawn_triggering_writers, CallGraph, SPAWN_TRIGGERING_STATE_SURFACE, }; use super::capability_ledger::{ policy_for, AUTHORITY_REDUCING_EXEMPTIONS, COMMAND_OVERRIDES, DECLARED_MEMBERSHIPS, @@ -19,6 +20,212 @@ fn census() -> Vec<(String, String)> { parse_registered_commands(registry_source()) } +const WORKER_AGENTS: &[&str] = &[ + "ralphx-execution-worker", + "ralphx-execution-coder", + "ralphx-execution-reviewer", + "ralphx-execution-merger", +]; + +fn yaml_mcp_tools(source: &str) -> BTreeSet { + let mut in_tools = false; + source + .lines() + .filter_map(|line| { + if line.trim() == "mcp_tools:" { + in_tools = true; + return None; + } + if in_tools && !line.starts_with(" ") { + in_tools = false; + } + in_tools + .then(|| line.trim().strip_prefix("- ").map(str::to_string)) + .flatten() + }) + .collect() +} + +fn live_mcp_tool_names() -> BTreeSet { + let dir = repo_root().join("plugins/app/ralphx-mcp-server/src"); + std::fs::read_dir(dir) + .expect("MCP source directory exists") + .flatten() + .filter(|entry| entry.path().extension().and_then(|value| value.to_str()) == Some("ts")) + .flat_map(|entry| { + std::fs::read_to_string(entry.path()) + .expect("MCP source is readable") + .lines() + .filter_map(|line| { + line.trim() + .strip_prefix("name: \"") + .and_then(|rest| rest.strip_suffix("\",")) + .map(str::to_string) + }) + .collect::>() + }) + .collect() +} + +fn agent_content_reads() -> Vec { + let live = live_mcp_tool_names(); + let mut grants = BTreeMap::>::new(); + for agent in WORKER_AGENTS { + let path = repo_root().join("agents").join(agent).join("agent.yaml"); + let tools = yaml_mcp_tools(&std::fs::read_to_string(path).expect("agent yaml readable")); + assert!( + tools.contains("get_task_context"), + "worker inclusion rule drifted for {agent}" + ); + for tool in tools + .intersection(&live) + .filter(|tool| is_content_read_tool(tool)) + { + grants + .entry(tool.clone()) + .or_default() + .push((*agent).to_string()); + } + } + grants + .into_iter() + .map(|(tool, granted_to)| { + serde_json::json!({ + "tool": tool, + "grantedTo": granted_to, + "reads": content_read_surface(&tool), + }) + }) + .collect() +} + +fn is_content_read_tool(tool: &str) -> bool { + matches!( + tool, + "get_task_context" + | "get_review_notes" + | "get_task_issues" + | "get_artifact" + | "get_artifact_version" + | "get_related_artifacts" + | "get_task_steps" + | "get_step_context" + | "get_task_diff" + | "get_task_diff_stat" + | "get_agent_task" + | "list_agent_tasks" + | "get_sub_steps" + | "get_project_analysis" + | "get_task_validation_summary" + | "search_project_artifacts" + | "get_memory" + | "search_memories" + | "get_memories_for_paths" + ) +} + +fn content_read_surface(tool: &str) -> &'static str { + if tool.contains("artifact") { + "artifacts/artifact_versions/artifact_relations" + } else if tool.contains("step") { + "task_steps" + } else if tool.contains("review") || tool.contains("issue") { + "review_notes/task_issues" + } else if tool.contains("memory") || tool.contains("memories") { + "memory_entries" + } else if tool.contains("diff") { + "task/worktree diff" + } else if tool.contains("agent_task") { + "agent_tasks" + } else if tool.contains("validation") { + "validation_runs" + } else if tool.contains("project_analysis") { + "project_analysis" + } else { + "worker TaskContext/prompt projection" + } +} + +fn agent_content_writers() -> Vec { + [ + ("create_task_step", "tauri-command", "task_steps", None), + ("update_task_step", "tauri-command", "task_steps", None), + ( + "create_artifact", + "tauri-command", + "artifacts (any kind)", + None, + ), + ( + "update_artifact", + "tauri-command", + "artifacts (any kind)", + None, + ), + ( + "add_artifact_relation", + "tauri-command", + "artifact_relations", + None, + ), + ( + "update_task_proposal", + "tauri-command", + "task proposals", + None, + ), + ("approve_review", "tauri-command", "review feedback", None), + ("reject_review", "tauri-command", "review feedback", None), + ("request_changes", "tauri-command", "review feedback", None), + ( + "reject_fix_task", + "tauri-command", + "review notes/fix feedback", + None, + ), + ( + "approve_task_for_review", + "tauri-command", + "review notes", + None, + ), + ( + "request_task_changes_for_review", + "tauri-command", + "review notes/feedback", + None, + ), + ( + "request_task_changes_from_reviewing", + "tauri-command", + "review notes/feedback", + None, + ), + ( + "move_task", + "tauri-command", + "task restart note", + Some("note"), + ), + ( + "update_task", + "tauri-command", + "task title/description", + Some("title,description — discharged by update_task_authz"), + ), + ("add_task_note", "http-handler", "task.description", None), + ] + .into_iter() + .map(|(writer, surface, writes, conditional)| { + let mut row = serde_json::json!({"writer": writer, "surface": surface, "writes": writes}); + if let Some(value) = conditional { + row["conditional"] = serde_json::json!(value); + } + row + }) + .collect() +} + fn generated_manifest() -> serde_json::Value { let rows = census(); let graph = CallGraph::build(&load_production_sources()); @@ -36,10 +243,16 @@ fn generated_manifest() -> serde_json::Value { }) }) .collect::>(); + let detector_b = spawn_triggering_writers( + &graph, + rows.iter().map(|(command, _)| command.clone()), + SPAWN_TRIGGERING_STATE_SURFACE, + ); let agent_control_floor = rows .iter() .filter_map(|(command, _)| { - closure_is_arming(&graph.closure([command.clone()])).then_some(command) + (closure_is_arming(&graph.closure([command.clone()])) || detector_b.contains(command)) + .then_some(command) }) .collect::>(); let background_loop_inventory = graph @@ -77,12 +290,22 @@ fn generated_manifest() -> serde_json::Value { .iter() .map(|(command, reason)| serde_json::json!({ "command": command, "reason": reason })) .collect::>(); + let loop_ids = graph + .loop_roots + .iter() + .map(|root| root.id.as_str()) + .collect::>(); + let spawn_triggering_state_surface = SPAWN_TRIGGERING_STATE_SURFACE.iter().map(|entry| { + assert!(entry.read_by_loops.iter().all(|id| loop_ids.contains(id)), "surface {} references a non-inventory loop", entry.id); + let writers = spawn_triggering_writers(&graph, rows.iter().map(|(command, _)| command.clone()), std::slice::from_ref(entry)); + serde_json::json!({"id": entry.id, "surface": entry.surface, "armedValue": entry.armed_value, "readByLoops": entry.read_by_loops, "writers": writers}) + }).collect::>(); serde_json::json!({ "schemaVersion": 1, "background_loop_inventory": background_loop_inventory, - "spawn_triggering_state_surface": [], - "agent_consumed_content_surface": [], + "spawn_triggering_state_surface": spawn_triggering_state_surface, + "agent_consumed_content_surface": {"reads": agent_content_reads(), "writers": agent_content_writers()}, "worker_task_view_allowlist": [ "id", "project_id", "title", "description", "internal_status", "ideation_session_id" ], @@ -92,8 +315,8 @@ fn generated_manifest() -> serde_json::Value { "agent_control_floor": agent_control_floor, "coverage": { "detectorA": "complete", - "detectorB": "pending", - "agentConsumedContent": "pending" + "detectorB": "complete", + "agentConsumedContent": "complete" } }) } @@ -192,6 +415,142 @@ fn detector_a_is_a_floor_for_agent_control() { assert_eq!(row.capabilities, &[Capability::AgentControl]); } +#[test] +fn detector_b_is_calibrated_and_floor_enforced() { + let graph = CallGraph::build(&load_production_sources()); + let rows = census(); + assert_eq!( + rows.len(), + 539, + "review the detector against the full command census" + ); + let flagged = spawn_triggering_writers( + &graph, + rows.iter().map(|(command, _)| command.clone()), + SPAWN_TRIGGERING_STATE_SURFACE, + ); + for command in ["inject_task", "resume_automation", "finalize_automation"] { + assert!( + flagged.contains(command), + "detector (b) missed canonical writer {command}" + ); + } + for command in [ + "pause_task", + "block_task", + "stop_task", + "pause_tasks_in_group", + "deny_permission_request", + "list_tasks", + "health_check", + ] { + assert!( + !flagged.contains(command), + "detector (b) false-positive: {command}" + ); + } + let modules = rows.into_iter().collect::>(); + for command in &flagged { + let row = policy_for(command, &modules[command]).expect("writer is ledgered"); + assert!( + matches!(row.class, RiskClass::AgentControl | RiskClass::Elevated), + "detector (b) writer {command} fell below AgentControl" + ); + } +} + +#[test] +fn detector_b_surface_rows_cannot_evaporate() { + let graph = CallGraph::build(&load_production_sources()); + let commands = census() + .into_iter() + .map(|(command, _)| command) + .collect::>(); + let complete = + spawn_triggering_writers(&graph, commands.clone(), SPAWN_TRIGGERING_STATE_SURFACE); + for index in 0..SPAWN_TRIGGERING_STATE_SURFACE.len() { + let mut stripped = SPAWN_TRIGGERING_STATE_SURFACE.to_vec(); + let removed = stripped.remove(index); + let reduced = spawn_triggering_writers(&graph, commands.clone(), &stripped); + assert!( + reduced.len() < complete.len(), + "removing state surface {} did not shrink its writer/floor set", + removed.id + ); + } +} + +#[test] +fn agent_consumed_content_derivation_is_calibrated() { + let reads = agent_content_reads(); + let tools = reads + .iter() + .filter_map(|row| row["tool"].as_str()) + .collect::>(); + for expected in [ + "get_task_context", + "get_review_notes", + "get_task_issues", + "get_artifact", + "get_artifact_version", + "get_related_artifacts", + "get_task_steps", + "get_step_context", + "get_task_diff", + "get_task_diff_stat", + "get_agent_task", + "list_agent_tasks", + "get_sub_steps", + "get_project_analysis", + "get_task_validation_summary", + "search_project_artifacts", + "get_memory", + "search_memories", + ] { + assert!( + tools.contains(expected), + "worker-granted live content read missing: {expected}" + ); + } + let helpers = std::fs::read_to_string(repo_root().join("src-tauri/src/http_server/helpers.rs")) + .expect("worker prompt builder source readable"); + assert!(helpers.contains("get_task_context_impl") && helpers.contains("TaskContext")); + let writers = agent_content_writers(); + assert!(writers + .iter() + .any(|row| row["writer"] == "add_task_note" && row["surface"] == "http-handler")); + assert!(writers.iter().any(|row| row["writer"] == "update_task" + && row["conditional"] + .as_str() + .is_some_and(|value| value.contains("update_task_authz")))); +} + +#[test] +fn content_surface_rows_cannot_evaporate_and_reads_are_not_writers() { + let writers = agent_content_writers(); + for index in 0..writers.len() { + let mut stripped = writers.clone(); + stripped.remove(index); + assert_eq!(stripped.len() + 1, writers.len()); + } + let names = writers + .iter() + .filter_map(|row| row["writer"].as_str()) + .collect::>(); + for absent in [ + "pause_task", + "block_task", + "stop_task", + "list_tasks", + "health_check", + ] { + assert!( + !names.contains(absent), + "non-content writer was flagged: {absent}" + ); + } +} + #[test] fn extended_deny_surface_is_not_remotely_registrable_as_read_or_operate() { let denied_modules = BTreeSet::from([ From 0fa10490536706d9795e34b0f6d974eea039b5e4 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:27:55 +0300 Subject: [PATCH 139/416] fix(remote_server): inject invoke dispatcher to fix off-main-thread auth test panics remote_server::auth_tests built a real tauri::Builder/Wry AppHandle per test, which requires an OS event loop on the main thread; running under libtest's worker threads panicked tao's EventLoopBuilder::build in 28 tests. Add a RemoteInvokeDispatcher seam so RemoteRouterState no longer hard-requires a concrete AppHandle: production wraps the real handle via TauriRemoteInvokeDispatcher, and auth tests inject a fake dispatcher instead of constructing a Wry runtime. --- src-tauri/src/remote_server/auth_tests.rs | 28 +++++++++---- src-tauri/src/remote_server/endpoints.rs | 21 ++++++++-- src-tauri/src/remote_server/invoke.rs | 48 +++++++++++++++++++---- 3 files changed, 78 insertions(+), 19 deletions(-) diff --git a/src-tauri/src/remote_server/auth_tests.rs b/src-tauri/src/remote_server/auth_tests.rs index b5fed86d4f..651d0ae7bc 100644 --- a/src-tauri/src/remote_server/auth_tests.rs +++ b/src-tauri/src/remote_server/auth_tests.rs @@ -39,6 +39,8 @@ use crate::domain::repositories::{ use crate::domain::services::key_crypto::hash_key; use crate::error::{AppError, AppResult}; use crate::infrastructure::sqlite::{run_migrations, DbConnection}; +use crate::remote_server::invoke::RemoteInvokeDispatcher; +use crate::remote_server::registry::{DispatchOutcome, RemoteInvokeError}; const TEST_ENVIRONMENT_ID: &str = "11111111-2222-3333-4444-555555555555"; @@ -55,18 +57,30 @@ pub(super) fn in_memory_auth_context() -> RemoteAuthContext { } fn router_for(context: &RemoteAuthContext) -> Router { - let app_handle = tauri::Builder::default() - .build(tauri::test::mock_context(tauri::test::noop_assets())) - .expect("test Wry app should build") - .handle() - .clone(); - authenticated_remote_routes(RemoteRouterState::new( + authenticated_remote_routes(RemoteRouterState::new_with_invoke_dispatcher( TEST_ENVIRONMENT_ID, context.clone(), - app_handle, + Arc::new(UnavailableInvokeDispatcher), )) } +struct UnavailableInvokeDispatcher; + +#[async_trait] +impl RemoteInvokeDispatcher for UnavailableInvokeDispatcher { + async fn dispatch( + &self, + _scopes: &[Scope], + _command: &str, + _args: &Value, + ) -> Result { + Err(RemoteInvokeError { + code: ralphx_remote_protocol::ErrorCode::RemoteCommandUnavailable, + message: "invoke is unavailable in auth router tests".to_string(), + }) + } +} + async fn body_json(response: Response) -> Value { let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) .await diff --git a/src-tauri/src/remote_server/endpoints.rs b/src-tauri/src/remote_server/endpoints.rs index 30857bb5fe..07ea0f6409 100644 --- a/src-tauri/src/remote_server/endpoints.rs +++ b/src-tauri/src/remote_server/endpoints.rs @@ -11,6 +11,7 @@ use ralphx_remote_protocol::{EnvironmentDescriptor, PROTOCOL_VERSION}; use serde::Serialize; use crate::remote_server::auth::RemoteAuthContext; +use crate::remote_server::invoke::{RemoteInvokeDispatcher, TauriRemoteInvokeDispatcher}; use crate::remote_server::sequencer::RemoteStreamHandle; use crate::remote_server::settings::RemoteExposureMode; use crate::remote_server::ws::{NoopLifecycleSink, SessionLifecycleSink}; @@ -29,7 +30,7 @@ pub(crate) const MIN_CLIENT_PROTOCOL: u32 = PROTOCOL_VERSION; pub(crate) struct RemoteRouterState { environment_id: Arc, auth: Arc, - app_handle: tauri::AppHandle, + invoke_dispatcher: Arc, /// The durable stream, installed at app setup when host mode is configured (P-23). /// /// `Option` because the listener and the stream have independent lifetimes by design: the @@ -44,11 +45,23 @@ impl RemoteRouterState { environment_id: impl Into>, auth: RemoteAuthContext, app_handle: tauri::AppHandle, + ) -> Self { + Self::new_with_invoke_dispatcher( + environment_id, + auth, + TauriRemoteInvokeDispatcher::shared(app_handle), + ) + } + + pub(crate) fn new_with_invoke_dispatcher( + environment_id: impl Into>, + auth: RemoteAuthContext, + invoke_dispatcher: Arc, ) -> Self { Self { environment_id: environment_id.into(), auth: Arc::new(auth), - app_handle, + invoke_dispatcher, stream: None, lifecycle: Arc::new(NoopLifecycleSink), } @@ -72,8 +85,8 @@ impl RemoteRouterState { &self.auth } - pub(crate) fn app_handle(&self) -> &tauri::AppHandle { - &self.app_handle + pub(crate) fn invoke_dispatcher(&self) -> Arc { + Arc::clone(&self.invoke_dispatcher) } pub(crate) fn stream(&self) -> Option<&RemoteStreamHandle> { diff --git a/src-tauri/src/remote_server/invoke.rs b/src-tauri/src/remote_server/invoke.rs index 06bf4f76e0..70caca9859 100644 --- a/src-tauri/src/remote_server/invoke.rs +++ b/src-tauri/src/remote_server/invoke.rs @@ -1,7 +1,10 @@ //! Bearer-authenticated command invocation over the remote facade. +use std::sync::Arc; + +use async_trait::async_trait; use axum::{extract::State, http::StatusCode, response::IntoResponse, Extension, Json}; -use ralphx_remote_protocol::ErrorCode; +use ralphx_remote_protocol::{ErrorCode, Scope}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -10,6 +13,38 @@ use crate::remote_server::endpoints::RemoteRouterState; use crate::remote_server::registry::{self, DispatchOutcome, RemoteInvokeError}; use crate::remote_server::remote_error_response; +#[async_trait] +pub(crate) trait RemoteInvokeDispatcher: Send + Sync { + async fn dispatch( + &self, + scopes: &[Scope], + command: &str, + args: &Value, + ) -> Result; +} + +pub(crate) struct TauriRemoteInvokeDispatcher { + app_handle: tauri::AppHandle, +} + +impl TauriRemoteInvokeDispatcher { + pub(crate) fn shared(app_handle: tauri::AppHandle) -> Arc { + Arc::new(Self { app_handle }) + } +} + +#[async_trait] +impl RemoteInvokeDispatcher for TauriRemoteInvokeDispatcher { + async fn dispatch( + &self, + scopes: &[Scope], + command: &str, + args: &Value, + ) -> Result { + registry::dispatch(&self.app_handle, scopes, command, args).await + } +} + /// Host-side mirror of the client wire type (C-11). #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -33,13 +68,10 @@ pub(crate) async fn invoke_handler( ) -> axum::response::Response { // Reserved for PR 1.5 deduplication. Deserializing it here keeps the v1 wire contract exact. let _request_id = request.request_id; - match registry::dispatch( - state.app_handle(), - identity.scopes.as_slice(), - &request.cmd, - &request.args, - ) - .await + match state + .invoke_dispatcher() + .dispatch(identity.scopes.as_slice(), &request.cmd, &request.args) + .await { Ok(outcome) => dispatch_outcome_response(outcome), Err(error) => invoke_error_response(error), From 0ca2933ffeb4e89ea5ae6296d723a5015d18674a Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:37:11 +0300 Subject: [PATCH 140/416] feat(remote): restore spec-literal Denied deny set + P-17g proofs + macro compile-fail fixture (items 3-5) Denied restore (item 5): 12 module defaults (agent_terminal, api_key, external_mcp, atlassian, linear, clickup, granola, provider_cli_management, harness_provider, chat_attachment, test_data, workspace_open) plus 14 command-level overrides (including the previously-missing cleanup_task and publish_agent_conversation_workspace) and the delete_* prefix now ledger Denied instead of Elevated, matching the macro's own permanent registration-rejecting class. Ledger distribution: agentControl 327 / elevated 106 / read 7 / operate 4 / denied 95. Exhaustiveness, floor, and P-17c deny-surface tests updated to assert class == Denied and the find_spec-none invariant instead of the weaker "not Read/Operate" check. P-17g proof classes (item 3): inject_task and finalize_automation prove detector (b) catches genuinely spawn-free commands that detector (a) alone misses. Verified against the real production call graph that resume_automation (via resume_automation_smart -> reopen_automation_run's chat-service redrive) and set_agent_conversation_workspace_auto_publish (via its PR-automation recovery scheduling reaching pr_merge_poller's redrive) both carry independent detector-(a) authority on the current tree -- a stronger outcome than the original spec calibration expected, documented with file:line evidence rather than forced into a false "spawn-free" assertion. All four are detector-(b) flagged, floor members, and ledgered AgentControl-or-stronger. Synthetic unregistered-loop self-test proves the staleness gate is what fails CI for an unregistered background loop and that an omitted read-site surface leaves its writer orphaned. Macro compile-fail fixture (item 4): a rustdoc doctest trio on remote_commands! itself proves the const-assert mechanism the Denied flip depends on -- a legal Elevated+capability registration compiles, an Operate+incompatible-capability registration fails to build, and a Denied registration (even with empty caps) fails to build. All three run under `cargo test --doc` (crate is rlib). This completes all six PR 1.3 completion-contract items: manifest has all six audit tables populated, coverage == {detectorA, detectorB, agentConsumedContent: complete}, agent_control_floor (108 members) contains inject_task/resume_automation/finalize_automation/ set_agent_conversation_workspace_auto_publish/reanalyze_project, declared_memberships is exactly the original two rows. Ledger suite green (15/15, serial), audit/registry/invoke focused suites green, doctest gate green (3/3 named fixtures), rustfmt clean. --- docs/generated/remote-commands.json | 218 +++++++++--------- .../src/remote_server/capability_ledger.rs | 141 +++++++++-- .../remote_server/capability_ledger_tests.rs | 212 ++++++++++++++++- src-tauri/src/remote_server/registry.rs | 48 ++++ 4 files changed, 486 insertions(+), 133 deletions(-) diff --git a/docs/generated/remote-commands.json b/docs/generated/remote-commands.json index dca215a4c4..f5b67504c4 100644 --- a/docs/generated/remote-commands.json +++ b/docs/generated/remote-commands.json @@ -1882,7 +1882,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "get_atlassian_integration_settings", "module": "atlassian_commands", "reason": "integration credential surface", @@ -1892,7 +1892,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "save_atlassian_integration_settings", "module": "atlassian_commands", "reason": "integration credential surface", @@ -1902,7 +1902,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "build_atlassian_oauth_authorization_url", "module": "atlassian_commands", "reason": "integration credential surface", @@ -1912,7 +1912,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "start_atlassian_oauth_local_callback", "module": "atlassian_commands", "reason": "integration credential surface", @@ -1922,7 +1922,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "complete_atlassian_oauth_local_callback", "module": "atlassian_commands", "reason": "integration credential surface", @@ -1932,7 +1932,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "exchange_atlassian_oauth_code", "module": "atlassian_commands", "reason": "integration credential surface", @@ -1942,7 +1942,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "validate_atlassian_integration", "module": "atlassian_commands", "reason": "integration credential surface", @@ -1952,7 +1952,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "disconnect_atlassian_integration", "module": "atlassian_commands", "reason": "integration credential surface", @@ -1962,7 +1962,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "search_atlassian_resources", "module": "atlassian_commands", "reason": "integration credential surface", @@ -1972,7 +1972,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "resolve_atlassian_resource_urls", "module": "atlassian_commands", "reason": "integration credential surface", @@ -1982,7 +1982,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "get_agent_conversation_jira_issue", "module": "atlassian_commands", "reason": "integration credential surface", @@ -1992,7 +1992,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "assign_agent_conversation_jira_issue", "module": "atlassian_commands", "reason": "integration credential surface", @@ -2002,7 +2002,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "refresh_agent_conversation_jira_issue", "module": "atlassian_commands", "reason": "integration credential surface", @@ -2012,7 +2012,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "assign_agent_conversation_jira_issue_to_me", "module": "atlassian_commands", "reason": "integration credential surface", @@ -2022,7 +2022,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "clear_agent_conversation_jira_issue", "module": "atlassian_commands", "reason": "integration credential surface", @@ -2172,7 +2172,7 @@ "capabilities": [ "deletesEntity" ], - "class": "elevated", + "class": "denied", "command": "delete_automation_run", "module": "automation_commands", "reason": "deletes a durable entity", @@ -2192,7 +2192,7 @@ "capabilities": [ "deletesEntity" ], - "class": "elevated", + "class": "denied", "command": "delete_automation", "module": "automation_commands", "reason": "deletes a durable entity", @@ -2322,7 +2322,7 @@ "capabilities": [ "deletesEntity" ], - "class": "elevated", + "class": "denied", "command": "delete_persona_draft", "module": "persona_commands", "reason": "deletes a durable entity", @@ -2332,7 +2332,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "get_agent_conversation_linear_issue", "module": "linear_commands", "reason": "integration credential surface", @@ -2342,7 +2342,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "assign_agent_conversation_linear_issue", "module": "linear_commands", "reason": "integration credential surface", @@ -2352,7 +2352,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "refresh_agent_conversation_linear_issue", "module": "linear_commands", "reason": "integration credential surface", @@ -2362,7 +2362,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "clear_agent_conversation_linear_issue", "module": "linear_commands", "reason": "integration credential surface", @@ -2477,10 +2477,10 @@ "capabilities": [ "agentControl" ], - "class": "agentControl", + "class": "denied", "command": "cleanup_task", "module": "task_commands", - "reason": "conservative-module-default: may steer or arm autonomous work", + "reason": "destructive task cleanup", "registered": false }, { @@ -2837,10 +2837,10 @@ "capabilities": [ "spawnsProcess" ], - "class": "elevated", + "class": "denied", "command": "get_git_branches", "module": "project_commands", - "reason": "project git/gh and deferred shell authority", + "reason": "spawns git over project-controlled state", "registered": false }, { @@ -2887,10 +2887,10 @@ "capabilities": [ "spawnsProcess" ], - "class": "elevated", + "class": "denied", "command": "update_custom_analysis", "module": "project_commands", - "reason": "project git/gh and deferred shell authority", + "reason": "executes the canonical deferred shell-authority shape", "registered": false }, { @@ -2917,30 +2917,30 @@ "capabilities": [ "spawnsProcess" ], - "class": "elevated", + "class": "denied", "command": "switch_git_origin_to_ssh", "module": "project_commands", - "reason": "project git/gh and deferred shell authority", + "reason": "changes repository origin authentication", "registered": false }, { "capabilities": [ "spawnsProcess" ], - "class": "elevated", + "class": "denied", "command": "setup_gh_git_auth", "module": "project_commands", - "reason": "project git/gh and deferred shell authority", + "reason": "configures git credential authority", "registered": false }, { "capabilities": [ "spawnsProcess" ], - "class": "elevated", + "class": "denied", "command": "login_gh_with_browser", "module": "project_commands", - "reason": "project git/gh and deferred shell authority", + "reason": "starts interactive GitHub authentication", "registered": false }, { @@ -3057,7 +3057,7 @@ "capabilities": [ "deletesEntity" ], - "class": "elevated", + "class": "denied", "command": "delete_custom_agent_model", "module": "agent_model_commands", "reason": "deletes a durable entity", @@ -3677,10 +3677,10 @@ "capabilities": [ "spawnsProcess" ], - "class": "elevated", + "class": "denied", "command": "build_agent_issue_report", "module": "agent_issue_report_commands", - "reason": "report construction may spawn diagnostics", + "reason": "spawns diagnostic report tooling", "registered": false }, { @@ -3987,7 +3987,7 @@ "capabilities": [ "deletesEntity" ], - "class": "elevated", + "class": "denied", "command": "delete_task_proposal", "module": "ideation_commands", "reason": "deletes a durable entity", @@ -4177,7 +4177,7 @@ "capabilities": [ "deletesEntity" ], - "class": "elevated", + "class": "denied", "command": "delete_chat_message", "module": "ideation_commands", "reason": "deletes a durable entity", @@ -4187,7 +4187,7 @@ "capabilities": [ "deletesEntity" ], - "class": "elevated", + "class": "denied", "command": "delete_session_messages", "module": "ideation_commands", "reason": "deletes a durable entity", @@ -4377,7 +4377,7 @@ "capabilities": [ "configuresFutureProcessAuthority" ], - "class": "elevated", + "class": "denied", "command": "get_agent_provider_settings", "module": "harness_provider_commands", "reason": "configures future provider process authority", @@ -4387,7 +4387,7 @@ "capabilities": [ "configuresFutureProcessAuthority" ], - "class": "elevated", + "class": "denied", "command": "update_agent_provider_settings", "module": "harness_provider_commands", "reason": "configures future provider process authority", @@ -4467,7 +4467,7 @@ "capabilities": [ "spawnsProcess" ], - "class": "elevated", + "class": "denied", "command": "get_managed_provider_cli_status", "module": "provider_cli_management_commands", "reason": "provider CLI installer surface", @@ -4477,7 +4477,7 @@ "capabilities": [ "spawnsProcess" ], - "class": "elevated", + "class": "denied", "command": "install_or_update_managed_provider_cli", "module": "provider_cli_management_commands", "reason": "provider CLI installer surface", @@ -4487,7 +4487,7 @@ "capabilities": [ "spawnsProcess" ], - "class": "elevated", + "class": "denied", "command": "auto_update_managed_provider_clis", "module": "provider_cli_management_commands", "reason": "provider CLI installer surface", @@ -4637,7 +4637,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "get_linear_integration_settings", "module": "linear_commands", "reason": "integration credential surface", @@ -4647,7 +4647,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "get_linear_webhook_config", "module": "linear_commands", "reason": "integration credential surface", @@ -4657,7 +4657,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "save_linear_integration_settings", "module": "linear_commands", "reason": "integration credential surface", @@ -4667,7 +4667,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "save_linear_webhook_signing_secret", "module": "linear_commands", "reason": "integration credential surface", @@ -4677,7 +4677,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "search_linear_issues", "module": "linear_commands", "reason": "integration credential surface", @@ -4687,7 +4687,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "validate_linear_integration", "module": "linear_commands", "reason": "integration credential surface", @@ -4697,7 +4697,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "disconnect_linear_integration", "module": "linear_commands", "reason": "integration credential surface", @@ -4707,7 +4707,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "get_clickup_integration_settings", "module": "clickup_commands", "reason": "integration credential surface", @@ -4717,7 +4717,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "save_clickup_integration_settings", "module": "clickup_commands", "reason": "integration credential surface", @@ -4727,7 +4727,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "validate_clickup_integration", "module": "clickup_commands", "reason": "integration credential surface", @@ -4737,7 +4737,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "disconnect_clickup_integration", "module": "clickup_commands", "reason": "integration credential surface", @@ -4747,7 +4747,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "list_clickup_workspaces", "module": "clickup_commands", "reason": "integration credential surface", @@ -4757,7 +4757,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "search_clickup_tasks", "module": "clickup_commands", "reason": "integration credential surface", @@ -4767,7 +4767,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "assign_agent_conversation_granola_note", "module": "granola_commands", "reason": "integration credential surface", @@ -4777,7 +4777,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "clear_agent_conversation_granola_note", "module": "granola_commands", "reason": "integration credential surface", @@ -4787,7 +4787,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "get_agent_conversation_granola_note", "module": "granola_commands", "reason": "integration credential surface", @@ -4797,7 +4797,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "get_granola_integration_settings", "module": "granola_commands", "reason": "integration credential surface", @@ -4807,7 +4807,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "get_granola_note_detail", "module": "granola_commands", "reason": "integration credential surface", @@ -4817,7 +4817,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "list_granola_notes", "module": "granola_commands", "reason": "integration credential surface", @@ -4827,7 +4827,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "refresh_agent_conversation_granola_note", "module": "granola_commands", "reason": "integration credential surface", @@ -4837,7 +4837,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "save_granola_integration_settings", "module": "granola_commands", "reason": "integration credential surface", @@ -4847,7 +4847,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "validate_granola_integration_settings", "module": "granola_commands", "reason": "integration credential surface", @@ -5117,7 +5117,7 @@ "capabilities": [ "deletesEntity" ], - "class": "elevated", + "class": "denied", "command": "seed_test_data", "module": "test_data_commands", "reason": "test-data mutation is never remotely operable", @@ -5127,7 +5127,7 @@ "capabilities": [ "deletesEntity" ], - "class": "elevated", + "class": "denied", "command": "seed_visual_audit_data", "module": "test_data_commands", "reason": "test-data mutation is never remotely operable", @@ -5137,7 +5137,7 @@ "capabilities": [ "deletesEntity" ], - "class": "elevated", + "class": "denied", "command": "clear_test_data", "module": "test_data_commands", "reason": "test-data mutation is never remotely operable", @@ -5347,7 +5347,7 @@ "capabilities": [ "deletesEntity" ], - "class": "elevated", + "class": "denied", "command": "delete_queued_agent_message", "module": "unified_chat_commands", "reason": "deletes a durable entity", @@ -5547,10 +5547,10 @@ "capabilities": [ "agentControl" ], - "class": "agentControl", + "class": "denied", "command": "publish_agent_conversation_workspace", "module": "unified_chat_commands", - "reason": "conservative-module-default: may steer or arm autonomous work", + "reason": "publishes an agent conversation workspace", "registered": false }, { @@ -5707,7 +5707,7 @@ "capabilities": [ "ptyControl" ], - "class": "elevated", + "class": "denied", "command": "open_agent_terminal", "module": "agent_terminal_commands", "reason": "terminal PTY control", @@ -5717,7 +5717,7 @@ "capabilities": [ "ptyControl" ], - "class": "elevated", + "class": "denied", "command": "write_agent_terminal", "module": "agent_terminal_commands", "reason": "terminal PTY control", @@ -5727,7 +5727,7 @@ "capabilities": [ "ptyControl" ], - "class": "elevated", + "class": "denied", "command": "resize_agent_terminal", "module": "agent_terminal_commands", "reason": "terminal PTY control", @@ -5737,7 +5737,7 @@ "capabilities": [ "ptyControl" ], - "class": "elevated", + "class": "denied", "command": "clear_agent_terminal", "module": "agent_terminal_commands", "reason": "terminal PTY control", @@ -5747,7 +5747,7 @@ "capabilities": [ "ptyControl" ], - "class": "elevated", + "class": "denied", "command": "restart_agent_terminal", "module": "agent_terminal_commands", "reason": "terminal PTY control", @@ -5757,7 +5757,7 @@ "capabilities": [ "ptyControl" ], - "class": "elevated", + "class": "denied", "command": "close_agent_terminal", "module": "agent_terminal_commands", "reason": "terminal PTY control", @@ -5767,7 +5767,7 @@ "capabilities": [ "writesArbitraryPath" ], - "class": "elevated", + "class": "denied", "command": "upload_chat_attachment", "module": "chat_attachment_commands", "reason": "attachment filesystem surface", @@ -5777,7 +5777,7 @@ "capabilities": [ "writesArbitraryPath" ], - "class": "elevated", + "class": "denied", "command": "link_attachments_to_message", "module": "chat_attachment_commands", "reason": "attachment filesystem surface", @@ -5787,7 +5787,7 @@ "capabilities": [ "writesArbitraryPath" ], - "class": "elevated", + "class": "denied", "command": "list_conversation_attachments", "module": "chat_attachment_commands", "reason": "attachment filesystem surface", @@ -5797,7 +5797,7 @@ "capabilities": [ "writesArbitraryPath" ], - "class": "elevated", + "class": "denied", "command": "list_message_attachments", "module": "chat_attachment_commands", "reason": "attachment filesystem surface", @@ -5807,7 +5807,7 @@ "capabilities": [ "deletesEntity" ], - "class": "elevated", + "class": "denied", "command": "delete_chat_attachment", "module": "chat_attachment_commands", "reason": "deletes a durable entity", @@ -5897,20 +5897,20 @@ "capabilities": [ "spawnsProcess" ], - "class": "elevated", + "class": "denied", "command": "get_task_file_changes", "module": "diff_commands", - "reason": "diff getters may spawn git", + "reason": "spawns git for task file changes", "registered": false }, { "capabilities": [ "spawnsProcess" ], - "class": "elevated", + "class": "denied", "command": "get_file_diff", "module": "diff_commands", - "reason": "diff getters may spawn git", + "reason": "spawns git for an arbitrary file diff", "registered": false }, { @@ -6207,10 +6207,10 @@ "capabilities": [ "spawnsProcess" ], - "class": "elevated", + "class": "denied", "command": "resolve_merge_conflict", "module": "git_commands", - "reason": "git process and worktree authority", + "reason": "destructive merge-conflict resolution", "registered": false }, { @@ -6227,20 +6227,20 @@ "capabilities": [ "spawnsProcess" ], - "class": "elevated", + "class": "denied", "command": "cleanup_task_branch", "module": "git_commands", - "reason": "git process and worktree authority", + "reason": "destructive task branch cleanup", "registered": false }, { "capabilities": [ "spawnsProcess" ], - "class": "elevated", + "class": "denied", "command": "change_project_git_mode", "module": "git_commands", - "reason": "git process and worktree authority", + "reason": "changes project git authority", "registered": false }, { @@ -6407,7 +6407,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "list_api_keys", "module": "api_key_commands", "reason": "credential surface", @@ -6417,7 +6417,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "create_api_key", "module": "api_key_commands", "reason": "credential surface", @@ -6427,7 +6427,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "revoke_api_key", "module": "api_key_commands", "reason": "credential surface", @@ -6437,7 +6437,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "rotate_api_key", "module": "api_key_commands", "reason": "credential surface", @@ -6447,7 +6447,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "update_api_key_projects", "module": "api_key_commands", "reason": "credential surface", @@ -6457,7 +6457,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "update_api_key_permissions", "module": "api_key_commands", "reason": "credential surface", @@ -6467,7 +6467,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "get_api_key_audit_log", "module": "api_key_commands", "reason": "credential surface", @@ -6487,10 +6487,10 @@ "capabilities": [ "spawnsProcess" ], - "class": "elevated", + "class": "denied", "command": "get_codex_cli_diagnostics", "module": "diagnostic_commands", - "reason": "diagnostics may spawn provider CLIs", + "reason": "spawns the Codex CLI for diagnostics", "registered": false }, { @@ -6717,7 +6717,7 @@ "capabilities": [ "spawnsProcess" ], - "class": "elevated", + "class": "denied", "command": "list_workspace_open_targets", "module": "workspace_open_commands", "reason": "opens workspace in an external process", @@ -6727,7 +6727,7 @@ "capabilities": [ "spawnsProcess" ], - "class": "elevated", + "class": "denied", "command": "open_agent_conversation_workspace", "module": "workspace_open_commands", "reason": "opens workspace in an external process", @@ -6737,7 +6737,7 @@ "capabilities": [ "spawnsProcess" ], - "class": "elevated", + "class": "denied", "command": "open_agent_conversation_workspace_path", "module": "workspace_open_commands", "reason": "opens workspace in an external process", @@ -6747,7 +6747,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "get_external_mcp_config", "module": "external_mcp_commands", "reason": "external MCP credential surface", @@ -6757,7 +6757,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "get_external_mcp_readiness", "module": "external_mcp_commands", "reason": "external MCP credential surface", @@ -6767,7 +6767,7 @@ "capabilities": [ "touchesCredentials" ], - "class": "elevated", + "class": "denied", "command": "update_external_mcp_config", "module": "external_mcp_commands", "reason": "external MCP credential surface", diff --git a/src-tauri/src/remote_server/capability_ledger.rs b/src-tauri/src/remote_server/capability_ledger.rs index 5dcae9c92e..68862682b0 100644 --- a/src-tauri/src/remote_server/capability_ledger.rs +++ b/src-tauri/src/remote_server/capability_ledger.rs @@ -85,6 +85,17 @@ const fn elevated_default( } } +const fn denied_default( + module: &'static str, + capabilities: &'static [Capability], + reason: &'static str, +) -> ModuleDefault { + ModuleDefault { + module, + policy: policy(RiskClass::Denied, capabilities, reason), + } +} + /// Every module in the live registry must have exactly one default here. pub const MODULE_DEFAULTS: &[ModuleDefault] = &[ agent_default("root"), @@ -99,21 +110,21 @@ pub const MODULE_DEFAULTS: &[ModuleDefault] = &[ agent_default("agent_plan_commands"), agent_default("agent_profile_commands"), agent_default("agent_sidebar_commands"), - elevated_default("agent_terminal_commands", PTY, "terminal PTY control"), - elevated_default("api_key_commands", CREDENTIALS, "credential surface"), + denied_default("agent_terminal_commands", PTY, "terminal PTY control"), + denied_default("api_key_commands", CREDENTIALS, "credential surface"), agent_default("artifact_commands"), - elevated_default( + denied_default( "atlassian_commands", CREDENTIALS, "integration credential surface", ), agent_default("automation_commands"), - elevated_default( + denied_default( "chat_attachment_commands", PATH, "attachment filesystem surface", ), - elevated_default( + denied_default( "clickup_commands", CREDENTIALS, "integration credential surface", @@ -127,7 +138,7 @@ pub const MODULE_DEFAULTS: &[ModuleDefault] = &[ ), elevated_default("diff_commands", PROCESS, "diff getters may spawn git"), agent_default("execution_commands"), - elevated_default( + denied_default( "external_mcp_commands", CREDENTIALS, "external MCP credential surface", @@ -142,19 +153,19 @@ pub const MODULE_DEFAULTS: &[ModuleDefault] = &[ PROCESS, "GitHub CLI/network process authority", ), - elevated_default( + denied_default( "granola_commands", CREDENTIALS, "integration credential surface", ), - elevated_default( + denied_default( "harness_provider_commands", FUTURE_PROCESS, "configures future provider process authority", ), agent_default("health"), agent_default("ideation_commands"), - elevated_default( + denied_default( "linear_commands", CREDENTIALS, "integration credential surface", @@ -178,7 +189,7 @@ pub const MODULE_DEFAULTS: &[ModuleDefault] = &[ PROCESS, "project git/gh and deferred shell authority", ), - elevated_default( + denied_default( "provider_cli_management_commands", PROCESS, "provider CLI installer surface", @@ -217,7 +228,7 @@ pub const MODULE_DEFAULTS: &[ModuleDefault] = &[ agent_default("task_commands"), agent_default("task_context_commands"), agent_default("task_step_commands"), - elevated_default( + denied_default( "test_data_commands", DELETE, "test-data mutation is never remotely operable", @@ -232,7 +243,7 @@ pub const MODULE_DEFAULTS: &[ModuleDefault] = &[ agent_default("update_channel_commands"), agent_default("validation_commands"), agent_default("workflow_commands"), - elevated_default( + denied_default( "workspace_open_commands", PROCESS, "opens workspace in an external process", @@ -378,6 +389,110 @@ pub const COMMAND_OVERRIDES: &[CommandOverride] = &[ "detector-a plus content-surface: restart note is worker-consumed", ), }, + CommandOverride { + command: "switch_git_origin_to_ssh", + policy: policy( + RiskClass::Denied, + PROCESS, + "changes repository origin authentication", + ), + }, + CommandOverride { + command: "setup_gh_git_auth", + policy: policy( + RiskClass::Denied, + PROCESS, + "configures git credential authority", + ), + }, + CommandOverride { + command: "login_gh_with_browser", + policy: policy( + RiskClass::Denied, + PROCESS, + "starts interactive GitHub authentication", + ), + }, + CommandOverride { + command: "update_custom_analysis", + policy: policy( + RiskClass::Denied, + PROCESS, + "executes the canonical deferred shell-authority shape", + ), + }, + CommandOverride { + command: "change_project_git_mode", + policy: policy(RiskClass::Denied, PROCESS, "changes project git authority"), + }, + CommandOverride { + command: "get_git_branches", + policy: policy( + RiskClass::Denied, + PROCESS, + "spawns git over project-controlled state", + ), + }, + CommandOverride { + command: "resolve_merge_conflict", + policy: policy( + RiskClass::Denied, + PROCESS, + "destructive merge-conflict resolution", + ), + }, + CommandOverride { + command: "cleanup_task_branch", + policy: policy( + RiskClass::Denied, + PROCESS, + "destructive task branch cleanup", + ), + }, + CommandOverride { + command: "cleanup_task", + policy: policy(RiskClass::Denied, AGENT, "destructive task cleanup"), + }, + CommandOverride { + command: "publish_agent_conversation_workspace", + policy: policy( + RiskClass::Denied, + AGENT, + "publishes an agent conversation workspace", + ), + }, + CommandOverride { + command: "get_task_file_changes", + policy: policy( + RiskClass::Denied, + PROCESS, + "spawns git for task file changes", + ), + }, + CommandOverride { + command: "get_file_diff", + policy: policy( + RiskClass::Denied, + PROCESS, + "spawns git for an arbitrary file diff", + ), + }, + CommandOverride { + command: "get_codex_cli_diagnostics", + policy: policy( + RiskClass::Denied, + PROCESS, + "spawns the Codex CLI for diagnostics", + ), + }, + CommandOverride { + command: "build_agent_issue_report", + policy: policy( + RiskClass::Denied, + PROCESS, + "spawns diagnostic report tooling", + ), + }, // Audited read-only registrations plus the two Wry-monomorphic reads which cannot yet be // registered through `remote_commands!` (facade runtime genericity; deferred to PR 3.1). CommandOverride { @@ -555,7 +670,7 @@ pub fn policy_for(command: &str, module: &str) -> Option { } if command.starts_with("delete_") { return Some(policy( - RiskClass::Elevated, + RiskClass::Denied, DELETE, "deletes a durable entity", )); diff --git a/src-tauri/src/remote_server/capability_ledger_tests.rs b/src-tauri/src/remote_server/capability_ledger_tests.rs index 892f01dc71..c302525059 100644 --- a/src-tauri/src/remote_server/capability_ledger_tests.rs +++ b/src-tauri/src/remote_server/capability_ledger_tests.rs @@ -4,7 +4,7 @@ use ralphx_remote_protocol::{class_permits, Capability, RiskClass}; use super::authority_audit::{ closure_is_arming, load_production_sources, parse_registered_commands, repo_root, - spawn_triggering_writers, CallGraph, SPAWN_TRIGGERING_STATE_SURFACE, + spawn_triggering_writers, CallGraph, StateSurfaceEntry, SPAWN_TRIGGERING_STATE_SURFACE, }; use super::capability_ledger::{ policy_for, AUTHORITY_REDUCING_EXEMPTIONS, COMMAND_OVERRIDES, DECLARED_MEMBERSHIPS, @@ -366,10 +366,17 @@ fn capability_ledger_is_exhaustive_and_internally_consistent() { let row = policy_for(&command, &module).unwrap_or_else(|| { panic!("classify this command: `{command}` (unknown module `{module}`)") }); - assert!( - class_permits(row.class, row.capabilities), - "ledger class/capability mismatch for `{command}`" - ); + if row.class == RiskClass::Denied { + assert!( + find_spec(&command).is_none(), + "Denied ledger row `{command}` must not be registered" + ); + } else { + assert!( + class_permits(row.class, row.capabilities), + "ledger class/capability mismatch for `{command}`" + ); + } } let defaults = MODULE_DEFAULTS @@ -401,7 +408,10 @@ fn detector_a_is_a_floor_for_agent_control() { floor.insert(command.clone()); let row = policy_for(&command, &module).expect("census is ledgered"); assert!( - matches!(row.class, RiskClass::AgentControl | RiskClass::Elevated), + matches!( + row.class, + RiskClass::AgentControl | RiskClass::Elevated | RiskClass::Denied + ), "detector (a) classifies `{command}` as authority-bearing; ledger must be AgentControl or stronger" ); } @@ -453,12 +463,189 @@ fn detector_b_is_calibrated_and_floor_enforced() { for command in &flagged { let row = policy_for(command, &modules[command]).expect("writer is ledgered"); assert!( - matches!(row.class, RiskClass::AgentControl | RiskClass::Elevated), + matches!( + row.class, + RiskClass::AgentControl | RiskClass::Elevated | RiskClass::Denied + ), "detector (b) writer {command} fell below AgentControl" ); } } +#[test] +fn detector_b_proof_classes_are_flagged_and_in_the_floor() { + let graph = CallGraph::build(&load_production_sources()); + let rows = census(); + let commands = rows + .iter() + .map(|(command, _)| command.clone()) + .collect::>(); + let detector_b = spawn_triggering_writers(&graph, commands, SPAWN_TRIGGERING_STATE_SURFACE); + let manifest = generated_manifest(); + let floor = manifest["agent_control_floor"] + .as_array() + .expect("generated floor is an array") + .iter() + .filter_map(serde_json::Value::as_str) + .collect::>(); + let modules = rows.into_iter().collect::>(); + + // Detector-(a)-alone spawn-free status varies per command as the production call graph + // evolves. The invariant here is detector-(b) classification, floor membership, and + // AgentControl-or-stronger ledgering regardless of detector-(a). inject_task and + // finalize_automation also prove detector-(b) catches genuinely spawn-free commands that + // detector-(a) misses; resume_automation and the auto-publish bridge writer independently + // carry detector-(a) authority, which is a stronger outcome, not a gap. + for command in [ + "inject_task", + "finalize_automation", + "resume_automation", + "set_agent_conversation_workspace_auto_publish", + ] { + assert!( + detector_b.contains(command), + "detector (b) missed proof-class writer {command}" + ); + assert!( + floor.contains(command), + "generated AgentControl floor missed {command}" + ); + let row = policy_for(command, &modules[command]).expect("proof-class writer is ledgered"); + assert!( + matches!( + row.class, + RiskClass::AgentControl | RiskClass::Elevated | RiskClass::Denied + ), + "proof-class writer {command} fell below AgentControl" + ); + } + + for command in ["inject_task", "finalize_automation"] { + assert!( + !closure_is_arming(&graph.closure([command.to_string()])), + "{command} must demonstrate detector (b), not detector (a)" + ); + } + + // automation_commands.rs:313 reaches startup_background.rs:366 through reopen redrive, so + // resume_automation has real detector-(a) send_message authority as well as detector-(b). + assert!( + closure_is_arming(&graph.closure(["resume_automation".to_string()])), + "resume_automation must retain its stronger detector-(a) classification" + ); + // unified_chat_commands/mod.rs:5277 returns through agent_workspace_response_for_state; + // its :1038 recovery scheduling reaches pr_merge_poller.rs:2616 send_message authority. + assert!( + closure_is_arming( + &graph.closure(["set_agent_conversation_workspace_auto_publish".to_string(),]) + ), + "auto-publish must retain its stronger detector-(a) classification" + ); + + for command in [ + "pause_task", + "block_task", + "stop_task", + "pause_tasks_in_group", + "deny_permission_request", + "list_tasks", + "get_task", + "search_tasks", + "health_check", + ] { + assert!( + !closure_is_arming(&graph.closure([command.to_string()])), + "brake/read {command} was falsely flagged by detector (a)" + ); + assert!( + !detector_b.contains(command), + "brake/read {command} was falsely flagged by detector (b)" + ); + } +} + +#[test] +fn synthetic_unregistered_authority_loop_requires_a_surface_tie_and_stales_manifest() { + let manifest: serde_json::Value = + serde_json::from_str(include_str!("../../../docs/generated/remote-commands.json")) + .expect("checked-in manifest parses"); + let manifest_loop_ids = manifest["background_loop_inventory"] + .as_array() + .expect("background loop inventory is an array") + .iter() + .filter_map(|row| row["id"].as_str()) + .collect::>(); + + let mut sources = load_production_sources(); + sources.push(( + "synthetic/unregistered_interval.rs".to_string(), + r#" + fn synthetic_unregistered_interval() { + tokio::spawn(async move { + let mut interval = tokio::time::interval(duration()); + loop { + interval.tick().await; + read_synthetic_armed_state(); + send_message(); + } + }); + } + fn synthetic_writer() { + write_synthetic_armed_state(); + } + "# + .to_string(), + )); + let graph = CallGraph::build(&sources); + let synthetic_root = graph + .loop_roots + .iter() + .find(|root| root.file == "synthetic/unregistered_interval.rs") + .expect("synthetic interval loop is discovered"); + assert!( + closure_is_arming(&graph.loop_closure(synthetic_root)), + "synthetic send_message loop is authority-bearing" + ); + assert!( + !manifest_loop_ids.contains(synthetic_root.id.as_str()), + "an unregistered production loop must make the checked-in inventory stale" + ); + + let leaked_id: &'static str = Box::leak(synthetic_root.id.clone().into_boxed_str()); + let read_by_loops: &'static [&'static str] = Box::leak(Box::new([leaked_id])); + let synthetic_surface = StateSurfaceEntry { + id: "synthetic-armed-state", + surface: "synthetic.armed_state", + armed_value: "true", + read_by_loops, + writer_markers: &["write_synthetic_armed_state"], + }; + let without_surface = spawn_triggering_writers( + &graph, + ["synthetic_writer".to_string()], + SPAWN_TRIGGERING_STATE_SURFACE, + ); + assert!( + !without_surface.contains("synthetic_writer"), + "an omitted read-site surface leaves its writer orphaned" + ); + let with_surface = spawn_triggering_writers( + &graph, + ["synthetic_writer".to_string()], + std::slice::from_ref(&synthetic_surface), + ); + assert!( + synthetic_surface + .read_by_loops + .contains(&synthetic_root.id.as_str()), + "synthetic surface must tie its read site to the discovered loop" + ); + assert!( + with_surface.contains("synthetic_writer"), + "adding the required surface tie must classify its arming writer" + ); +} + #[test] fn detector_b_surface_rows_cannot_evaporate() { let graph = CallGraph::build(&load_production_sources()); @@ -552,7 +739,7 @@ fn content_surface_rows_cannot_evaporate_and_reads_are_not_writers() { } #[test] -fn extended_deny_surface_is_not_remotely_registrable_as_read_or_operate() { +fn extended_deny_surface_is_denied_and_not_remotely_registrable() { let denied_modules = BTreeSet::from([ "agent_terminal_commands", "api_key_commands", @@ -576,6 +763,8 @@ fn extended_deny_surface_is_not_remotely_registrable_as_read_or_operate() { "change_project_git_mode", "resolve_merge_conflict", "cleanup_task_branch", + "cleanup_task", + "publish_agent_conversation_workspace", "get_task_file_changes", "get_file_diff", "get_codex_cli_diagnostics", @@ -588,9 +777,10 @@ fn extended_deny_surface_is_not_remotely_registrable_as_read_or_operate() { || command.starts_with("delete_"); if named { let row = policy_for(&command, &module).expect("deny entry is ledgered"); - assert!( - !matches!(row.class, RiskClass::Read | RiskClass::Operate), - "P-17c deny surface `{module}::{command}` is remotely registrable below elevated authority" + assert_eq!( + row.class, + RiskClass::Denied, + "P-17c deny surface `{module}::{command}` must be Denied" ); assert!( find_spec(&command).is_none(), diff --git a/src-tauri/src/remote_server/registry.rs b/src-tauri/src/remote_server/registry.rs index 1c61031a83..583051c78e 100644 --- a/src-tauri/src/remote_server/registry.rs +++ b/src-tauri/src/remote_server/registry.rs @@ -165,6 +165,54 @@ pub fn serialize_ok(value: T) -> Result legal_target { +/// class: Elevated, +/// caps: [ConfiguresFutureProcessAuthority], +/// params: [], +/// call: async, +/// result: infallible, +/// }, +/// } +/// ``` +/// +/// The macro-emitted const assertion rejects capabilities unavailable to the declared class: +/// +/// ```compile_fail +/// async fn capability_mismatch_target() {} +/// +/// ralphx_lib::remote_commands! { +/// "capability_mismatch_fixture" => capability_mismatch_target { +/// class: Operate, +/// caps: [ConfiguresFutureProcessAuthority], +/// params: [], +/// call: async, +/// result: infallible, +/// }, +/// } +/// ``` +/// +/// `Denied` is unregistrable even with an empty capability set: +/// +/// ```compile_fail +/// async fn denied_target() {} +/// +/// ralphx_lib::remote_commands! { +/// "denied_fixture" => denied_target { +/// class: Denied, +/// caps: [], +/// params: [], +/// call: async, +/// result: infallible, +/// }, +/// } +/// ``` #[macro_export] macro_rules! remote_commands { ( From 929108008d63dd9edc1c3ba233172443a4069917 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:11:39 +0300 Subject: [PATCH 141/416] fix(remote): gate cursor validity on a committed cold-hydrate snapshot The cold path published lastSeq/lastAckedSeq/cursorValid at H before awaiting hydrate(), so a socket drop mid-hydration left a resumable cursor for a snapshot that never loaded and the next attempt warm-resumed over the gap (P-24a). The hydration barrier itself could not fail either: TanStack swallows every refetch rejection unless throwOnError, so a 500ing host resolved it over an empty board. - publish cursorValid only once hydrate() resolved, and discard the cursor when the socket closes while hydrating - re-check the attempt generation after the first cursorAck so a reset landing during the ack write cannot resurrect the stream to live - hydrate with { refetchType: "all" }, { throwOnError: true } so the barrier fails closed --- .../lib/remote/environment-runtime.test.ts | 37 +++++++- .../src/lib/remote/environment-runtime.ts | 10 ++- .../src/lib/remote/network-event-bus.test.ts | 88 +++++++++++++++++++ frontend/src/lib/remote/network-event-bus.ts | 21 ++++- 4 files changed, 152 insertions(+), 4 deletions(-) diff --git a/frontend/src/lib/remote/environment-runtime.test.ts b/frontend/src/lib/remote/environment-runtime.test.ts index c8f527e853..3312a16755 100644 --- a/frontend/src/lib/remote/environment-runtime.test.ts +++ b/frontend/src/lib/remote/environment-runtime.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { RemoteEnvironmentSummary } from "@/api/remote-environments"; import { createEventBus, type EventBus } from "@/lib/event-bus"; -import { resetQueryClient } from "@/lib/queryClient"; +import { getQueryClient, resetQueryClient } from "@/lib/queryClient"; import { LOCAL_ENVIRONMENT_ID, useEnvironmentStore, @@ -71,6 +71,19 @@ vi.mock("./network-fetch", () => ({ networkFetch: vi.fn(), })); +vi.mock("#tauri-core-primitive", () => ({ + invoke: vi.fn(async () => undefined), +})); + +const OUTCOME = { + environmentId: "env-b", + hostEnvironmentId: "host-env-b", + streamEpoch: "epoch-1", + maxSeq: 100, + heartbeatSecs: 20, + protocolVersion: 1, +}; + function summary(id: string): RemoteEnvironmentSummary { return { id, @@ -177,6 +190,28 @@ describe("environment runtime composition", () => { expect(supervisors[0]?.starts).toBeGreaterThan(1); }); + it("fails the hydration barrier when the snapshot refetch rejects", async () => { + const { initializeEnvironmentRuntime } = await import("./environment-runtime"); + useEnvironmentStore.getState().setEnvironments([summary("env-b")]); + setFlag(true); + teardown = initializeEnvironmentRuntime(); + useEnvironmentStore.setState({ activeEnvironmentId: "env-b" }); + + const client = getQueryClient("env-b"); + const invalidate = vi + .spyOn(client, "invalidateQueries") + .mockRejectedValue(new Error("host answered 500")); + + // A swallowed refetch failure would resolve the §3.4 barrier over an empty board. + await expect( + supervisors[supervisors.length - 1]?.deps.beginStream(OUTCOME) + ).rejects.toThrow(/500/); + expect(invalidate).toHaveBeenCalledWith( + { refetchType: "all" }, + { throwOnError: true } + ); + }); + it("uses pairing scopes in background without a session fetch and records them", async () => { const { getConfirmedScopes, initializeEnvironmentRuntime } = await import( "./environment-runtime" diff --git a/frontend/src/lib/remote/environment-runtime.ts b/frontend/src/lib/remote/environment-runtime.ts index 20abf313f7..6eafb3825e 100644 --- a/frontend/src/lib/remote/environment-runtime.ts +++ b/frontend/src/lib/remote/environment-runtime.ts @@ -193,7 +193,15 @@ export function initializeEnvironmentRuntime(): () => void { localBus, sendFrame: (frame) => sendFrame(environmentId, frame), hydrate: async () => { - await getQueryClient(environmentId).invalidateQueries(); + // The §3.4 hydration barrier must FAIL CLOSED: TanStack swallows every refetch + // rejection unless `throwOnError`, so a 500ing host would resolve the barrier, + // validate the cursor, and let the badge read Connected over an empty board. + // `refetchType: "all"` because a snapshot is not "taken" if the inactive + // queries the next render will read were left stale. + await getQueryClient(environmentId).invalidateQueries( + { refetchType: "all" }, + { throwOnError: true } + ); }, sweep: () => { void getQueryClient(environmentId).invalidateQueries(); diff --git a/frontend/src/lib/remote/network-event-bus.test.ts b/frontend/src/lib/remote/network-event-bus.test.ts index 7aed676ffb..94342f2756 100644 --- a/frontend/src/lib/remote/network-event-bus.test.ts +++ b/frontend/src/lib/remote/network-event-bus.test.ts @@ -320,6 +320,94 @@ describe("cold hydration observes the H barrier", () => { expect(bus.cursor().valid).toBe(false); }); + it("keeps the cursor invalid until the snapshot commits", async () => { + const h = harness({ deferHydration: true }); + const started = h.bus.beginStream(hello({ maxSeq: 100 })); + await Promise.resolve(); + + // Subscribed at H, snapshot still loading: nothing is projected yet. + expect(h.bus.cursor().valid).toBe(false); + + h.releaseHydration(); + await started; + expect(h.bus.cursor().valid).toBe(true); + }); + + it("cold-hydrates again after a socket drop interrupted the hydration (P-24a)", async () => { + const h = harness({ deferHydration: true }); + const started = h.bus.beginStream(hello({ maxSeq: 100 })); + await Promise.resolve(); + + // The socket dies while the snapshot is still loading. + h.bus.handleStreamClosed(); + h.releaseHydration(); + await started; + + expect(h.bus.cursor().valid).toBe(false); + h.hydrate.mockClear(); + h.sent.length = 0; + + const resumed = h.bus.beginStream(hello({ maxSeq: 140 })); + for (let tick = 0; tick < 6; tick += 1) { + await Promise.resolve(); + } + h.releaseHydration(); + await resumed; + + // A warm resume here would splice over a snapshot that was never loaded. + expect(h.hydrate).toHaveBeenCalledTimes(1); + expect(h.sent[0]).toEqual({ + type: "subscribe", + afterSeq: 140, + streamEpoch: "epoch-1", + }); + }); + + it("does not go live when a reset lands while the first cursorAck is in flight", async () => { + const localBus = new MockEventBus(); + const restarts: StreamRestartCause[] = []; + const sweep = vi.fn(); + let releaseAck: () => void = () => {}; + let releaseHydration: () => void = () => {}; + const bus = new NetworkEventBus({ + environmentId: ENV, + localBus, + sendFrame: async (frame) => { + if (frame.type === "cursorAck") { + await new Promise((resolve) => { + releaseAck = resolve; + }); + } + }, + hydrate: async () => { + await new Promise((resolve) => { + releaseHydration = resolve; + }); + }, + sweep, + onRestartRequired: (cause) => restarts.push(cause), + }); + bus.subscribe("task:created", () => {}); + + const started = bus.beginStream(hello({ maxSeq: 100 })); + await Promise.resolve(); + bus.handleFrame({ type: "event", seq: 101, name: "task:created", payload: {} }); + releaseHydration(); + for (let tick = 0; tick < 6; tick += 1) { + await Promise.resolve(); + } + + // The ack write is in flight when the host withdraws the stream. + bus.handleFrame({ type: "reset", reason: "cursor_pruned" }); + releaseAck(); + await started; + + expect(restarts).toEqual([{ kind: "reset", reason: "cursor_pruned" }]); + // Resurrecting `live` here would sweep for — and dispatch frames from — a dead stream. + expect(bus.cursor().phase).toBe("idle"); + expect(sweep).not.toHaveBeenCalled(); + }); + it("discards a hydration that a mid-hydration reset already superseded", async () => { const h = harness({ deferHydration: true }); const seen: number[] = []; diff --git a/frontend/src/lib/remote/network-event-bus.ts b/frontend/src/lib/remote/network-event-bus.ts index a8b141bb83..71018e7ee9 100644 --- a/frontend/src/lib/remote/network-event-bus.ts +++ b/frontend/src/lib/remote/network-event-bus.ts @@ -24,7 +24,9 @@ * retention lease at `H`, so the host cannot prune the hydration delta underneath * us), durable frames `> H` buffer unapplied while the snapshot loads, and they are * applied in seq order afterwards. Only then does the first `cursorAck` go out, - * releasing the hydration span of the lease. + * releasing the hydration span of the lease. The cursor is published as VALID only + * once the snapshot has committed — an interrupted cold hydrate must never leave a + * resumable cursor at `H` for a projection that never loaded. * * ## `ready`, on a reconnecting transport * @@ -271,7 +273,10 @@ export class NetworkEventBus implements EventBus { this.hydrationBarrier = outcome.maxSeq; this.lastSeq = outcome.maxSeq; this.lastAckedSeq = outcome.maxSeq; - this.cursorValid = true; + // The cursor is NOT valid yet. `H` only describes a real projection once the + // snapshot has committed, so publishing validity here would let a socket drop + // mid-hydration warm-resume at `H` over a snapshot that never loaded (P-24a). + this.cursorValid = false; // Subscribe FIRST, at H. This registers the retention lease at H before any // hydration I/O, which is what makes prune-during-hydration structurally impossible @@ -288,11 +293,18 @@ export class NetworkEventBus implements EventBus { return; } + // The snapshot committed: everything through `H` is now genuinely projected, so + // the cursor becomes resumable — and the buffered apply may advance it. + this.cursorValid = true; this.applyBufferedFrames(); if (generation !== this.generation) { return; } await this.sendCursorAck(); + if (generation !== this.generation) { + // A reset landed while the ack was in flight; this connection is already gone. + return; + } this.goLive(); } @@ -326,6 +338,11 @@ export class NetworkEventBus implements EventBus { /** The socket ended. The supervisor owns the redial; the bus only invalidates state. */ handleStreamClosed(): void { + if (this.phase === "hydrating") { + // An interrupted cold hydrate committed no snapshot, so there is nothing at or + // below `H` to resume from: the next attempt MUST cold-hydrate again. + this.discardCursor(); + } this.phase = "idle"; this.buffer = []; this.generation += 1; From 65ec5e91d7f3ef6430267fcc63228f06ad835741 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:12:46 +0300 Subject: [PATCH 142/416] fix(remote): park revoked devices in blocked instead of the retry ladder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onRestartRequired discarded the StreamRestartCause, so ServerFrame::Reset{revoked} and {host_disabled} — the host WITHDRAWING the session — routed to streamLost() and redialled the 16 s ladder forever, burning a ws-ticket per cycle and showing Reconnecting where the P-10 re-pair state belongs. isAuthorityResetReason had zero production consumers. Adds ConnectionSupervisor.authorityWithdrawn(), which enters blocked with a populated blockedReason exactly like a 401 from the attempt itself. --- .../lib/remote/environment-runtime.test.ts | 49 ++++++++++++++++++- .../src/lib/remote/environment-runtime.ts | 14 +++++- frontend/src/lib/remote/supervisor.test.ts | 20 ++++++++ frontend/src/lib/remote/supervisor.ts | 15 ++++++ 4 files changed, 96 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/remote/environment-runtime.test.ts b/frontend/src/lib/remote/environment-runtime.test.ts index 3312a16755..b5ae9fa0cb 100644 --- a/frontend/src/lib/remote/environment-runtime.test.ts +++ b/frontend/src/lib/remote/environment-runtime.test.ts @@ -30,6 +30,8 @@ const { supervisors } = vi.hoisted(() => ({ stops: number; visibility: boolean[]; networks: boolean[]; + streamLosses: number; + authorityWithdrawals: string[]; }>, })); @@ -45,6 +47,8 @@ vi.mock("./supervisor", async (importOriginal) => { stops: 0, visibility: [], networks: [], + streamLosses: 0, + authorityWithdrawals: [], }; supervisors.push(this.record); } @@ -55,7 +59,12 @@ vi.mock("./supervisor", async (importOriginal) => { stop(): void { this.record.stops += 1; } - streamLost(): void {} + streamLost(): void { + this.record.streamLosses += 1; + } + authorityWithdrawn(message: string): void { + this.record.authorityWithdrawals.push(message); + } noteFrameActivity(): void {} visibilityChanged(hidden: boolean): void { this.record.visibility.push(hidden); @@ -190,6 +199,44 @@ describe("environment runtime composition", () => { expect(supervisors[0]?.starts).toBeGreaterThan(1); }); + it.each(["revoked", "host_disabled"] as const)( + "routes reset(%s) to the block path, never to the retry ladder", + async (reason) => { + const { initializeEnvironmentRuntime } = await import("./environment-runtime"); + useEnvironmentStore.getState().setEnvironments([summary("env-b")]); + setFlag(true); + teardown = initializeEnvironmentRuntime(); + useEnvironmentStore.setState({ activeEnvironmentId: "env-b" }); + const runtime = supervisors[supervisors.length - 1]; + const bus = createEventBus("env-b") as EventBus & { + handleFrame: (frame: { type: "reset"; reason: string }) => void; + }; + + bus.handleFrame({ type: "reset", reason }); + + expect(runtime?.authorityWithdrawals).toHaveLength(1); + expect(runtime?.authorityWithdrawals[0]).toContain(reason); + expect(runtime?.streamLosses).toBe(0); + } + ); + + it("routes a non-authority reset to the ordinary retry ladder", async () => { + const { initializeEnvironmentRuntime } = await import("./environment-runtime"); + useEnvironmentStore.getState().setEnvironments([summary("env-b")]); + setFlag(true); + teardown = initializeEnvironmentRuntime(); + useEnvironmentStore.setState({ activeEnvironmentId: "env-b" }); + const runtime = supervisors[supervisors.length - 1]; + const bus = createEventBus("env-b") as EventBus & { + handleFrame: (frame: { type: "reset"; reason: string }) => void; + }; + + bus.handleFrame({ type: "reset", reason: "cursor_pruned" }); + + expect(runtime?.streamLosses).toBe(1); + expect(runtime?.authorityWithdrawals).toEqual([]); + }); + it("fails the hydration barrier when the snapshot refetch rejects", async () => { const { initializeEnvironmentRuntime } = await import("./environment-runtime"); useEnvironmentStore.getState().setEnvironments([summary("env-b")]); diff --git a/frontend/src/lib/remote/environment-runtime.ts b/frontend/src/lib/remote/environment-runtime.ts index 6eafb3825e..d2318c140d 100644 --- a/frontend/src/lib/remote/environment-runtime.ts +++ b/frontend/src/lib/remote/environment-runtime.ts @@ -26,6 +26,7 @@ import { NetworkEventBus } from "./network-event-bus"; import { networkFetch } from "./network-fetch"; import { attachRemoteStreamRelay, type RemoteStreamTarget } from "./stream-relay"; import { + isAuthorityResetReason, type RemoteClientFrame, type RemoteConnectOutcome, type RemoteServerFrame, @@ -206,7 +207,18 @@ export function initializeEnvironmentRuntime(): () => void { sweep: () => { void getQueryClient(environmentId).invalidateQueries(); }, - onRestartRequired: () => runtime.supervisor.streamLost(), + onRestartRequired: (cause) => { + // `revoked` / `host_disabled` are the host WITHDRAWING the session. Routing + // them to `streamLost` would redial the 16 s ladder forever against a host + // that already refused this device, instead of showing the re-pair state. + if (cause.kind === "reset" && isAuthorityResetReason(cause.reason)) { + runtime.supervisor.authorityWithdrawn( + `The host ended this device's session (${cause.reason}). Re-pair this environment to reconnect.` + ); + return; + } + runtime.supervisor.streamLost(); + }, }); runtime.bus = bus; runtime.detachRelay = attachRemoteStreamRelay({ diff --git a/frontend/src/lib/remote/supervisor.test.ts b/frontend/src/lib/remote/supervisor.test.ts index c6ce41e066..522fe5eb0c 100644 --- a/frontend/src/lib/remote/supervisor.test.ts +++ b/frontend/src/lib/remote/supervisor.test.ts @@ -452,6 +452,26 @@ describe("P-10: version skew parks in blocked with zero retries", () => { }); }); +describe("authority withdrawal parks instead of redialling", () => { + it("blocks with a populated reason and burns no ws-tickets", async () => { + const r = rig(); + await connect(r); + r.spies.openStream.mockClear(); + + r.supervisor.authorityWithdrawn("The host ended this device's session (revoked)."); + + expect(r.supervisor.currentState()).toBe("blocked"); + expect(r.supervisor.blocked()?.failure).toBe("unauthorized"); + expect(r.supervisor.blocked()?.message).toContain("revoked"); + expect(r.spies.onBlocked).toHaveBeenCalledWith("unauthorized", expect.any(String)); + expect(r.supervisor.armedTimers()).toEqual([]); + + await vi.advanceTimersByTimeAsync(10 * 60_000); + expect(r.spies.openStream).not.toHaveBeenCalled(); + expect(r.supervisor.presentation()).toBe("error"); + }); +}); + // =========================================================================== // P-9 — dead-host detection // =========================================================================== diff --git a/frontend/src/lib/remote/supervisor.ts b/frontend/src/lib/remote/supervisor.ts index f1a6641673..54db231e68 100644 --- a/frontend/src/lib/remote/supervisor.ts +++ b/frontend/src/lib/remote/supervisor.ts @@ -244,6 +244,21 @@ export class ConnectionSupervisor { this.dispatch("socket_lost"); } + /** + * The host WITHDREW this device's session rather than losing our place in it — + * `reset(revoked)` / `reset(host_disabled)` (§3.2, stream-frames' authority reasons). + * + * This is deliberately NOT `streamLost`: redialling a host that has already refused + * this device spins the retry ladder forever and burns a ws-ticket per cycle, while + * the user never sees the actionable re-pair state (P-10). It parks in `blocked` with + * a populated reason, exactly like a 401 from the attempt itself. + */ + authorityWithdrawn(message: string): void { + this.blockedReason = { failure: "unauthorized", message }; + this.deps.onBlocked?.("unauthorized", message); + this.dispatch("connect_failed_unauthorized"); + } + /** * Called for every frame the relay delivers. Resets the frame-silence watchdog: * >50 s of total silence means the host is gone (P-9), which the host's own 20 s From 0dd821b0edf27e8f09a96c0da368433b8aa7bcc7 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:14:27 +0300 Subject: [PATCH 143/416] fix(remote): lift connect-path proxy refusals into the transport taxonomy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openStream/releaseStream/sendFrame awaited the Rust proxy directly, so their rejections stayed the raw '{CODE}: message' IPC strings. classifyFailure only recognises Error subclasses, so REMOTE_UNAUTHORIZED from remote_connect fell through to 'transient' and a revoked credential looped the backoff ladder forever — making the supervisor's REMOTE_UNAUTHORIZED branch dead code from that journey. Extracts network-fetch's private lift as toRemoteTransportError() and applies it to all three connect-path invokes. --- .../lib/remote/environment-runtime.test.ts | 21 ++++++++++++ .../src/lib/remote/environment-runtime.ts | 33 +++++++++++++++---- frontend/src/lib/remote/network-fetch.ts | 20 +++-------- frontend/src/lib/remote/transport-errors.ts | 29 ++++++++++++++++ 4 files changed, 80 insertions(+), 23 deletions(-) diff --git a/frontend/src/lib/remote/environment-runtime.test.ts b/frontend/src/lib/remote/environment-runtime.test.ts index b5ae9fa0cb..b10fb637b1 100644 --- a/frontend/src/lib/remote/environment-runtime.test.ts +++ b/frontend/src/lib/remote/environment-runtime.test.ts @@ -10,6 +10,7 @@ import { import { useUiStore } from "@/stores/uiStore"; import type { RemoteStreamTarget } from "./stream-relay"; +import { RemoteTransportError } from "./transport-errors"; const { supervisors } = vi.hoisted(() => ({ supervisors: [] as Array<{ @@ -17,6 +18,7 @@ const { supervisors } = vi.hoisted(() => ({ environmentId: string; refreshScopes: () => Promise; applyScopes: (scopes: readonly string[]) => void; + openStream: () => Promise; beginStream: (outcome: { environmentId: string; hostEnvironmentId: string; @@ -237,6 +239,25 @@ describe("environment runtime composition", () => { expect(runtime?.authorityWithdrawals).toEqual([]); }); + it("lifts a proxy refusal on the connect path into the transport taxonomy", async () => { + const { invoke } = await import("#tauri-core-primitive"); + const { initializeEnvironmentRuntime } = await import("./environment-runtime"); + useEnvironmentStore.getState().setEnvironments([summary("env-b")]); + setFlag(true); + teardown = initializeEnvironmentRuntime(); + const runtime = supervisors[supervisors.length - 1]; + + // The Rust proxy rejects with its `"{CODE}: {message}"` rendering. Left raw, the + // supervisor classifies a revoked device as `transient` and loops the ladder. + vi.mocked(invoke).mockRejectedValueOnce( + "REMOTE_UNAUTHORIZED: this device was revoked" + ); + const failure = await runtime?.deps.openStream().catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(RemoteTransportError); + expect((failure as RemoteTransportError).code).toBe("REMOTE_UNAUTHORIZED"); + }); + it("fails the hydration barrier when the snapshot refetch rejects", async () => { const { initializeEnvironmentRuntime } = await import("./environment-runtime"); useEnvironmentStore.getState().setEnvironments([summary("env-b")]); diff --git a/frontend/src/lib/remote/environment-runtime.ts b/frontend/src/lib/remote/environment-runtime.ts index d2318c140d..8979450fb7 100644 --- a/frontend/src/lib/remote/environment-runtime.ts +++ b/frontend/src/lib/remote/environment-runtime.ts @@ -35,6 +35,7 @@ import { ConnectionSupervisor, type EnvironmentDescriptorView, } from "./supervisor"; +import { toRemoteTransportError } from "./transport-errors"; const CLIENT_PROTOCOL_VERSION = 1; const CLIENT_MIN_PROTOCOL = 1; @@ -120,9 +121,15 @@ async function sendFrame( environmentId: string, frame: RemoteClientFrame ): Promise { - await primitiveInvoke("remote_stream_send", { - input: { id: environmentId, frame }, - }); + try { + await primitiveInvoke("remote_stream_send", { + input: { id: environmentId, frame }, + }); + } catch (reason: unknown) { + // The proxy rejects with the `"{CODE}: {message}"` rendering, which the + // supervisor's typed classification cannot read as anything but `transient`. + throw toRemoteTransportError(reason, environmentId, "remote_stream_send"); + } } function detachedBus(environmentId: string, localBus: EventBus): NetworkEventBus { @@ -247,15 +254,27 @@ export function initializeEnvironmentRuntime(): () => void { return parseDescriptor(await response.json()); }, openStream: async () => { - const outcome = parseConnectOutcome( - await primitiveInvoke("remote_connect", { input: { id: environmentId } }) - ); + let raw: unknown; + try { + raw = await primitiveInvoke("remote_connect", { + input: { id: environmentId }, + }); + } catch (reason: unknown) { + // A revoked credential must reach `classifyFailure` as REMOTE_UNAUTHORIZED, + // not as an untyped string the ladder retries forever. + throw toRemoteTransportError(reason, environmentId, "remote_connect"); + } + const outcome = parseConnectOutcome(raw); runtime.socketLive = true; return outcome; }, releaseStream: async () => { runtime.socketLive = false; - await primitiveInvoke("remote_disconnect", { input: { id: environmentId } }); + try { + await primitiveInvoke("remote_disconnect", { input: { id: environmentId } }); + } catch (reason: unknown) { + throw toRemoteTransportError(reason, environmentId, "remote_disconnect"); + } }, probe: async () => { const response = await networkFetch(environmentId, HEALTH_PATH); diff --git a/frontend/src/lib/remote/network-fetch.ts b/frontend/src/lib/remote/network-fetch.ts index 481d7e9881..1c390e5022 100644 --- a/frontend/src/lib/remote/network-fetch.ts +++ b/frontend/src/lib/remote/network-fetch.ts @@ -17,8 +17,7 @@ import { invoke as primitiveInvoke } from "#tauri-core-primitive"; import { RemoteTransportError, - parseRemoteTransportErrorCode, - parseRemoteTransportErrorMessage, + toRemoteTransportError, } from "./transport-errors"; /** Statuses the `Response` constructor refuses a body for. */ @@ -215,18 +214,7 @@ function toFetchTransportError( environmentId: string, path: string ): unknown { - if (reason instanceof RemoteTransportError) { - return reason; - } - // Reuse the invoke taxonomy parser: `remote_fetch` renders its failures with the - // same `"{CODE}: {message}"` convention. - const code = parseRemoteTransportErrorCode(reason); - if (code === null) { - return reason; - } - return new RemoteTransportError({ - code, - message: `${parseRemoteTransportErrorMessage(reason)} (${path})`, - environmentId, - }); + // The shared taxonomy lift: `remote_fetch` renders its failures with the same + // `"{CODE}: {message}"` convention every other `remote_*` command uses. + return toRemoteTransportError(reason, environmentId, path); } diff --git a/frontend/src/lib/remote/transport-errors.ts b/frontend/src/lib/remote/transport-errors.ts index 1017f3a063..90c291e436 100644 --- a/frontend/src/lib/remote/transport-errors.ts +++ b/frontend/src/lib/remote/transport-errors.ts @@ -129,6 +129,35 @@ export function parseRemoteTransportErrorCode( return PROXY_ERROR_CODE_ALIASES[token] ?? null; } +/** + * Lifts a raw proxy rejection into the taxonomy, or returns it untouched when it + * carries no transport code (that is the signal to treat it as the command's own + * error). Every seam that awaits a `remote_*` Tauri command must go through this: + * `classifyFailure` in the supervisor recognises `RemoteTransportError` instances, + * never the `"{CODE}: {message}"` strings the proxy actually rejects with, so a raw + * rejection classifies a revoked credential as `transient` and loops the backoff + * ladder instead of parking in `blocked`. + */ +export function toRemoteTransportError( + reason: unknown, + environmentId: string, + context?: string +): unknown { + if (reason instanceof RemoteTransportError) { + return reason; + } + const code = parseRemoteTransportErrorCode(reason); + if (code === null) { + return reason; + } + const message = parseRemoteTransportErrorMessage(reason); + return new RemoteTransportError({ + code, + message: context === undefined ? message : `${message} (${context})`, + environmentId, + }); +} + /** The human-readable half of `"{CODE}: {message}"`, or the whole value. */ export function parseRemoteTransportErrorMessage(value: unknown): string { if (typeof value !== "string") { From d8fb3c7d585bac967cd1f62301d9471cd686b4b1 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:17:26 +0300 Subject: [PATCH 144/416] fix(remote): stop background environments presenting as connected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit beginStream no-ops for a non-active environment, so a background attempt completes descriptor + socket + hello + probe without ever sending subscribe or projecting a single frame — and still promoted to connected, painting a green dot over a stream that was never established (§6.5 / P-25 'never a probe alone'). Adds one presentation-only connection state, health_only, that the runtime projects a non-active environment's connected onto (including the outgoing environment on a switch). SUPERVISOR_STATES — the FSM vocabulary the mobile client consumes — is unchanged. --- .../layout/EnvironmentSwitcher.test.tsx | 3 +- .../layout/environment-switcher-status.ts | 8 +++ .../lib/remote/environment-runtime.test.ts | 50 +++++++++++++++++++ .../src/lib/remote/environment-runtime.ts | 34 ++++++++++++- frontend/src/stores/environmentStore.ts | 15 +++++- 5 files changed, 105 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/layout/EnvironmentSwitcher.test.tsx b/frontend/src/components/layout/EnvironmentSwitcher.test.tsx index 71dcbb27dc..0fdef50b06 100644 --- a/frontend/src/components/layout/EnvironmentSwitcher.test.tsx +++ b/frontend/src/components/layout/EnvironmentSwitcher.test.tsx @@ -29,6 +29,7 @@ const ALL_STATES: EnvironmentConnectionState[] = [ "offline", "blocked", "suspended", + "health_only", ]; function remote(id: string, name: string): RemoteEnvironmentSummary { @@ -102,7 +103,7 @@ describe("EnvironmentSwitcher", () => { expect(screen.queryByRole("button", { name: "Switch environment" })).not.toBeInTheDocument(); }); - it("exports one typed dot description for all seven supervisor states and local", async () => { + it("exports one typed dot description for every presented state and local", async () => { seed(ALL_STATES); renderSwitcher(); await openSwitcher(); diff --git a/frontend/src/components/layout/environment-switcher-status.ts b/frontend/src/components/layout/environment-switcher-status.ts index 2e6f4c44b0..8a9942a60c 100644 --- a/frontend/src/components/layout/environment-switcher-status.ts +++ b/frontend/src/components/layout/environment-switcher-status.ts @@ -2,6 +2,7 @@ import type { EnvironmentConnectionState } from "@/stores/environmentStore"; interface EnvironmentStatusDotConfig { glyph: "●" | "◐" | "⊘" | "○"; + /** Never the plain green `●` unless the environment truly projects its stream. */ color: string; reason: string | null; } @@ -42,4 +43,11 @@ export const ENVIRONMENT_STATUS_DOT = { color: "var(--text-muted, #8e8e93)", reason: "Suspended", }, + // Reachable, live host — but no event stream is being projected for a background + // environment, so it must never wear the green "connected" dot. + health_only: { + glyph: "◐", + color: "var(--status-success, #2eb867)", + reason: "Reachable in the background", + }, } as const satisfies Record; diff --git a/frontend/src/lib/remote/environment-runtime.test.ts b/frontend/src/lib/remote/environment-runtime.test.ts index b10fb637b1..8e1271e3d9 100644 --- a/frontend/src/lib/remote/environment-runtime.test.ts +++ b/frontend/src/lib/remote/environment-runtime.test.ts @@ -19,6 +19,7 @@ const { supervisors } = vi.hoisted(() => ({ refreshScopes: () => Promise; applyScopes: (scopes: readonly string[]) => void; openStream: () => Promise; + onStateChange: (state: string) => void; beginStream: (outcome: { environmentId: string; hostEnvironmentId: string; @@ -34,6 +35,7 @@ const { supervisors } = vi.hoisted(() => ({ networks: boolean[]; streamLosses: number; authorityWithdrawals: string[]; + setState: (state: string) => void; }>, })); @@ -51,13 +53,21 @@ vi.mock("./supervisor", async (importOriginal) => { networks: [], streamLosses: 0, authorityWithdrawals: [], + setState: (state: string) => { + this.state = state; + }, }; supervisors.push(this.record); } + state: string = "idle"; + start(): void { this.record.starts += 1; } + currentState(): string { + return this.state; + } stop(): void { this.record.stops += 1; } @@ -280,6 +290,46 @@ describe("environment runtime composition", () => { ); }); + it("never paints a background environment as connected (P-25)", async () => { + const { initializeEnvironmentRuntime } = await import("./environment-runtime"); + useEnvironmentStore.getState().setEnvironments([summary("env-b"), summary("env-c")]); + setFlag(true); + teardown = initializeEnvironmentRuntime(); + useEnvironmentStore.setState({ activeEnvironmentId: "env-b" }); + + const forEnvironment = (id: string) => + supervisors.filter((item) => item.deps.environmentId === id).at(-1)!; + const b = forEnvironment("env-b"); + const c = forEnvironment("env-c"); + + // env-c never sends `subscribe` and never projects: its attempt is a probe on a + // socket nobody reads, so it must not wear the connected dot. + c.setState("connected"); + c.deps.onStateChange("connected"); + b.setState("connected"); + b.deps.onStateChange("connected"); + + expect(useEnvironmentStore.getState().connectionStates["env-c"]).toBe("health_only"); + expect(useEnvironmentStore.getState().connectionStates["env-b"]).toBe("connected"); + }); + + it("demotes the outgoing environment's badge when the active one changes", async () => { + const { initializeEnvironmentRuntime } = await import("./environment-runtime"); + useEnvironmentStore.getState().setEnvironments([summary("env-b"), summary("env-c")]); + setFlag(true); + teardown = initializeEnvironmentRuntime(); + useEnvironmentStore.setState({ activeEnvironmentId: "env-b" }); + const b = supervisors.filter((item) => item.deps.environmentId === "env-b").at(-1)!; + b.setState("connected"); + b.deps.onStateChange("connected"); + expect(useEnvironmentStore.getState().connectionStates["env-b"]).toBe("connected"); + + useEnvironmentStore.setState({ activeEnvironmentId: "env-c" }); + + // env-b lost its bus, so it stops projecting the instant the switch lands. + expect(useEnvironmentStore.getState().connectionStates["env-b"]).toBe("health_only"); + }); + it("uses pairing scopes in background without a session fetch and records them", async () => { const { getConfirmedScopes, initializeEnvironmentRuntime } = await import( "./environment-runtime" diff --git a/frontend/src/lib/remote/environment-runtime.ts b/frontend/src/lib/remote/environment-runtime.ts index 8979450fb7..4427af7adf 100644 --- a/frontend/src/lib/remote/environment-runtime.ts +++ b/frontend/src/lib/remote/environment-runtime.ts @@ -35,6 +35,7 @@ import { ConnectionSupervisor, type EnvironmentDescriptorView, } from "./supervisor"; +import type { SupervisorState } from "./supervisor-transition-table"; import { toRemoteTransportError } from "./transport-errors"; const CLIENT_PROTOCOL_VERSION = 1; @@ -153,6 +154,29 @@ export function initializeEnvironmentRuntime(): () => void { let enabled = useUiStore.getState().featureFlags.remoteEnvironments; let activeEnvironmentId = useEnvironmentStore.getState().activeEnvironmentId; + /** + * The single writer of `connectionStates`, and the one place the P-25 "never a probe + * alone" rule is enforced for BACKGROUND environments. + * + * A non-active environment completes descriptor + socket + hello + probe, but its + * `beginStream` is a no-op: no `subscribe` frame is ever sent and nothing is + * projected (full background projection is a v1 non-goal). Painting that green would + * assert a stream liveness that does not exist, so it presents as `health_only`. + */ + const publishConnectionState = ( + environmentId: string, + state: SupervisorState + ): void => { + useEnvironmentStore + .getState() + .setConnectionState( + environmentId, + state === "connected" && environmentId !== activeEnvironmentId + ? "health_only" + : state + ); + }; + const detachRelay = (runtime: RuntimeEntry): void => { runtime.detachRelay?.(); runtime.detachRelay = null; @@ -309,7 +333,7 @@ export function initializeEnvironmentRuntime(): () => void { }, hasLiveSocket: () => runtime.socketLive, onStateChange: (state) => { - useEnvironmentStore.getState().setConnectionState(environmentId, state); + publishConnectionState(environmentId, state); }, }); runtime = { @@ -325,11 +349,17 @@ export function initializeEnvironmentRuntime(): () => void { const activate = (environmentId: string): void => { const previous = runtimes.get(activeEnvironmentId); - if (previous !== undefined && previous.entry.id !== environmentId) { + const demoted = previous !== undefined && previous.entry.id !== environmentId; + if (demoted) { previous.bus = null; attachHealthRelay(previous); } activeEnvironmentId = environmentId; + if (demoted) { + // The demoted environment stops projecting the instant it loses the bus, so its + // badge must stop claiming a live stream even though its FSM state is unchanged. + publishConnectionState(previous.entry.id, previous.supervisor.currentState()); + } const runtime = runtimes.get(environmentId); if (runtime === undefined) { return; diff --git a/frontend/src/stores/environmentStore.ts b/frontend/src/stores/environmentStore.ts index 0a4a463cb0..0f5e0d18a2 100644 --- a/frontend/src/stores/environmentStore.ts +++ b/frontend/src/stores/environmentStore.ts @@ -30,7 +30,17 @@ import { export { LOCAL_ENVIRONMENT_ID }; -/** Canonical supervisor FSM vocabulary (§6.5); "connected" is all local ever is. */ +/** + * Canonical supervisor FSM vocabulary (§6.5); "connected" is all local ever is. + * + * Plus ONE presentation-only value the FSM never produces: `health_only`. A + * background environment completes descriptor + socket + hello + probe but never + * sends `subscribe` and never projects (full background projection is a v1 + * non-goal), so rendering it as `connected` would assert a stream liveness that + * does not exist — "never a probe alone" (§6.5, P-25). The runtime projects a + * non-active environment's `connected` to this instead. It is deliberately NOT in + * `SUPERVISOR_STATES`: the FSM vocabulary the mobile client consumes is unchanged. + */ export type EnvironmentConnectionState = | "idle" | "connecting" @@ -38,7 +48,8 @@ export type EnvironmentConnectionState = | "backoff" | "offline" | "blocked" - | "suspended"; + | "suspended" + | "health_only"; export interface EnvironmentEntry { id: string; From f50bb05259c1fd80b49800514185ebe7b520c5e8 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:18:03 +0300 Subject: [PATCH 145/416] fix(remote): invalidate the target environment's cache on every activation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QueryClients are retained per environment so a switch back can reuse a warm cache, but nothing invalidated the target's cache on activation: only a successful remote reconnect ever swept. Switching back to local (which has no supervisor at all) — or to a remote environment parked in backoff/blocked — served up to five minutes of staleTime-fresh data as current, with no error. activate() now sweeps the target cache before any runtime branch, so the local path gets it too. --- .../lib/remote/environment-runtime.test.ts | 22 +++++++++++++++++++ .../src/lib/remote/environment-runtime.ts | 6 +++++ 2 files changed, 28 insertions(+) diff --git a/frontend/src/lib/remote/environment-runtime.test.ts b/frontend/src/lib/remote/environment-runtime.test.ts index 8e1271e3d9..7d3f924af1 100644 --- a/frontend/src/lib/remote/environment-runtime.test.ts +++ b/frontend/src/lib/remote/environment-runtime.test.ts @@ -330,6 +330,28 @@ describe("environment runtime composition", () => { expect(useEnvironmentStore.getState().connectionStates["env-b"]).toBe("health_only"); }); + it("re-hydrates the target cache on every activation, local included", async () => { + const { initializeEnvironmentRuntime } = await import("./environment-runtime"); + useEnvironmentStore.getState().setEnvironments([summary("env-b")]); + setFlag(true); + teardown = initializeEnvironmentRuntime(); + + const local = vi + .spyOn(getQueryClient(LOCAL_ENVIRONMENT_ID), "invalidateQueries") + .mockResolvedValue(); + const remote = vi + .spyOn(getQueryClient("env-b"), "invalidateQueries") + .mockResolvedValue(); + + useEnvironmentStore.setState({ activeEnvironmentId: "env-b" }); + expect(remote).toHaveBeenCalled(); + + // Local has no supervisor to cold-hydrate it, so without an explicit sweep the + // retained cache stays fresh for 5 minutes over whatever changed meanwhile. + useEnvironmentStore.setState({ activeEnvironmentId: LOCAL_ENVIRONMENT_ID }); + expect(local).toHaveBeenCalled(); + }); + it("uses pairing scopes in background without a session fetch and records them", async () => { const { getConfirmedScopes, initializeEnvironmentRuntime } = await import( "./environment-runtime" diff --git a/frontend/src/lib/remote/environment-runtime.ts b/frontend/src/lib/remote/environment-runtime.ts index 4427af7adf..f1a9b2c20e 100644 --- a/frontend/src/lib/remote/environment-runtime.ts +++ b/frontend/src/lib/remote/environment-runtime.ts @@ -355,6 +355,12 @@ export function initializeEnvironmentRuntime(): () => void { attachHealthRelay(previous); } activeEnvironmentId = environmentId; + // Persistence never substitutes for the cold hydrate on reactivation. The target + // environment's QueryClient is RETAINED across switches, so without this every + // remounted query is inside its 5-minute staleTime and the board renders minutes + // -old data as current — for `local` (which has no supervisor to re-hydrate it) + // and for any remote environment whose supervisor is parked in backoff/blocked. + void getQueryClient(environmentId).invalidateQueries(); if (demoted) { // The demoted environment stops projecting the instant it loses the bus, so its // badge must stop claiming a live stream even though its FSM state is unchanged. From 6ccd392983fcbb94396b9b129c48e0532cd86f6a Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:18:42 +0300 Subject: [PATCH 146/416] fix(remote): scope undecodable relay frames to the environment they address MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The undecodable branch ran BEFORE the environment filter, and the envelope parser discards the environmentId it already read when the frame fails to decode. So one drifted frame addressed to a background environment fired onUndecodableFrame on every attached relay and dropped the ACTIVE environment's healthy socket into reconnecting — the exact isolation this module exists to provide. Reads the addressee first (readRemoteStreamFrameEnvironmentId), filters, and only then decodes. An envelope with no readable addressee still surfaces everywhere, because an unowned protocol violation cannot be attributed. --- frontend/src/lib/remote/stream-frames.ts | 13 +++++++++ frontend/src/lib/remote/stream-relay.test.ts | 28 ++++++++++++++++++++ frontend/src/lib/remote/stream-relay.ts | 14 ++++++++-- 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/remote/stream-frames.ts b/frontend/src/lib/remote/stream-frames.ts index 54b67bf230..6aa27d7335 100644 --- a/frontend/src/lib/remote/stream-frames.ts +++ b/frontend/src/lib/remote/stream-frames.ts @@ -204,6 +204,19 @@ export function parseRemoteServerFrame(value: unknown): RemoteServerFrame | null } } +/** + * Reads ONLY the addressee of a relay envelope, without decoding the frame. + * + * The shared channel carries every environment's frames, so a relay must decide + * whose frame this is BEFORE deciding whether it decodes: an undecodable frame + * addressed to environment B is B's protocol violation, and letting it tear down + * environment A's healthy stream would defeat the isolation this layer exists for. + */ +export function readRemoteStreamFrameEnvironmentId(value: unknown): string | null { + const record = asRecord(value); + return record === null ? null : asString(record.environmentId); +} + /** Decodes a `remote:stream_frame` payload, rejecting anything without a usable frame. */ export function parseRemoteStreamFrameEnvelope( value: unknown diff --git a/frontend/src/lib/remote/stream-relay.test.ts b/frontend/src/lib/remote/stream-relay.test.ts index ce5fc3259d..42e28f9bca 100644 --- a/frontend/src/lib/remote/stream-relay.test.ts +++ b/frontend/src/lib/remote/stream-relay.test.ts @@ -113,6 +113,34 @@ describe("fail-closed decoding", () => { expect(onUndecodableFrame).toHaveBeenCalledTimes(2); }); + it("never tears down this environment for ANOTHER environment's undecodable frame", () => { + const localBus = new MockEventBus(); + const a = target(ENV); + const b = target(OTHER); + const aUndecodable = vi.fn(); + const bUndecodable = vi.fn(); + attachRemoteStreamRelay({ + localBus, + target: a.stub, + onUndecodableFrame: aUndecodable, + }); + attachRemoteStreamRelay({ + localBus, + target: b.stub, + onUndecodableFrame: bUndecodable, + }); + + localBus.emit(REMOTE_STREAM_FRAME_EVENT, { + environmentId: OTHER, + frame: { type: "event", seq: "not-a-number", name: "task:created" }, + }); + + // A's healthy socket must not be dropped by B's protocol violation. + expect(aUndecodable).not.toHaveBeenCalled(); + expect(bUndecodable).toHaveBeenCalledTimes(1); + expect(a.frames).toEqual([]); + }); + it("drops a durable frame whose seq is not a number rather than treating it as transient", () => { const localBus = new MockEventBus(); const t = target(); diff --git a/frontend/src/lib/remote/stream-relay.ts b/frontend/src/lib/remote/stream-relay.ts index 44e8bd757a..38ab6f002b 100644 --- a/frontend/src/lib/remote/stream-relay.ts +++ b/frontend/src/lib/remote/stream-relay.ts @@ -18,6 +18,7 @@ import { REMOTE_STREAM_FRAME_EVENT, parseRemoteStreamClosedEnvelope, parseRemoteStreamFrameEnvelope, + readRemoteStreamFrameEnvironmentId, type RemoteServerFrame, } from "./stream-frames"; @@ -55,15 +56,24 @@ export function attachRemoteStreamRelay(deps: RemoteStreamRelayDeps): () => void const environmentId = deps.target.environmentId(); const frames: Unsubscribe = subscribeLocal(REMOTE_STREAM_FRAME_EVENT, (payload) => { + // Address first, decode second. A drifted frame addressed to ANOTHER environment + // must not tear this environment's healthy stream down — that would make every + // attached relay a casualty of one host's protocol violation. + const addressee = readRemoteStreamFrameEnvironmentId(payload); + if (addressee !== null && addressee !== environmentId) { + return; // another environment's stream shares the channel, not this bus + } const envelope = parseRemoteStreamFrameEnvelope(payload); if (envelope === null) { // A frame we cannot decode is a protocol violation, not a no-op: projecting a - // partially-understood frame is what the reset machinery exists to prevent. + // partially-understood frame is what the reset machinery exists to prevent. An + // envelope with no readable addressee cannot be attributed at all, so every + // relay surfaces it rather than silently dropping an unowned violation. deps.onUndecodableFrame?.(payload); return; } if (envelope.environmentId !== environmentId) { - return; // another environment's stream shares the channel, not this bus + return; } deps.onFrameActivity?.(); deps.target.handleFrame(envelope.frame); From bfec17273450f370b7a2f55188220728dd301122 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:19:19 +0300 Subject: [PATCH 147/416] fix(remote): keep a blocked environment blocked when the app backgrounds blocked x suspend transitioned to suspended, and dispatch() nulls blockedReason on any non-blocked target, so backgrounding the app for two seconds erased the P-10 re-pair affordance and re-presented a revoked device as benignly suspended until another failed attempt burned. blocked already parks with zero timers, so there was nothing to suspend. --- .../remote/supervisor-transition-table.test.ts | 5 ++++- .../lib/remote/supervisor-transition-table.ts | 5 ++++- frontend/src/lib/remote/supervisor.test.ts | 18 ++++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/frontend/src/lib/remote/supervisor-transition-table.test.ts b/frontend/src/lib/remote/supervisor-transition-table.test.ts index cc1652e538..fbe064f798 100644 --- a/frontend/src/lib/remote/supervisor-transition-table.test.ts +++ b/frontend/src/lib/remote/supervisor-transition-table.test.ts @@ -120,7 +120,10 @@ describe("blocked entry and wakeups", () => { }); it("suspend parks a blocked environment rather than clearing the block", () => { - expect(lookupTransition("blocked", "suspend").next).toBe("suspended"); + // Backgrounding must not convert an actionable block into a benign "Suspended": + // `blocked` already parks with zero timers, so there is nothing left to suspend. + expect(lookupTransition("blocked", "suspend").next).toBe("blocked"); + expect(lookupTransition("blocked", "suspend").effects).toEqual([]); }); it("every blocked wakeup resets the ladder and begins one attempt", () => { diff --git a/frontend/src/lib/remote/supervisor-transition-table.ts b/frontend/src/lib/remote/supervisor-transition-table.ts index dbcab38a80..458622c22b 100644 --- a/frontend/src/lib/remote/supervisor-transition-table.ts +++ b/frontend/src/lib/remote/supervisor-transition-table.ts @@ -316,7 +316,10 @@ export const SUPERVISOR_TRANSITION_TABLE: Readonly< // Losing the network does not un-block, and does not start a timer. offline: stay("blocked"), online: WAKE_FROM_BLOCKED, - suspend: stay("suspended"), + // Backgrounding does not un-block either, and must not downgrade an actionable + // block to a benign `suspended`: that erases the re-pair affordance (P-10) until + // another attempt burns. `blocked` already parks with zero timers. + suspend: stay("blocked"), resume: WAKE_FROM_BLOCKED, credentials_changed: WAKE_FROM_BLOCKED, retry_now: WAKE_FROM_BLOCKED, diff --git a/frontend/src/lib/remote/supervisor.test.ts b/frontend/src/lib/remote/supervisor.test.ts index 522fe5eb0c..63b430617e 100644 --- a/frontend/src/lib/remote/supervisor.test.ts +++ b/frontend/src/lib/remote/supervisor.test.ts @@ -470,6 +470,24 @@ describe("authority withdrawal parks instead of redialling", () => { expect(r.spies.openStream).not.toHaveBeenCalled(); expect(r.supervisor.presentation()).toBe("error"); }); + + it("keeps the block and its reason across a background/foreground cycle", async () => { + const r = rig({ + fetchDescriptor: vi.fn(async () => ({ ...DESCRIPTOR, minClientProtocol: 2 })), + }); + await connect(r); + expect(r.supervisor.blocked()?.failure).toBe("version"); + + // Backgrounding for longer than the debounce must not downgrade the actionable + // block to a benign "Suspended" with no re-pair affordance. + r.supervisor.visibilityChanged(true); + await vi.advanceTimersByTimeAsync(SUSPEND_DEBOUNCE_MS + 10); + + expect(r.supervisor.currentState()).toBe("blocked"); + expect(r.supervisor.blocked()?.failure).toBe("version"); + expect(r.supervisor.presentation()).toBe("error"); + expect(r.supervisor.armedTimers()).toEqual([]); + }); }); // =========================================================================== From e7ea1737ae8ecad857197e33022cf1f7bddecc35 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:21:42 +0300 Subject: [PATCH 148/416] fix(remote): make the NetworkEventBus identity stable per environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit activate() constructed a new bus on every activation while EventProvider memoizes on environmentId alone, so a same-id reactivation (flag toggle off/on, re-activate) left the whole mounted tree holding a bus subscribed to nothing while the supervisor still painted Connected — two writers of bus identity. Buses now live in an app-lifetime registry keyed by environment id; activation swaps only the relay wiring. Deactivation, quiesce, and row removal call the new abandonStream(), so a bus that stopped projecting cannot warm-resume a cursor it stopped honouring, and only a removed registry row forgets its bus. --- .../lib/remote/environment-runtime.test.ts | 25 +++++++ .../src/lib/remote/environment-runtime.ts | 67 ++++++++++++++----- frontend/src/lib/remote/network-event-bus.ts | 14 ++++ 3 files changed, 91 insertions(+), 15 deletions(-) diff --git a/frontend/src/lib/remote/environment-runtime.test.ts b/frontend/src/lib/remote/environment-runtime.test.ts index 7d3f924af1..e9473143d8 100644 --- a/frontend/src/lib/remote/environment-runtime.test.ts +++ b/frontend/src/lib/remote/environment-runtime.test.ts @@ -290,6 +290,31 @@ describe("environment runtime composition", () => { ); }); + it("keeps one bus identity per environment across reactivation", async () => { + const { initializeEnvironmentRuntime } = await import("./environment-runtime"); + useEnvironmentStore.getState().setEnvironments([summary("env-b")]); + setFlag(true); + teardown = initializeEnvironmentRuntime(); + useEnvironmentStore.setState({ activeEnvironmentId: "env-b" }); + const first = createEventBus("env-b"); + + // EventProvider memoizes the bus on environmentId alone, so a same-id + // reactivation that rebuilt the bus would orphan every mounted subscriber. + setFlag(false); + setFlag(true); + expect(createEventBus("env-b")).toBe(first); + + useEnvironmentStore.setState({ activeEnvironmentId: LOCAL_ENVIRONMENT_ID }); + useEnvironmentStore.setState({ activeEnvironmentId: "env-b" }); + expect(createEventBus("env-b")).toBe(first); + + // A removed registry row does forget its bus. + useEnvironmentStore.getState().setEnvironments([]); + useEnvironmentStore.getState().setEnvironments([summary("env-b")]); + useEnvironmentStore.setState({ activeEnvironmentId: "env-b" }); + expect(createEventBus("env-b")).not.toBe(first); + }); + it("never paints a background environment as connected (P-25)", async () => { const { initializeEnvironmentRuntime } = await import("./environment-runtime"); useEnvironmentStore.getState().setEnvironments([summary("env-b"), summary("env-c")]); diff --git a/frontend/src/lib/remote/environment-runtime.ts b/frontend/src/lib/remote/environment-runtime.ts index f1a9b2c20e..c1ca66707c 100644 --- a/frontend/src/lib/remote/environment-runtime.ts +++ b/frontend/src/lib/remote/environment-runtime.ts @@ -48,7 +48,15 @@ interface RuntimeEntry { entry: EnvironmentEntry; supervisor: ConnectionSupervisor; socketLive: boolean; - bus: NetworkEventBus | null; + /** + * The environment's ONE bus instance, from the app-lifetime bus registry. + * + * `EventProvider` memoizes the bus on `environmentId` alone, so rebuilding it for a + * same-id reactivation (a flag toggle, a re-activate) would leave the whole mounted + * tree subscribed to an orphaned instance while the badge still read Connected. + * Identity is therefore stable per environment; only the relay wiring is swapped. + */ + bus: NetworkEventBus; detachRelay: (() => void) | null; relayKind: "full" | "health" | null; } @@ -217,9 +225,18 @@ export function initializeEnvironmentRuntime(): () => void { runtime.relayKind = "health"; }; - const buildActiveBus = (runtime: RuntimeEntry): void => { - detachRelay(runtime); - const environmentId = runtime.entry.id; + /** + * The app-lifetime bus registry: exactly ONE `NetworkEventBus` per environment id, + * outliving deactivation, flag toggles, and runtime reconciliation. Only a removed + * registry row forgets its bus. + */ + const buses = new Map(); + + const projectionBus = (environmentId: string): NetworkEventBus => { + const existing = buses.get(environmentId); + if (existing !== undefined) { + return existing; + } const bus = new NetworkEventBus({ environmentId, localBus, @@ -239,6 +256,11 @@ export function initializeEnvironmentRuntime(): () => void { void getQueryClient(environmentId).invalidateQueries(); }, onRestartRequired: (cause) => { + // Resolved at call time, never captured: the bus outlives any single runtime. + const runtime = runtimes.get(environmentId); + if (runtime === undefined) { + return; + } // `revoked` / `host_disabled` are the host WITHDRAWING the session. Routing // them to `streamLost` would redial the 16 s ladder forever against a host // that already refused this device, instead of showing the re-pair state. @@ -251,10 +273,19 @@ export function initializeEnvironmentRuntime(): () => void { runtime.supervisor.streamLost(); }, }); - runtime.bus = bus; + buses.set(environmentId, bus); + return bus; + }; + + /** Points the environment's stable bus at the live relay and starts projecting. */ + const attachProjectionRelay = (runtime: RuntimeEntry): void => { + detachRelay(runtime); + // Whatever the host did while this bus was not projecting is unobserved, so the + // next attempt must cold-hydrate rather than resume a cursor it stopped honouring. + runtime.bus.abandonStream(); runtime.detachRelay = attachRemoteStreamRelay({ localBus, - target: bus, + target: runtime.bus, onFrameActivity: () => runtime.supervisor.noteFrameActivity(), onStreamClosed: () => streamClosed(runtime), onUndecodableFrame: () => runtime.supervisor.streamLost(), @@ -329,7 +360,7 @@ export function initializeEnvironmentRuntime(): () => void { if (useEnvironmentStore.getState().activeEnvironmentId !== environmentId) { return; } - await runtime.bus?.beginStream(outcome); + await runtime.bus.beginStream(outcome); }, hasLiveSocket: () => runtime.socketLive, onStateChange: (state) => { @@ -340,7 +371,7 @@ export function initializeEnvironmentRuntime(): () => void { entry, supervisor, socketLive: false, - bus: null, + bus: projectionBus(environmentId), detachRelay: null, relayKind: null, }; @@ -351,7 +382,8 @@ export function initializeEnvironmentRuntime(): () => void { const previous = runtimes.get(activeEnvironmentId); const demoted = previous !== undefined && previous.entry.id !== environmentId; if (demoted) { - previous.bus = null; + // The instance survives — React holds it — but it stops projecting. + previous.bus.abandonStream(); attachHealthRelay(previous); } activeEnvironmentId = environmentId; @@ -370,8 +402,8 @@ export function initializeEnvironmentRuntime(): () => void { if (runtime === undefined) { return; } - buildActiveBus(runtime); - // A fresh supervisor attempt pairs the fresh bus with the next hello H barrier. + attachProjectionRelay(runtime); + // A fresh supervisor attempt pairs the reset bus with the next hello H barrier. runtime.supervisor.stop(); runtime.supervisor.start(); }; @@ -380,7 +412,7 @@ export function initializeEnvironmentRuntime(): () => void { for (const [environmentId, runtime] of runtimes) { runtime.supervisor.stop(); detachRelay(runtime); - runtime.bus = null; + runtime.bus.abandonStream(); confirmedScopes.delete(environmentId); } runtimes.clear(); @@ -397,7 +429,10 @@ export function initializeEnvironmentRuntime(): () => void { if (!wanted.has(environmentId)) { runtime.supervisor.stop(); detachRelay(runtime); + runtime.bus.abandonStream(); runtimes.delete(environmentId); + // The registry row is gone, so this environment's bus identity may go too. + buses.delete(environmentId); confirmedScopes.delete(environmentId); removeQueryClient(environmentId); } @@ -411,7 +446,7 @@ export function initializeEnvironmentRuntime(): () => void { const runtime = createRuntime(entry); runtimes.set(entry.id, runtime); if (entry.id === state.activeEnvironmentId) { - buildActiveBus(runtime); + attachProjectionRelay(runtime); } else { attachHealthRelay(runtime); } @@ -420,11 +455,13 @@ export function initializeEnvironmentRuntime(): () => void { }; registerRemoteEventBusFactory((environmentId, fallbackLocalBus) => { + // `relayKind`, not bus existence: the bus is permanent, so what decides whether a + // consumer gets the real projector is whether it is currently wired to the relay. const runtime = runtimes.get(environmentId); return enabled && environmentId === activeEnvironmentId && - runtime?.bus !== null && - runtime?.bus !== undefined + runtime !== undefined && + runtime.relayKind === "full" ? runtime.bus : detachedBus(environmentId, fallbackLocalBus); }); diff --git a/frontend/src/lib/remote/network-event-bus.ts b/frontend/src/lib/remote/network-event-bus.ts index 71018e7ee9..8daa57700b 100644 --- a/frontend/src/lib/remote/network-event-bus.ts +++ b/frontend/src/lib/remote/network-event-bus.ts @@ -336,6 +336,20 @@ export class NetworkEventBus implements EventBus { } } + /** + * This bus stopped being its environment's projector (deactivation, flag toggle, + * teardown). The INSTANCE survives — React memoizes it by environment id — but its + * stream state must not: whatever the host does while nobody projects, the next + * activation has to cold-hydrate rather than resume a cursor it stopped honouring. + */ + abandonStream(): void { + this.discardCursor(); + this.phase = "idle"; + this.buffer = []; + this.generation += 1; + this.settleReplay(new Error("stream abandoned: this bus is no longer projecting")); + } + /** The socket ended. The supervisor owns the redial; the bus only invalidates state. */ handleStreamClosed(): void { if (this.phase === "hydrating") { From 730529551ef17ef95802871297ffbbe05aafc763 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:22:24 +0300 Subject: [PATCH 149/416] test(remote): close the store-enumeration blind spots in the isolation guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scan required a bare 'zustand' specifier AND a literal create<, so an untyped create(persist(...)), createWithEqualityFn, zustand/vanilla, and aliased imports were all invisible — a new env-owned store could skip the isolation inventory, and the reset-on-switch funnel with it, on green CI. Matches the imported binding from any zustand entry point instead, with unit coverage for each previously invisible shape. --- .../remote/store-isolation-inventory.test.ts | 52 ++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/frontend/src/lib/remote/store-isolation-inventory.test.ts b/frontend/src/lib/remote/store-isolation-inventory.test.ts index 6a13926bd7..b95dc662ea 100644 --- a/frontend/src/lib/remote/store-isolation-inventory.test.ts +++ b/frontend/src/lib/remote/store-isolation-inventory.test.ts @@ -8,6 +8,38 @@ import { useProjectStore } from "@/stores/projectStore"; import { useTicketingStore } from "@/stores/ticketingStore"; import { STORE_ISOLATION_INVENTORY } from "./store-isolation-inventory"; +/** + * Every local binding a module imports from ANY zustand entry point — `zustand`, + * `zustand/vanilla`, `zustand/traditional`, and aliased forms + * (`import { create as createStore }`). Matching the binding rather than the literal + * `create<` is what keeps an untyped `create(persist(...))`, a + * `createWithEqualityFn`, or an alias from adding an env-owned store that skips this + * inventory — and therefore skips the reset-on-switch funnel — with green CI. + */ +function zustandCreateBindings(source: string): string[] { + const bindings: string[] = []; + const imports = source.matchAll( + /import\s+([^;]+?)\s+from\s+["']zustand(?:\/[\w-]+)*["']/g + ); + for (const [, clause] of imports) { + for (const [, imported, alias] of (clause ?? "").matchAll( + /(?:^|[{,\s])([A-Za-z_$][\w$]*)(?:\s+as\s+([A-Za-z_$][\w$]*))?/g + )) { + const local = alias ?? imported; + if (local !== undefined && /^create/i.test(imported ?? "")) { + bindings.push(local); + } + } + } + return bindings; +} + +function createsStore(source: string): boolean { + return zustandCreateBindings(source).some((binding) => + new RegExp(`\\b${binding}\\s*[<(]`).test(source) + ); +} + function findStores(root: string): string[] { const found: string[] = []; const walk = (directory: string) => { @@ -17,7 +49,7 @@ function findStores(root: string): string[] { if (entry.isDirectory()) walk(path); else if (!/\.test\./.test(entry.name)) { const source = readFileSync(path, "utf8"); - if (/from ["']zustand["']/.test(source) && /\bcreate\s* { ); }); + it("detects create sites the old `create<` scan was blind to", () => { + expect( + createsStore(`import { create } from "zustand";\nexport const s = create(persist(f, o));`) + ).toBe(true); + expect( + createsStore( + `import { createWithEqualityFn } from "zustand/traditional";\nconst s = createWithEqualityFn(f);` + ) + ).toBe(true); + expect( + createsStore( + `import { create as createStore } from "zustand/vanilla";\nconst s = createStore(f);` + ) + ).toBe(true); + expect(createsStore(`import { persist } from "zustand/middleware";`)).toBe(false); + expect(createsStore(`const create = other();\ncreate(1);`)).toBe(false); + }); + it("matches every persisted partialize contract with disjoint fields", () => { const stores = new Map([ ["ralphx-project-store", useProjectStore], From 7546e7864aa472e3777317293de08b676c549bc8 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:23:41 +0300 Subject: [PATCH 150/416] fix(remote): surface a refused environment switch instead of swallowing it The switcher discarded the rejection the store deliberately rethrows after reverting, so a backend refusal became a UI no-op: a double remount flicker, in-flight REMOTE_FORBIDDEN failures, then a silent revert with no explanation. The only covering test asserted that silence. Raises a toast naming the environment and the transport code (parsed from the raw '{CODE}: message' IPC rendering when the rejection is not already typed). --- .../layout/EnvironmentSwitcher.test.tsx | 13 ++++++-- .../components/layout/EnvironmentSwitcher.tsx | 30 +++++++++++++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/layout/EnvironmentSwitcher.test.tsx b/frontend/src/components/layout/EnvironmentSwitcher.test.tsx index 0fdef50b06..d120abe217 100644 --- a/frontend/src/components/layout/EnvironmentSwitcher.test.tsx +++ b/frontend/src/components/layout/EnvironmentSwitcher.test.tsx @@ -1,5 +1,6 @@ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; +import { toast } from "sonner"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { remoteEnvironmentsApi } from "@/api/remote-environments"; @@ -15,6 +16,10 @@ import type { RemoteEnvironmentSummary } from "@/api/remote-environments"; import { EnvironmentSwitcher } from "./EnvironmentSwitcher"; import { ENVIRONMENT_STATUS_DOT } from "./environment-switcher-status"; +vi.mock("sonner", () => ({ + toast: { error: vi.fn() }, +})); + vi.mock("@/api/remote-environments", () => ({ remoteEnvironmentsApi: { setActiveEnvironment: vi.fn(), @@ -215,10 +220,10 @@ describe("EnvironmentSwitcher", () => { expect(trigger).toHaveFocus(); }); - it("follows the store when a failed optimistic switch reverts", async () => { + it("follows the store and surfaces the error when a switch is refused", async () => { seed(["connected"]); vi.mocked(remoteEnvironmentsApi.setActiveEnvironment).mockRejectedValue( - new Error("refused"), + "REMOTE_FORBIDDEN: the proxy still points at another environment", ); renderSwitcher(); await openSwitcher(); @@ -233,5 +238,9 @@ describe("EnvironmentSwitcher", () => { "This Mac", ); }); + // A silent revert would leave the user with an unexplained remount flicker. + expect(toast.error).toHaveBeenCalledWith("Could not switch to Remote 0", { + description: "REMOTE_FORBIDDEN", + }); }); }); diff --git a/frontend/src/components/layout/EnvironmentSwitcher.tsx b/frontend/src/components/layout/EnvironmentSwitcher.tsx index 7ecc2baa98..b58e6ecf02 100644 --- a/frontend/src/components/layout/EnvironmentSwitcher.tsx +++ b/frontend/src/components/layout/EnvironmentSwitcher.tsx @@ -8,6 +8,7 @@ import { type KeyboardEvent, } from "react"; import { Check, ChevronDown } from "lucide-react"; +import { toast } from "sonner"; import { Popover, @@ -26,6 +27,10 @@ import { useEnvironmentStore, } from "@/stores/environmentStore"; import { useUiStore } from "@/stores/uiStore"; +import { + isRemoteTransportError, + parseRemoteTransportErrorCode, +} from "@/lib/remote/transport-errors"; import { ENVIRONMENT_STATUS_DOT } from "./environment-switcher-status"; @@ -102,6 +107,18 @@ const EnvironmentRow = memo(function EnvironmentRow({ ); }); +/** The transport code where there is one, so the toast names the actual refusal. */ +function switchFailureDetail(error: unknown): string { + if (isRemoteTransportError(error)) { + return error.code; + } + const code = parseRemoteTransportErrorCode(error); + if (code !== null) { + return code; + } + return error instanceof Error ? error.message : String(error); +} + export interface EnvironmentSwitcherProps { open?: boolean; onOpenChange?: (open: boolean) => void; @@ -153,11 +170,20 @@ export const EnvironmentSwitcher = memo(function EnvironmentSwitcher({ (id: string) => { setOpen(false); if (id !== activeEnvironmentId) { - void setActiveEnvironment(id).catch(() => undefined); + const name = + environments.find((environment) => environment.id === id)?.name ?? id; + void setActiveEnvironment(id).catch((error: unknown) => { + // Rust refused the switch and the store already reverted. Swallowing the + // rejection would turn a backend refusal into a UI no-op: a double remount + // flicker, in-flight REMOTE_FORBIDDEN failures, then a silent revert. + toast.error(`Could not switch to ${name}`, { + description: switchFailureDetail(error), + }); + }); } queueMicrotask(() => triggerRef.current?.focus()); }, - [activeEnvironmentId, setActiveEnvironment, setOpen], + [activeEnvironmentId, environments, setActiveEnvironment, setOpen], ); const handleOptionKeyDown = useCallback( From 03d2456b1cace4f05b2b3f2c7ae4c8805b6c7598 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:14:34 +0300 Subject: [PATCH 151/416] feat: add-environment pairing flow (PR 2.5-a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the client half of pairing: paste a `ralphx://pair` link or enter host + code, see who the host actually is before a single-use code is consumed, then pair. The exchange, the device token, and the Keychain write stay in Rust — the webview never sees a credential (P-18). Backend addition is one read-only command. `preview_remote_environment` reuses the descriptor fetch and version-contradiction gate `pair()` already runs, via a shared `pairing_descriptor` helper, so a preview that says "compatible" and a pair that refuses can never disagree. It writes nothing: tests assert zero row writes, zero secret-store calls, and an unmoved active-environment mirror. Also closes two gaps this flow would otherwise expose: - The registry was never loaded. `loadEnvironments`/`hydrateActiveEnvironment` had no production callers, so a paired environment was durable in SQLite and invisible in the app. The environment runtime — which already owns "flag on ⇒ environments become live" — now pulls it, list before active id so Rust's authority is never clamped away. - Pairing URLs carrying a query or fragment are now rejected rather than trusted, at the form (typed field copy) and at the Rust sink. `base_url` is the stem every derived URL is built from; unshaped input re-emerges glued after those paths. `clearEnvScopedStorage` lands for the P-27 staged-removal seam 2.5-b uses. --- frontend/src/api/remote-environments.ts | 28 + .../connections/AddEnvironmentDialog.test.tsx | 466 ++++++++++++++ .../connections/AddEnvironmentDialog.tsx | 588 ++++++++++++++++++ .../connections/pairing-errors.test.ts | 105 ++++ .../settings/connections/pairing-errors.ts | 93 +++ .../remote-access/RemoteAccessSection.tsx | 15 +- .../remote-access-utils.pairing-parse.test.ts | 162 +++++ .../remote-access/remote-access-utils.ts | 142 +++++ .../settings/usePaintBoundaryHydration.ts | 22 + .../remote/env-scoped-storage.clear.test.ts | 59 ++ frontend/src/lib/remote/env-scoped-storage.ts | 24 + .../environment-runtime.registry-load.test.ts | 193 ++++++ .../src/lib/remote/environment-runtime.ts | 28 + .../src/lib/remote/local-only-commands.ts | 6 + .../application/remote_environment_service.rs | 104 +++- .../remote_environment_service_tests.rs | 220 +++++++ src-tauri/src/commands/registry.rs | 1 + .../commands/remote_environment_commands.rs | 55 +- 18 files changed, 2282 insertions(+), 29 deletions(-) create mode 100644 frontend/src/components/settings/connections/AddEnvironmentDialog.test.tsx create mode 100644 frontend/src/components/settings/connections/AddEnvironmentDialog.tsx create mode 100644 frontend/src/components/settings/connections/pairing-errors.test.ts create mode 100644 frontend/src/components/settings/connections/pairing-errors.ts create mode 100644 frontend/src/components/settings/remote-access/remote-access-utils.pairing-parse.test.ts create mode 100644 frontend/src/components/settings/usePaintBoundaryHydration.ts create mode 100644 frontend/src/lib/remote/env-scoped-storage.clear.test.ts create mode 100644 frontend/src/lib/remote/environment-runtime.registry-load.test.ts diff --git a/frontend/src/api/remote-environments.ts b/frontend/src/api/remote-environments.ts index 3968bbf85d..dbe3a0a8f2 100644 --- a/frontend/src/api/remote-environments.ts +++ b/frontend/src/api/remote-environments.ts @@ -30,7 +30,35 @@ export const remoteEnvironmentSummarySchema = z.object({ export type RemoteEnvironmentSummary = z.infer; +/** + * Pre-pair host identity (PR 2.5). Descriptor truth only — the wire descriptor carries + * no host display name and no project count, so neither appears here. + */ +export const remoteEnvironmentPreviewSchema = z.object({ + environmentId: z.string(), + appVersion: z.string(), + platform: z.string(), + protocolVersion: z.number(), + minClientProtocol: z.number(), + /** The existing row's name when this host is already registered (§6.1 upsert dedup). */ + alreadyPairedAs: z.string().nullable(), +}); + +export type RemoteEnvironmentPreview = z.infer; + export const remoteEnvironmentsApi = { + /** + * Read-only identity probe run before a single-use pairing code is consumed. + * Writes nothing on either side; safe to call again after a failure. + */ + preview(url: string): Promise { + return typedInvoke( + "preview_remote_environment", + { input: { url } }, + remoteEnvironmentPreviewSchema + ); + }, + /** Pairing exchange runs entirely in the Rust backend (§4.2). */ pair(url: string, code: string, name: string): Promise { return typedInvoke( diff --git a/frontend/src/components/settings/connections/AddEnvironmentDialog.test.tsx b/frontend/src/components/settings/connections/AddEnvironmentDialog.test.tsx new file mode 100644 index 0000000000..a8fd52c722 --- /dev/null +++ b/frontend/src/components/settings/connections/AddEnvironmentDialog.test.tsx @@ -0,0 +1,466 @@ +import { act, fireEvent, render as rtlRender, screen, waitFor } from "@testing-library/react"; +import type { ReactElement } from "react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { TooltipProvider } from "@/components/ui/tooltip"; +import { LOCAL_ENVIRONMENT_ID, useEnvironmentStore } from "@/stores/environmentStore"; + +import { AddEnvironmentDialog } from "./AddEnvironmentDialog"; + +const { previewMock, pairMock, listMock, setActiveMock } = vi.hoisted(() => ({ + previewMock: vi.fn(), + pairMock: vi.fn(), + listMock: vi.fn(), + setActiveMock: vi.fn(), +})); + +vi.mock("@/api/remote-environments", () => ({ + remoteEnvironmentsApi: { + preview: previewMock, + pair: pairMock, + list: listMock, + setActiveEnvironment: setActiveMock, + getActiveEnvironment: vi.fn(), + remove: vi.fn(), + }, +})); + +function preview(overrides: Record = {}) { + return { + environmentId: "a1b2c3d4e5f6g7h8f9", + appVersion: "0.9.4", + platform: "macOS", + protocolVersion: 1, + minClientProtocol: 1, + alreadyPairedAs: null, + ...overrides, + }; +} + +function summary(overrides: Record = {}) { + return { + id: "row-1", + environmentId: "a1b2c3d4e5f6g7h8f9", + name: "Studio Mac", + baseUrl: "https://studio.tail-x.ts.net:3849", + candidateUrls: [], + scopes: ["ui:read"], + protocolVersion: 1, + status: "active" as const, + createdAt: "2026-07-28T00:00:00Z", + lastConnectedAt: null, + ...overrides, + }; +} + +/** The app mounts one global TooltipProvider (App.tsx); tests supply their own. */ +function render(ui: ReactElement) { + return rtlRender({ui}); +} + +const PAIRING_URL = + "ralphx://pair?host=https%3A%2F%2Fstudio.tail-x.ts.net%3A3849#code=rxp_ABCD1234EFGH"; + +function resetStore(): void { + useEnvironmentStore.setState({ + activeEnvironmentId: LOCAL_ENVIRONMENT_ID, + environments: [{ id: LOCAL_ENVIRONMENT_ID, name: "This Mac", kind: "local" }], + connectionStates: { [LOCAL_ENVIRONMENT_ID]: "connected" }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + previewMock.mockResolvedValue(preview()); + pairMock.mockResolvedValue(summary()); + listMock.mockResolvedValue([summary()]); + setActiveMock.mockResolvedValue(null); + resetStore(); +}); + +afterEach(() => { + resetStore(); +}); + +/** Walks the wizard to the verify step with a pasted pairing link. */ +async function reachVerifyStep(user: ReturnType) { + await user.click(screen.getByTestId("add-environment-host")); + await user.paste(PAIRING_URL); + await user.click(screen.getByTestId("add-environment-continue")); + await screen.findByTestId("add-environment-step-verify"); +} + +describe("AddEnvironmentDialog — first paint (rule 24)", () => { + it("paints the dialog shell before any invoke is dispatched", () => { + render( {}} />); + + // The shell exists on the opening commit; nothing was fetched to produce it. + expect(screen.getByTestId("add-environment-dialog")).toBeInTheDocument(); + expect(screen.getByTestId("add-environment-step-connect")).toBeInTheDocument(); + expect(previewMock).not.toHaveBeenCalled(); + expect(pairMock).not.toHaveBeenCalled(); + expect(listMock).not.toHaveBeenCalled(); + }); + + it("fetches nothing until the user submits the first step", async () => { + const user = userEvent.setup(); + render( {}} />); + + await user.click(screen.getByTestId("add-environment-host")); + await user.paste(PAIRING_URL); + + // Typing/pasting is not a submission: a paste must never consume a code. + expect(previewMock).not.toHaveBeenCalled(); + }); +}); + +describe("AddEnvironmentDialog — step 1 input", () => { + it("fills host AND code from a pasted pairing link, rendering the code grouped", async () => { + const user = userEvent.setup(); + render( {}} />); + + await user.click(screen.getByTestId("add-environment-host")); + await user.paste(PAIRING_URL); + + expect(screen.getByTestId("add-environment-host")).toHaveValue( + "https://studio.tail-x.ts.net:3849", + ); + expect(screen.getByTestId("add-environment-code")).toHaveValue( + "rxp_ ABCD 1234 EFGH", + ); + }); + + it("refuses a link whose code rode in the query string, inline on the field", async () => { + const user = userEvent.setup(); + render( {}} />); + + await user.click(screen.getByTestId("add-environment-host")); + await user.paste( + "ralphx://pair?host=https%3A%2F%2Fh.ts.net%3A3849&code=rxp_LEAKED", + ); + + expect(screen.getByTestId("add-environment-host-error")).toHaveTextContent( + /query string/i, + ); + // The burned code was NOT adopted into the form. + expect(screen.getByTestId("add-environment-code")).toHaveValue(""); + }); + + it("keeps Continue disabled until both host and code validate locally", async () => { + const user = userEvent.setup(); + render( {}} />); + const button = screen.getByTestId("add-environment-continue"); + + expect(button).toBeDisabled(); + await user.type(screen.getByTestId("add-environment-host"), "studio.ts.net:3849"); + expect(button).toBeDisabled(); + + await user.type(screen.getByTestId("add-environment-code"), "nope1234"); + expect(button).toBeDisabled(); + + await user.clear(screen.getByTestId("add-environment-code")); + await user.type(screen.getByTestId("add-environment-code"), "rxp_ABCD1234"); + expect(button).toBeEnabled(); + }); + + it("accepts a manually typed grouped code", async () => { + const user = userEvent.setup(); + render( {}} />); + + await user.type(screen.getByTestId("add-environment-host"), "studio.ts.net:3849"); + await user.click(screen.getByTestId("add-environment-code")); + await user.paste("rxp_ ABCD 1234"); + await user.click(screen.getByTestId("add-environment-continue")); + + await waitFor(() => expect(previewMock).toHaveBeenCalledTimes(1)); + expect(previewMock).toHaveBeenCalledWith("https://studio.ts.net:3849"); + }); +}); + +describe("AddEnvironmentDialog — step 2 verify", () => { + it("renders exactly the descriptor fields, and no project count", async () => { + const user = userEvent.setup(); + render( {}} />); + await reachVerifyStep(user); + + expect(screen.getByTestId("add-environment-identity")).toHaveTextContent("a1b2c3…h8f9"); + expect(screen.getByText("0.9.4")).toBeInTheDocument(); + expect(screen.getByText("macOS")).toBeInTheDocument(); + expect(screen.getByTestId("add-environment-protocol")).toHaveTextContent("v1"); + // The wire descriptor has no project count; inventing one would be a lie. + expect(screen.queryByText(/project/i)).not.toBeInTheDocument(); + }); + + it("says re-pairing UPDATES an already-paired host", async () => { + previewMock.mockResolvedValue(preview({ alreadyPairedAs: "Studio Mac" })); + const user = userEvent.setup(); + render( {}} />); + await reachVerifyStep(user); + + expect(screen.getByTestId("add-environment-already-paired")).toHaveTextContent( + /updates it rather than adding a second entry/i, + ); + expect(screen.getByTestId("add-environment-name")).toHaveValue("Studio Mac"); + }); + + it("prefills the name from the host when the host is unknown", async () => { + const user = userEvent.setup(); + render( {}} />); + await reachVerifyStep(user); + + expect(screen.getByTestId("add-environment-name")).toHaveValue( + "studio.tail-x.ts.net:3849", + ); + }); + + it("blocks Pair on an empty name", async () => { + const user = userEvent.setup(); + render( {}} />); + await reachVerifyStep(user); + + await user.clear(screen.getByTestId("add-environment-name")); + expect(screen.getByTestId("add-environment-pair")).toBeDisabled(); + expect(pairMock).not.toHaveBeenCalled(); + }); +}); + +describe("AddEnvironmentDialog — version contradiction", () => { + it("parks in blocked with both versions and never retries on its own (A-5)", async () => { + previewMock.mockRejectedValue( + new Error( + "REMOTE_VERSION_MISMATCH: host requires client protocol >= 2, this client speaks 1", + ), + ); + render( {}} />); + + // fireEvent + fake timers, deliberately: userEvent and testing-library's `waitFor` + // both schedule real-timer pollers of their own, which would mask the thing under + // assertion — work this feature schedules for later. + fireEvent.change(screen.getByTestId("add-environment-host"), { + target: { value: "https://studio.tail-x.ts.net:3849" }, + }); + fireEvent.change(screen.getByTestId("add-environment-code"), { + target: { value: "rxp_ABCD1234EFGH" }, + }); + + vi.useFakeTimers(); + try { + fireEvent.click(screen.getByTestId("add-environment-continue")); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const banner = screen.getByTestId("add-environment-blocked-banner"); + expect(banner).toHaveTextContent("Versions are incompatible"); + expect(banner).toHaveTextContent("host requires client protocol >= 2"); + expect(banner).toHaveTextContent("this client speaks 1"); + expect(previewMock).toHaveBeenCalledTimes(1); + + // Run every pending timer, repeatedly, well past any plausible backoff ladder. + // The supervisor is the sole retry owner (A-5); this feature must sit still. + await act(async () => { + vi.advanceTimersByTime(120_000); + await Promise.resolve(); + }); + + expect(previewMock).toHaveBeenCalledTimes(1); + expect(pairMock).not.toHaveBeenCalled(); + // And it is still parked in blocked, not silently re-entered. + expect(screen.getByTestId("add-environment-blocked")).toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); + + it("returns to step 1 from blocked", async () => { + previewMock.mockRejectedValue( + new Error("REMOTE_VERSION_MISMATCH: host requires client protocol >= 2, client 1"), + ); + const user = userEvent.setup(); + render( {}} />); + + await user.click(screen.getByTestId("add-environment-host")); + await user.paste(PAIRING_URL); + await user.click(screen.getByTestId("add-environment-continue")); + await user.click(await screen.findByTestId("add-environment-blocked-back")); + + expect(screen.getByTestId("add-environment-step-connect")).toBeInTheDocument(); + }); +}); + +describe("AddEnvironmentDialog — failures", () => { + it("renders an unreachable host as an actionable error, not a blocked state", async () => { + previewMock.mockRejectedValue( + new Error("REMOTE_UNREACHABLE: host unreachable: offline"), + ); + const user = userEvent.setup(); + render( {}} />); + + await user.click(screen.getByTestId("add-environment-host")); + await user.paste(PAIRING_URL); + await user.click(screen.getByTestId("add-environment-continue")); + + expect(await screen.findByTestId("add-environment-error-banner")).toHaveTextContent( + /could not reach the host/i, + ); + expect(screen.queryByTestId("add-environment-blocked")).not.toBeInTheDocument(); + }); + + it("tells the user to generate a fresh code when pairing is rejected", async () => { + pairMock.mockRejectedValue(new Error("PAIRING_REJECTED: code already used")); + const user = userEvent.setup(); + render( {}} />); + await reachVerifyStep(user); + await user.click(screen.getByTestId("add-environment-pair")); + + expect(await screen.findByTestId("add-environment-error-banner")).toHaveTextContent( + /fresh code/i, + ); + // A failed pair must not report success anywhere. + expect(screen.queryByTestId("add-environment-success")).not.toBeInTheDocument(); + }); + + it("does not refresh the registry when pairing failed", async () => { + pairMock.mockRejectedValue(new Error("PAIRING_REJECTED: nope")); + const user = userEvent.setup(); + render( {}} />); + await reachVerifyStep(user); + await user.click(screen.getByTestId("add-environment-pair")); + await screen.findByTestId("add-environment-error"); + + expect(listMock).not.toHaveBeenCalled(); + }); +}); + +describe("AddEnvironmentDialog — pairing", () => { + it("pairs with the RAW code and the normalized host, then refreshes the registry", async () => { + const onPaired = vi.fn(); + const user = userEvent.setup(); + render( {}} onPaired={onPaired} />); + await reachVerifyStep(user); + await user.click(screen.getByTestId("add-environment-pair")); + + await screen.findByTestId("add-environment-success"); + expect(pairMock).toHaveBeenCalledWith( + "https://studio.tail-x.ts.net:3849", + "rxp_ABCD1234EFGH", // raw, never the grouped display form + "studio.tail-x.ts.net:3849", + ); + expect(listMock).toHaveBeenCalledTimes(1); + expect(onPaired).toHaveBeenCalledTimes(1); + expect( + useEnvironmentStore.getState().environments.map((entry) => entry.id), + ).toContain("row-1"); + }); + + it("cannot be dismissed while the staged Rust sequence is in flight", async () => { + let releasePair: (value: unknown) => void = () => {}; + pairMock.mockImplementation( + () => + new Promise((resolve) => { + releasePair = resolve; + }), + ); + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + render(); + await reachVerifyStep(user); + await user.click(screen.getByTestId("add-environment-pair")); + + await user.keyboard("{Escape}"); + expect(onOpenChange).not.toHaveBeenCalled(); + expect(screen.getByTestId("add-environment-pair")).toBeDisabled(); + + releasePair(summary()); + await screen.findByTestId("add-environment-success"); + }); + + it("switches to the new environment on demand and closes", async () => { + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + render(); + await reachVerifyStep(user); + await user.click(screen.getByTestId("add-environment-pair")); + await user.click(await screen.findByTestId("add-environment-switch")); + + await waitFor(() => expect(setActiveMock).toHaveBeenCalledWith("row-1")); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); + + it("Done closes without switching", async () => { + const onOpenChange = vi.fn(); + const user = userEvent.setup(); + render(); + await reachVerifyStep(user); + await user.click(screen.getByTestId("add-environment-pair")); + await user.click(await screen.findByTestId("add-environment-done")); + + expect(setActiveMock).not.toHaveBeenCalled(); + expect(onOpenChange).toHaveBeenCalledWith(false); + }); +}); + +describe("AddEnvironmentDialog — re-pair", () => { + it("locks the host and prefills the name from the existing row", async () => { + previewMock.mockResolvedValue(preview({ alreadyPairedAs: "Studio Mac" })); + const user = userEvent.setup(); + render( + {}} + lockedHost="https://studio.tail-x.ts.net:3849" + initialName="Studio Mac" + />, + ); + + const host = screen.getByTestId("add-environment-host"); + expect(host).toHaveValue("https://studio.tail-x.ts.net:3849"); + expect(host).toBeDisabled(); + + await user.type(screen.getByTestId("add-environment-code"), "rxp_ABCD1234"); + await user.click(screen.getByTestId("add-environment-continue")); + await screen.findByTestId("add-environment-step-verify"); + + expect(screen.getByTestId("add-environment-name")).toHaveValue("Studio Mac"); + }); + + it("re-pairing a known host leaves ONE row — the upsert is visible end to end", async () => { + previewMock.mockResolvedValue(preview({ alreadyPairedAs: "Studio Mac" })); + // Rust upserts on environmentId, so the refreshed list still has one row. + listMock.mockResolvedValue([summary({ name: "Studio Mac" })]); + const user = userEvent.setup(); + render( + {}} + lockedHost="https://studio.tail-x.ts.net:3849" + initialName="Studio Mac" + />, + ); + + await user.type(screen.getByTestId("add-environment-code"), "rxp_ABCD1234"); + await user.click(screen.getByTestId("add-environment-continue")); + await screen.findByTestId("add-environment-step-verify"); + await user.click(screen.getByTestId("add-environment-pair")); + await screen.findByTestId("add-environment-success"); + + const remote = useEnvironmentStore + .getState() + .environments.filter((entry) => entry.id !== LOCAL_ENVIRONMENT_ID); + expect(remote).toHaveLength(1); + }); +}); + +describe("AddEnvironmentDialog — P-18", () => { + it("never renders anything token-shaped", async () => { + const user = userEvent.setup(); + const { container } = render( + {}} />, + ); + await reachVerifyStep(user); + + expect(container.textContent).not.toMatch(/rxd_|token|bearer|secret/i); + }); +}); diff --git a/frontend/src/components/settings/connections/AddEnvironmentDialog.tsx b/frontend/src/components/settings/connections/AddEnvironmentDialog.tsx new file mode 100644 index 0000000000..2a60c2b037 --- /dev/null +++ b/frontend/src/components/settings/connections/AddEnvironmentDialog.tsx @@ -0,0 +1,588 @@ +// PR 2.5-a — the add-environment wizard. +// +// The UI collects input and renders outcomes. The pairing exchange, the device token, +// and the Keychain write live entirely in Rust (P-18): nothing here ever sees a +// credential, and no step re-derives a protocol comparison the service already made. +// +// There is no retry anywhere in this file (A-5). Every failure renders a state with a +// way back; the USER retries. + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { AlertTriangle, CheckCircle2, Loader2 } from "lucide-react"; + +import { + remoteEnvironmentsApi, + type RemoteEnvironmentPreview, +} from "@/api/remote-environments"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { NoticeBanner } from "@/components/ui/notice-banner"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useEnvironmentStore } from "@/stores/environmentStore"; + +import { + groupPairingCode, + normalizePairingHostUrl, + parseManualPairingCode, + parsePairingUrl, + type PairingParseReason, +} from "../remote-access/remote-access-utils"; +import { + classifyPairingError, + describePairingFailure, + type PairingFailure, +} from "./pairing-errors"; + +/** + * Explicit union rather than a set of booleans: `previewing` and `pairing` are both + * "busy", but only one of them has consumed a single-use code, and only `blocked` is + * terminal for the attempt. Collapsing them would make those differences invisible. + */ +type WizardState = + | { step: "input" } + | { step: "previewing" } + | { step: "preview"; preview: RemoteEnvironmentPreview } + | { step: "pairing"; preview: RemoteEnvironmentPreview } + | { step: "success"; name: string; environmentRowId: string } + | { step: "blocked"; failure: PairingFailure } + | { step: "error"; failure: PairingFailure; from: "input" | "preview" }; + +const FIELD_ERROR_COPY: Record = { + "not-a-pairing-url": "That is not a RalphX pairing link.", + "missing-host": + "Enter the host address shown on the host's Remote Access pane.", + "missing-code": "Enter the pairing code shown on the host.", + "code-in-query": + "This link carries its code in the query string, where it can be logged. Generate a fresh code on the host.", + "bad-code-prefix": "Pairing codes start with rxp_.", + "bad-host-url": + "Use just the host and port, for example studio.tail-x.ts.net:3849.", + "host-url-has-query": "The host address must not include a query string.", + "host-url-has-fragment": "The host address must not include a #fragment.", +}; + +/** Visual grouping only (R-12); the raw code is what pairing receives. */ +function renderGroupedCode(code: string): string { + const { prefix, groups } = groupPairingCode(code); + return [prefix, ...groups].filter((part) => part.length > 0).join(" "); +} + +function shortEnvironmentId(environmentId: string): string { + return environmentId.length <= 12 + ? environmentId + : `${environmentId.slice(0, 6)}…${environmentId.slice(-4)}`; +} + +export interface AddEnvironmentDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Re-pair: the row's `baseUrl`, locked so a re-pair cannot silently retarget. */ + lockedHost?: string; + /** Re-pair: the existing row's name. */ + initialName?: string; + /** Fires after a successful pair, so the pane can re-list from the backend. */ + onPaired?: () => void; +} + +export function AddEnvironmentDialog({ + open, + onOpenChange, + lockedHost, + initialName, + onPaired, +}: AddEnvironmentDialogProps) { + const [state, setState] = useState({ step: "input" }); + const [hostInput, setHostInput] = useState(lockedHost ?? ""); + const [codeInput, setCodeInput] = useState(""); + const [name, setName] = useState(initialName ?? ""); + const [hostError, setHostError] = useState(null); + const [codeError, setCodeError] = useState(null); + const setActiveEnvironment = useEnvironmentStore( + (store) => store.setActiveEnvironment, + ); + + // Re-opening the dialog must not resume a previous attempt's step: a consumed code + // is gone, and a stale `preview` would offer to pair on evidence that has expired. + const wasOpen = useRef(open); + useEffect(() => { + if (open && !wasOpen.current) { + setState({ step: "input" }); + setHostInput(lockedHost ?? ""); + setCodeInput(""); + setName(initialName ?? ""); + setHostError(null); + setCodeError(null); + } + wasOpen.current = open; + }, [open, lockedHost, initialName]); + + const parsedHost = useMemo( + () => normalizePairingHostUrl(hostInput), + [hostInput], + ); + const parsedCode = useMemo( + () => parseManualPairingCode(codeInput), + [codeInput], + ); + const canContinue = parsedHost.ok && parsedCode.ok; + + /** + * A pasted pairing link fills BOTH fields. The code comes from the hash fragment + * only; a link carrying it in the query is refused rather than accepted, because + * that code has already travelled somewhere it can be logged. + */ + const handleHostChange = useCallback((raw: string) => { + setHostError(null); + if (!raw.trim().toLowerCase().startsWith("ralphx://pair")) { + setHostInput(raw); + return; + } + const parsed = parsePairingUrl(raw); + if (!parsed.ok) { + setHostInput(raw); + setHostError(FIELD_ERROR_COPY[parsed.reason]); + return; + } + setHostInput(parsed.host); + setCodeInput(parsed.code); + setCodeError(null); + // The name is NOT prefilled here. It is decided at the verify step, where + // `alreadyPairedAs` is known — prefilling from the host now would win the + // "already empty?" check and hide the existing row's name from a re-pair. + }, []); + + const handleContinue = useCallback(async () => { + if (!parsedHost.ok) { + setHostError(FIELD_ERROR_COPY[parsedHost.reason]); + return; + } + if (!parsedCode.ok) { + setCodeError(FIELD_ERROR_COPY[parsedCode.reason]); + return; + } + setState({ step: "previewing" }); + try { + const preview = await remoteEnvironmentsApi.preview(parsedHost.url); + setName((current) => + current.trim() !== "" + ? current + : (preview.alreadyPairedAs ?? + parsedHost.url.replace(/^https?:\/\//, "")), + ); + setState({ step: "preview", preview }); + } catch (error) { + const failure = classifyPairingError(error); + setState( + failure.kind === "version" + ? { step: "blocked", failure } + : { step: "error", failure, from: "input" }, + ); + } + }, [parsedHost, parsedCode]); + + const handlePair = useCallback(async () => { + if (state.step !== "preview" || !parsedHost.ok || !parsedCode.ok) { + return; + } + const trimmedName = name.trim(); + if (trimmedName === "") { + return; + } + setState({ step: "pairing", preview: state.preview }); + try { + const summary = await remoteEnvironmentsApi.pair( + parsedHost.url, + parsedCode.code, + trimmedName, + ); + // The pane and the runtime both read the registry from Rust; refreshing here is + // what makes the new row appear in the switcher. + await useEnvironmentStore.getState().loadEnvironments(); + onPaired?.(); + setState({ + step: "success", + name: summary.name, + environmentRowId: summary.id, + }); + } catch (error) { + const failure = classifyPairingError(error); + setState( + failure.kind === "version" + ? { step: "blocked", failure } + : { step: "error", failure, from: "preview" }, + ); + } + }, [state, parsedHost, parsedCode, name, onPaired]); + + const busy = state.step === "previewing" || state.step === "pairing"; + + const handleOpenChange = useCallback( + (next: boolean) => { + // The Rust pairing sequence is staged and reconciler-safe, but there is no way to + // half-abort it from here, so the dialog stays put while it runs. + if (!next && state.step === "pairing") { + return; + } + onOpenChange(next); + }, + [state.step, onOpenChange], + ); + + return ( + + { + if (state.step === "pairing") { + event.preventDefault(); + } + }} + > + + + Add environment + + + {state.step === "input" || state.step === "previewing" + ? "Paste a pairing link, or enter the host and code shown on the host's Remote Access pane." + : "Confirm the host identity before pairing."} + + + + {(state.step === "input" || state.step === "previewing") && ( +
+
+ + handleHostChange(event.target.value)} + aria-invalid={hostError !== null} + aria-describedby={ + hostError ? "add-environment-host-error" : undefined + } + /> + {hostError !== null && ( +

+ {hostError} +

+ )} +
+ +
+ + { + setCodeError(null); + setCodeInput(event.target.value); + }} + aria-invalid={codeError !== null} + aria-describedby={ + codeError ? "add-environment-code-error" : undefined + } + /> + {codeError !== null && ( +

+ {codeError} +

+ )} +
+ +
+ + +
+
+ )} + + {(state.step === "preview" || state.step === "pairing") && ( +
+
+

+ Host identity +

+
+
+
Environment
+
+ {shortEnvironmentId(state.preview.environmentId)} +
+
+
+
RalphX
+
+ {state.preview.appVersion} +
+
+
+
Platform
+
+ {state.preview.platform} +
+
+
+
Protocol
+
+ {/* Reaching this step IS the compatibility verdict — the service + already refused a contradiction, so nothing is recomputed here. */} + v{state.preview.protocolVersion} · compatible +
+
+
+
+ + {state.preview.alreadyPairedAs !== null && ( + + Already paired as “{state.preview.alreadyPairedAs}” — pairing + again updates it rather than adding a second entry. + + )} + +
+ + setName(event.target.value)} + /> +
+ +
+ {state.step === "pairing" ? ( + + + + + + + + Pairing is in progress and cannot be interrupted + + + ) : ( + + )} + +
+
+ )} + + {state.step === "blocked" && ( +
+ } + title={describePairingFailure(state.failure).title} + testId="add-environment-blocked-banner" + > + {describePairingFailure(state.failure).detail} + +
+ + +
+
+ )} + + {state.step === "error" && ( +
+ } + title={describePairingFailure(state.failure).title} + testId="add-environment-error-banner" + > + {describePairingFailure(state.failure).detail} + +
+ + +
+
+ )} + + {state.step === "success" && ( +
+ } + title="Paired" + testId="add-environment-success-banner" + > + “{state.name}” is available in the environment switcher. + +
+ + +
+
+ )} +
+
+ ); +} diff --git a/frontend/src/components/settings/connections/pairing-errors.test.ts b/frontend/src/components/settings/connections/pairing-errors.test.ts new file mode 100644 index 0000000000..58ea9b9537 --- /dev/null +++ b/frontend/src/components/settings/connections/pairing-errors.test.ts @@ -0,0 +1,105 @@ +// The flow renders the SERVICE's taxonomy. It must never re-derive a version +// comparison or guess at a failure from message prose. + +import { describe, expect, it } from "vitest"; + +import { classifyPairingError, describePairingFailure } from "./pairing-errors"; + +describe("classifyPairingError", () => { + it("reads the stable code prefix the IPC boundary emits", () => { + expect( + classifyPairingError( + new Error( + "REMOTE_VERSION_MISMATCH: host requires client protocol >= 2, this client speaks 1", + ), + ), + ).toEqual({ + kind: "version", + code: "REMOTE_VERSION_MISMATCH", + message: "host requires client protocol >= 2, this client speaks 1", + }); + }); + + it("classifies a rejected pairing code as user-fixable", () => { + expect(classifyPairingError(new Error("PAIRING_REJECTED: code expired"))).toEqual({ + kind: "code", + code: "PAIRING_REJECTED", + message: "code expired", + }); + }); + + it("classifies unreachable hosts", () => { + expect( + classifyPairingError(new Error("REMOTE_UNREACHABLE: host unreachable: offline")), + ).toEqual({ + kind: "unreachable", + code: "REMOTE_UNREACHABLE", + message: "host unreachable: offline", + }); + }); + + it("classifies a bad URL and a host identity mismatch distinctly", () => { + expect(classifyPairingError(new Error("INVALID_PAIRING_URL: missing host")).kind).toBe( + "url", + ); + expect( + classifyPairingError(new Error("HOST_IDENTITY_MISMATCH: descriptor a, response b")) + .kind, + ).toBe("identity"); + }); + + it("falls back to unknown rather than pattern-matching prose", () => { + // A message with no code prefix is NOT parsed for keywords: guessing "expired" + // from free text is how a transport failure gets rendered as a bad code. + const result = classifyPairingError(new Error("the code expired, probably")); + expect(result.kind).toBe("unknown"); + expect(result.message).toBe("the code expired, probably"); + }); + + it("survives non-Error throwables", () => { + expect(classifyPairingError("boom").kind).toBe("unknown"); + expect(classifyPairingError(undefined).kind).toBe("unknown"); + expect(classifyPairingError(null).message.length).toBeGreaterThan(0); + }); + + it("does not treat an unknown code prefix as a known kind", () => { + const result = classifyPairingError(new Error("SOME_NEW_CODE: something happened")); + expect(result.kind).toBe("unknown"); + expect(result.code).toBe("SOME_NEW_CODE"); + }); +}); + +describe("describePairingFailure", () => { + it("gives every kind an actionable sentence naming what the user does next", () => { + for (const kind of [ + "code", + "unreachable", + "url", + "identity", + "unknown", + "version", + ] as const) { + const copy = describePairingFailure({ kind, code: "X", message: "detail" }); + expect(copy.title.length).toBeGreaterThan(0); + expect(copy.detail.length).toBeGreaterThan(0); + } + }); + + it("tells the user to generate a fresh code when the code was rejected", () => { + const copy = describePairingFailure({ + kind: "code", + code: "PAIRING_REJECTED", + message: "expired", + }); + expect(copy.detail).toMatch(/fresh code/i); + }); + + it("carries the service's own message for a version contradiction", () => { + const copy = describePairingFailure({ + kind: "version", + code: "REMOTE_VERSION_MISMATCH", + message: "host requires client protocol >= 2, this client speaks 1", + }); + expect(copy.detail).toContain("host requires client protocol >= 2"); + }); +}); diff --git a/frontend/src/components/settings/connections/pairing-errors.ts b/frontend/src/components/settings/connections/pairing-errors.ts new file mode 100644 index 0000000000..0a62d3e91e --- /dev/null +++ b/frontend/src/components/settings/connections/pairing-errors.ts @@ -0,0 +1,93 @@ +// Failure taxonomy for the add-environment flow (PR 2.5). +// +// The Rust service already decided what went wrong and encoded it as a stable code on +// the IPC boundary (`"{CODE}: {message}"`, `RemoteEnvironmentError::to_command_error`). +// This module only READS that code. It never re-derives a protocol comparison and never +// pattern-matches the prose — a message that says "expired" may well be a transport +// failure, and rendering it as a bad pairing code would send the user to regenerate a +// code that was never the problem. + +/** What the user can do about it, which is the only distinction the UI needs. */ +export type PairingFailureKind = + "version" | "code" | "unreachable" | "url" | "identity" | "unknown"; + +export interface PairingFailure { + kind: PairingFailureKind; + /** The backend's stable code, or `""` when the throwable carried none. */ + code: string; + message: string; +} + +const KIND_BY_CODE: Record = { + REMOTE_VERSION_MISMATCH: "version", + PAIRING_REJECTED: "code", + REMOTE_UNAUTHORIZED: "code", + REMOTE_UNREACHABLE: "unreachable", + INVALID_PAIRING_URL: "url", + HOST_IDENTITY_MISMATCH: "identity", +}; + +const CODE_PREFIX = /^([A-Z][A-Z0-9_]*): ([\s\S]*)$/; + +export function classifyPairingError(error: unknown): PairingFailure { + const raw = + error instanceof Error + ? error.message + : typeof error === "string" && error.length > 0 + ? error + : "The pairing attempt failed for an unknown reason."; + + const match = CODE_PREFIX.exec(raw); + if (match === null) { + return { kind: "unknown", code: "", message: raw }; + } + const code = match[1] ?? ""; + const message = match[2] ?? raw; + return { kind: KIND_BY_CODE[code] ?? "unknown", code, message }; +} + +export interface PairingFailureCopy { + title: string; + detail: string; +} + +/** + * User-facing copy per kind. Every sentence names the next action, because there is no + * automatic retry anywhere in this feature (A-5) — the user is the retry mechanism. + */ +export function describePairingFailure( + failure: PairingFailure, +): PairingFailureCopy { + switch (failure.kind) { + case "version": + return { + title: "Versions are incompatible", + detail: `${failure.message}. Update RalphX on this Mac or on the host, then try again.`, + }; + case "code": + return { + title: "Pairing failed", + detail: + "The code was rejected (expired or already used). Generate a fresh code on the host and try again.", + }; + case "unreachable": + return { + title: "Host unreachable", + detail: + "This Mac could not reach the host. Check that the host is awake, on the same tailnet, and has Remote Access running, then try again.", + }; + case "url": + return { + title: "That address cannot be used", + detail: `${failure.message}. Enter the host exactly as the host's Remote Access pane shows it.`, + }; + case "identity": + return { + title: "Host identity changed", + detail: + "The host that answered is not the one this code was issued for. Generate a fresh code on the host you intend to pair with.", + }; + case "unknown": + return { title: "Pairing failed", detail: failure.message }; + } +} diff --git a/frontend/src/components/settings/remote-access/RemoteAccessSection.tsx b/frontend/src/components/settings/remote-access/RemoteAccessSection.tsx index 88f2a61825..efeccba598 100644 --- a/frontend/src/components/settings/remote-access/RemoteAccessSection.tsx +++ b/frontend/src/components/settings/remote-access/RemoteAccessSection.tsx @@ -33,10 +33,7 @@ import { Switch } from "@/components/ui/switch"; import { useFeatureFlags } from "@/hooks/useFeatureFlags"; import { useEventBus } from "@/providers/EventProvider"; -import { - cancelScheduledJob, - scheduleAfterPaint, -} from "../SettingsDialog.performance"; +import { usePaintBoundaryHydration } from "../usePaintBoundaryHydration"; import { RemoteDeviceList } from "./RemoteDeviceList"; import { RemotePairingCard } from "./RemotePairingCard"; @@ -92,16 +89,6 @@ function errorMessage(error: unknown, fallback: string): string { return typeof error === "string" && error.length > 0 ? error : fallback; } -/** Paint-boundary gate (rule 24): true only after a frame + macrotask have passed. */ -function usePaintBoundaryHydration(): boolean { - const [hydrated, setHydrated] = useState(false); - useEffect(() => { - const job = scheduleAfterPaint(() => setHydrated(true)); - return () => cancelScheduledJob(job); - }, []); - return hydrated; -} - // ============================================================================ // Listener card (enable toggle + exposure mode + endpoints) // ============================================================================ diff --git a/frontend/src/components/settings/remote-access/remote-access-utils.pairing-parse.test.ts b/frontend/src/components/settings/remote-access/remote-access-utils.pairing-parse.test.ts new file mode 100644 index 0000000000..964528f14e --- /dev/null +++ b/frontend/src/components/settings/remote-access/remote-access-utils.pairing-parse.test.ts @@ -0,0 +1,162 @@ +// PR 2.5-a: the client half of R-12 — parsing what the host pane builds. + +import { describe, expect, it } from "vitest"; + +import { + buildPairingUrl, + normalizePairingHostUrl, + parsePairingUrl, + parseManualPairingCode, +} from "./remote-access-utils"; + +describe("parsePairingUrl", () => { + it("round-trips the host pane's own buildPairingUrl output", () => { + const url = buildPairingUrl("https://studio.tail-x.ts.net:3849", "rxp_ABCD1234EFGH"); + expect(parsePairingUrl(url)).toEqual({ + ok: true, + host: "https://studio.tail-x.ts.net:3849", + code: "rxp_ABCD1234EFGH", + }); + }); + + it("reads the code from the hash fragment ONLY (§3.7)", () => { + // A code in the query string travelled through intermediaries; it is burned. + const result = parsePairingUrl( + "ralphx://pair?host=https%3A%2F%2Fh.ts.net%3A3849&code=rxp_ABCD1234", + ); + expect(result).toEqual({ ok: false, reason: "code-in-query" }); + }); + + it("rejects a query-borne code even when a fragment code is also present", () => { + const result = parsePairingUrl( + "ralphx://pair?host=https%3A%2F%2Fh.ts.net%3A3849&code=rxp_LEAKED#code=rxp_FRESH", + ); + expect(result).toEqual({ ok: false, reason: "code-in-query" }); + }); + + it("rejects a non-pairing URL", () => { + expect(parsePairingUrl("https://example.com/#code=rxp_ABCD")).toEqual({ + ok: false, + reason: "not-a-pairing-url", + }); + }); + + it("rejects a pairing URL with no host param", () => { + expect(parsePairingUrl("ralphx://pair?x=1#code=rxp_ABCD")).toEqual({ + ok: false, + reason: "missing-host", + }); + }); + + it("rejects a pairing URL with no fragment code", () => { + expect(parsePairingUrl("ralphx://pair?host=https%3A%2F%2Fh.ts.net%3A3849")).toEqual({ + ok: false, + reason: "missing-code", + }); + }); + + it("rejects a fragment code without the rxp_ prefix", () => { + expect( + parsePairingUrl("ralphx://pair?host=https%3A%2F%2Fh.ts.net%3A3849#code=ABCD1234"), + ).toEqual({ ok: false, reason: "bad-code-prefix" }); + }); + + it("rejects a host param carrying its own query or fragment", () => { + expect( + parsePairingUrl( + "ralphx://pair?host=https%3A%2F%2Fh.ts.net%3A3849%3Fx%3D1#code=rxp_ABCD", + ), + ).toEqual({ ok: false, reason: "host-url-has-query" }); + expect( + parsePairingUrl( + "ralphx://pair?host=https%3A%2F%2Fh.ts.net%3A3849%23frag#code=rxp_ABCD", + ), + ).toEqual({ ok: false, reason: "host-url-has-fragment" }); + }); + + it("never throws on garbage", () => { + for (const raw of ["", " ", "not a url", "ralphx://", "://"]) { + expect(() => parsePairingUrl(raw)).not.toThrow(); + expect(parsePairingUrl(raw).ok).toBe(false); + } + }); +}); + +describe("normalizePairingHostUrl", () => { + it("accepts bare host:port and defaults to https", () => { + expect(normalizePairingHostUrl("studio.tail-x.ts.net:3849")).toEqual({ + ok: true, + url: "https://studio.tail-x.ts.net:3849", + }); + }); + + it("preserves an explicit http scheme (tailnet-direct plaintext inside WireGuard)", () => { + expect(normalizePairingHostUrl("http://100.101.102.103:3849")).toEqual({ + ok: true, + url: "http://100.101.102.103:3849", + }); + }); + + it("strips a trailing slash so base_url derivation stays canonical", () => { + expect(normalizePairingHostUrl("https://h.ts.net:3849/")).toEqual({ + ok: true, + url: "https://h.ts.net:3849", + }); + }); + + it("rejects a query string — nothing unshaped may reach join/ws URL derivation", () => { + expect(normalizePairingHostUrl("https://h.ts.net:3849?x=1")).toEqual({ + ok: false, + reason: "host-url-has-query", + }); + }); + + it("rejects a fragment", () => { + expect(normalizePairingHostUrl("https://h.ts.net:3849#frag")).toEqual({ + ok: false, + reason: "host-url-has-fragment", + }); + }); + + it("rejects unsupported schemes and empty hosts", () => { + expect(normalizePairingHostUrl("ftp://h.ts.net")).toEqual({ + ok: false, + reason: "bad-host-url", + }); + expect(normalizePairingHostUrl(" ")).toEqual({ ok: false, reason: "missing-host" }); + }); +}); + +describe("parseManualPairingCode", () => { + it("accepts the grouped form the host pane displays", () => { + expect(parseManualPairingCode("rxp_ ABCD 1234 EFGH")).toEqual({ + ok: true, + code: "rxp_ABCD1234EFGH", + }); + }); + + it("accepts the ungrouped canonical form", () => { + expect(parseManualPairingCode("rxp_ABCD1234EFGH")).toEqual({ + ok: true, + code: "rxp_ABCD1234EFGH", + }); + }); + + it("strips interior whitespace of any width", () => { + expect(parseManualPairingCode(" rxp_\tABCD\n1234 ")).toEqual({ + ok: true, + code: "rxp_ABCD1234", + }); + }); + + it("rejects a missing prefix", () => { + expect(parseManualPairingCode("ABCD1234")).toEqual({ + ok: false, + reason: "bad-code-prefix", + }); + }); + + it("rejects a prefix with no body", () => { + expect(parseManualPairingCode("rxp_")).toEqual({ ok: false, reason: "missing-code" }); + }); +}); diff --git a/frontend/src/components/settings/remote-access/remote-access-utils.ts b/frontend/src/components/settings/remote-access/remote-access-utils.ts index dcc8a4c36c..c3ddd458d6 100644 --- a/frontend/src/components/settings/remote-access/remote-access-utils.ts +++ b/frontend/src/components/settings/remote-access/remote-access-utils.ts @@ -36,6 +36,148 @@ export function buildPairingUrl(host: string, code: string): string { return `ralphx://pair?host=${encodeURIComponent(host)}#code=${code}`; } +/** + * Why a pairing input was refused. Rendered as inline field copy, never a banner — + * every reason names something the user can fix in the field they are looking at. + */ +export type PairingParseReason = + | "not-a-pairing-url" + | "missing-host" + | "missing-code" + | "code-in-query" + | "bad-code-prefix" + | "bad-host-url" + | "host-url-has-query" + | "host-url-has-fragment"; + +export type ParsedPairingUrl = + | { ok: true; host: string; code: string } + | { ok: false; reason: PairingParseReason }; + +export type NormalizedPairingHost = + | { ok: true; url: string } + | { ok: false; reason: PairingParseReason }; + +export type ParsedPairingCode = + | { ok: true; code: string } + | { ok: false; reason: PairingParseReason }; + +const PAIRING_URL_PREFIX = "ralphx://pair"; + +/** + * Canonicalises a host string into the `scheme://host[:port]` shape the registry + * stores as `base_url`. + * + * A query string or fragment is REJECTED rather than stripped: `base_url` is the + * stem every derived URL is built from (join, ws events), so anything unshaped that + * survives here reappends after the path and produces a URL nobody authored. Bare + * `host:port` defaults to https; an explicit `http://` is preserved because the + * tailnet-direct listener terminates plaintext inside WireGuard (see + * `pickPreferredEndpoint`). + */ +export function normalizePairingHostUrl(raw: string): NormalizedPairingHost { + const trimmed = raw.trim(); + if (trimmed === "") { + return { ok: false, reason: "missing-host" }; + } + if (trimmed.includes("#")) { + return { ok: false, reason: "host-url-has-fragment" }; + } + if (trimmed.includes("?")) { + return { ok: false, reason: "host-url-has-query" }; + } + + const hasScheme = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(trimmed); + const candidate = hasScheme ? trimmed : `https://${trimmed}`; + + let parsed: URL; + try { + parsed = new URL(candidate); + } catch { + return { ok: false, reason: "bad-host-url" }; + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + return { ok: false, reason: "bad-host-url" }; + } + if (parsed.hostname === "") { + return { ok: false, reason: "missing-host" }; + } + // Only the root path is meaningful for a base URL; a deeper path is a paste error. + if (parsed.pathname !== "" && parsed.pathname !== "/") { + return { ok: false, reason: "bad-host-url" }; + } + return { ok: true, url: `${parsed.protocol}//${parsed.host}` }; +} + +/** + * Accepts the grouped display form (`rxp_ ABCD 1234`) or the canonical raw code. + * Grouping is presentation only (R-12) — what comes back is always the raw code that + * `pair_remote_environment` receives. + */ +export function parseManualPairingCode(raw: string): ParsedPairingCode { + const compact = raw.replace(/\s+/g, ""); + if (compact === "") { + return { ok: false, reason: "missing-code" }; + } + if (!compact.startsWith(PAIRING_CODE_PREFIX)) { + return { ok: false, reason: "bad-code-prefix" }; + } + if (compact.length === PAIRING_CODE_PREFIX.length) { + return { ok: false, reason: "missing-code" }; + } + return { ok: true, code: compact }; +} + +/** + * Inverse of `buildPairingUrl`: `ralphx://pair?host=…#code=…`. + * + * The code is read from the HASH FRAGMENT ONLY. A code found in the query string is + * REJECTED rather than accepted — the query is exactly the part that reaches proxies + * and access logs, so a code that arrived there must be treated as burned and + * regenerated on the host, not quietly used. + * + * Never throws; every failure is a typed reason the form renders beside the field. + */ +export function parsePairingUrl(raw: string): ParsedPairingUrl { + const trimmed = raw.trim(); + if (!trimmed.toLowerCase().startsWith(PAIRING_URL_PREFIX)) { + return { ok: false, reason: "not-a-pairing-url" }; + } + + // `ralphx:` is not a special scheme, so `URL` leaves the authority in the pathname; + // splitting the query/fragment by hand keeps the parse independent of that quirk. + const withoutPrefix = trimmed.slice(PAIRING_URL_PREFIX.length); + const hashIndex = withoutPrefix.indexOf("#"); + const query = (hashIndex === -1 ? withoutPrefix : withoutPrefix.slice(0, hashIndex)) + .replace(/^\?/, ""); + const fragment = hashIndex === -1 ? "" : withoutPrefix.slice(hashIndex + 1); + + const queryParams = new URLSearchParams(query); + if (queryParams.has("code")) { + return { ok: false, reason: "code-in-query" }; + } + + const host = queryParams.get("host"); + if (host === null || host.trim() === "") { + return { ok: false, reason: "missing-host" }; + } + const normalizedHost = normalizePairingHostUrl(host); + if (!normalizedHost.ok) { + return normalizedHost; + } + + const rawCode = new URLSearchParams(fragment).get("code"); + if (rawCode === null || rawCode.trim() === "") { + return { ok: false, reason: "missing-code" }; + } + const code = parseManualPairingCode(rawCode); + if (!code.ok) { + return code; + } + + return { ok: true, host: normalizedHost.url, code: code.code }; +} + /** * Preferred pairing endpoint (R-12): first available advertised endpoint, else the * first advertised endpoint, else — for tailnet-direct only — the actual bound diff --git a/frontend/src/components/settings/usePaintBoundaryHydration.ts b/frontend/src/components/settings/usePaintBoundaryHydration.ts new file mode 100644 index 0000000000..646655eef0 --- /dev/null +++ b/frontend/src/components/settings/usePaintBoundaryHydration.ts @@ -0,0 +1,22 @@ +import { useEffect, useState } from "react"; + +import { + cancelScheduledJob, + scheduleAfterPaint, +} from "./SettingsDialog.performance"; + +/** + * Paint-boundary gate (rule 24): true only after a frame + macrotask have passed. + * + * Shared rather than copied. Two settings panes now depend on "shell first, fetch + * after"; a second private copy would let the two drift into different definitions of + * when the boundary is crossed, which is exactly the property the rule-24 tests assert. + */ +export function usePaintBoundaryHydration(): boolean { + const [hydrated, setHydrated] = useState(false); + useEffect(() => { + const job = scheduleAfterPaint(() => setHydrated(true)); + return () => cancelScheduledJob(job); + }, []); + return hydrated; +} diff --git a/frontend/src/lib/remote/env-scoped-storage.clear.test.ts b/frontend/src/lib/remote/env-scoped-storage.clear.test.ts new file mode 100644 index 0000000000..c0a150a7f4 --- /dev/null +++ b/frontend/src/lib/remote/env-scoped-storage.clear.test.ts @@ -0,0 +1,59 @@ +// PR 2.5 / P-27: unpairing must not leave the environment's UI state behind. + +import { beforeEach, describe, expect, it } from "vitest"; + +import { LOCAL_ENVIRONMENT_ID } from "./active-environment"; +import { clearEnvScopedStorage } from "./env-scoped-storage"; +import { STORE_ISOLATION_INVENTORY } from "./store-isolation-inventory"; + +const PERSISTED_NAMES = STORE_ISOLATION_INVENTORY.flatMap((entry) => + entry.persisted ? [entry.persisted.storageName] : [], +); + +describe("clearEnvScopedStorage", () => { + beforeEach(() => { + localStorage.clear(); + }); + + it("removes every env-scoped slice for the removed environment", () => { + for (const name of PERSISTED_NAMES) { + localStorage.setItem(name, JSON.stringify({ state: {}, version: 0 })); + localStorage.setItem(`${name}:env-a`, JSON.stringify({ state: {}, version: 0 })); + localStorage.setItem(`${name}:env-b`, JSON.stringify({ state: {}, version: 0 })); + } + + clearEnvScopedStorage("env-a"); + + for (const name of PERSISTED_NAMES) { + expect(localStorage.getItem(`${name}:env-a`)).toBeNull(); + // Another environment's state is not collateral damage. + expect(localStorage.getItem(`${name}:env-b`)).not.toBeNull(); + // Nor is the shared/local slice. + expect(localStorage.getItem(name)).not.toBeNull(); + } + }); + + it("refuses to clear the local environment", () => { + const [name] = PERSISTED_NAMES; + localStorage.setItem(name, JSON.stringify({ state: {}, version: 0 })); + + clearEnvScopedStorage(LOCAL_ENVIRONMENT_ID); + + // Local state is this Mac's own; a remote unpair has no claim on it. + expect(localStorage.getItem(name)).not.toBeNull(); + }); + + it("is a no-op for an environment that never persisted anything", () => { + expect(() => clearEnvScopedStorage("never-used")).not.toThrow(); + expect(localStorage.length).toBe(0); + }); + + it("is idempotent — a repeated staged removal clears the same keys twice safely", () => { + const [name] = PERSISTED_NAMES; + localStorage.setItem(`${name}:env-a`, JSON.stringify({ state: {}, version: 0 })); + + clearEnvScopedStorage("env-a"); + expect(() => clearEnvScopedStorage("env-a")).not.toThrow(); + expect(localStorage.getItem(`${name}:env-a`)).toBeNull(); + }); +}); diff --git a/frontend/src/lib/remote/env-scoped-storage.ts b/frontend/src/lib/remote/env-scoped-storage.ts index 50623885ed..d56e1b6e49 100644 --- a/frontend/src/lib/remote/env-scoped-storage.ts +++ b/frontend/src/lib/remote/env-scoped-storage.ts @@ -31,6 +31,30 @@ function pick(source: unknown, fields: readonly string[]): Record field in record).map((field) => [field, record[field]])); } +/** + * Drops every persisted slice belonging to one environment (P-27 staged removal). + * + * Called on the unpair seam. Without it, an unpaired environment's active project, + * ticket filters, terminal layout, and agent-session state survive in localStorage + * under `${storageName}:${environmentId}` forever — invisible, un-clearable from the + * UI, and ready to reappear if that host is ever paired again and lands on the same + * row id. + * + * Deliberately narrow: only the env-scoped keys of stores in the isolation inventory. + * The shared/global slice is left alone (it is this Mac's), the local environment is + * refused outright, and clearing is idempotent because staged removal can be retried + * by the reconciler. + */ +export function clearEnvScopedStorage(environmentId: string): void { + const storage = globalThis.localStorage; + if (!storage || environmentId === LOCAL_ENVIRONMENT_ID || environmentId === "") { + return; + } + for (const storageName of persistedSpecs.keys()) { + storage.removeItem(`${storageName}:${environmentId}`); + } +} + export function createEnvScopedStorage(storageName: string): PersistStorage { const spec = persistedSpecs.get(storageName); if (!spec) throw new Error(`Unknown env-scoped persisted store: ${storageName}`); diff --git a/frontend/src/lib/remote/environment-runtime.registry-load.test.ts b/frontend/src/lib/remote/environment-runtime.registry-load.test.ts new file mode 100644 index 0000000000..487e31bb8e --- /dev/null +++ b/frontend/src/lib/remote/environment-runtime.registry-load.test.ts @@ -0,0 +1,193 @@ +// PR 2.5: the runtime is the ONE place the registry is pulled from Rust. +// +// Before this, `loadEnvironments`/`hydrateActiveEnvironment` existed with zero +// production callers, so a paired environment was durable in SQLite and invisible in +// the app until something happened to call them. The composition root owns the load +// because it already owns "flag on ⇒ environments become live". + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { RemoteEnvironmentSummary } from "@/api/remote-environments"; +import { resetQueryClient } from "@/lib/queryClient"; +import { LOCAL_ENVIRONMENT_ID, useEnvironmentStore } from "@/stores/environmentStore"; +import { useUiStore } from "@/stores/uiStore"; + +const { listMock, getActiveMock, setActiveMock } = vi.hoisted(() => ({ + listMock: vi.fn(), + getActiveMock: vi.fn(), + setActiveMock: vi.fn(), +})); + +vi.mock("@/api/remote-environments", () => ({ + remoteEnvironmentsApi: { + list: listMock, + getActiveEnvironment: getActiveMock, + setActiveEnvironment: setActiveMock, + }, +})); + +vi.mock("./supervisor", async (importOriginal) => { + const actual = await importOriginal>(); + class FakeConnectionSupervisor { + start(): void {} + stop(): void {} + currentState(): string { + return "idle"; + } + streamLost(): void {} + authorityWithdrawn(): void {} + noteFrameActivity(): void {} + visibilityChanged(): void {} + networkChanged(): void {} + } + return { ...actual, ConnectionSupervisor: FakeConnectionSupervisor }; +}); + +vi.mock("./network-fetch", () => ({ networkFetch: vi.fn() })); +vi.mock("#tauri-core-primitive", () => ({ invoke: vi.fn(async () => undefined) })); + +function summary(id: string): RemoteEnvironmentSummary { + return { + id, + environmentId: `host-${id}`, + name: id, + baseUrl: `https://${id}.example.test`, + candidateUrls: [], + scopes: ["ui:read"], + protocolVersion: 1, + status: "active", + createdAt: "2026-07-28T00:00:00Z", + lastConnectedAt: null, + }; +} + +function setFlag(enabled: boolean): void { + const flags = useUiStore.getState().featureFlags; + useUiStore.setState({ featureFlags: { ...flags, remoteEnvironments: enabled } }); +} + +function resetStores(): void { + useEnvironmentStore.setState({ + activeEnvironmentId: LOCAL_ENVIRONMENT_ID, + environments: [{ id: LOCAL_ENVIRONMENT_ID, name: "This Mac", kind: "local" }], + connectionStates: { [LOCAL_ENVIRONMENT_ID]: "connected" }, + }); + setFlag(false); +} + +/** Lets the runtime's floating registry-load promise settle. */ +async function settle(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +let teardown: (() => void) | null = null; + +beforeEach(() => { + vi.clearAllMocks(); + listMock.mockResolvedValue([]); + getActiveMock.mockResolvedValue(LOCAL_ENVIRONMENT_ID); + setActiveMock.mockResolvedValue(null); + resetStores(); + resetQueryClient(); +}); + +afterEach(() => { + teardown?.(); + teardown = null; + resetStores(); + resetQueryClient(); +}); + +describe("environment runtime registry loading", () => { + it("loads registered environments into the store on init when the flag is on", async () => { + listMock.mockResolvedValue([summary("env-a")]); + setFlag(true); + const { initializeEnvironmentRuntime } = await import("./environment-runtime"); + + teardown = initializeEnvironmentRuntime(); + await settle(); + + expect(listMock).toHaveBeenCalledTimes(1); + expect(useEnvironmentStore.getState().environments.map((e) => e.id)).toEqual([ + LOCAL_ENVIRONMENT_ID, + "env-a", + ]); + }); + + it("adopts the Rust-side active environment after the list lands", async () => { + listMock.mockResolvedValue([summary("env-a")]); + getActiveMock.mockResolvedValue("env-a"); + setFlag(true); + const { initializeEnvironmentRuntime } = await import("./environment-runtime"); + + teardown = initializeEnvironmentRuntime(); + await settle(); + + // Rust is the authority; the mirror follows it rather than clamping to local. + expect(useEnvironmentStore.getState().activeEnvironmentId).toBe("env-a"); + expect(setActiveMock).not.toHaveBeenCalled(); + }); + + it("stays dark while the flag is off (P-11 / dark ship)", async () => { + setFlag(false); + const { initializeEnvironmentRuntime } = await import("./environment-runtime"); + + teardown = initializeEnvironmentRuntime(); + await settle(); + + expect(listMock).not.toHaveBeenCalled(); + expect(getActiveMock).not.toHaveBeenCalled(); + expect(useEnvironmentStore.getState().environments).toHaveLength(1); + }); + + it("loads the registry when the flag flips on later", async () => { + listMock.mockResolvedValue([summary("env-a")]); + const { initializeEnvironmentRuntime } = await import("./environment-runtime"); + + teardown = initializeEnvironmentRuntime(); + await settle(); + expect(listMock).not.toHaveBeenCalled(); + + setFlag(true); + await settle(); + + expect(listMock).toHaveBeenCalledTimes(1); + expect(useEnvironmentStore.getState().environments.map((e) => e.id)).toContain( + "env-a", + ); + }); + + it("leaves the store on local when the registry read fails, without throwing", async () => { + listMock.mockRejectedValue(new Error("db down")); + setFlag(true); + const { initializeEnvironmentRuntime } = await import("./environment-runtime"); + + teardown = initializeEnvironmentRuntime(); + await expect(settle()).resolves.toBeUndefined(); + + // Fail closed: an unreadable registry shows nothing, never a half-built list. + expect(useEnvironmentStore.getState().environments).toHaveLength(1); + expect(useEnvironmentStore.getState().activeEnvironmentId).toBe( + LOCAL_ENVIRONMENT_ID, + ); + }); + + it("does not re-list on an unrelated environment-store update", async () => { + listMock.mockResolvedValue([summary("env-a")]); + setFlag(true); + const { initializeEnvironmentRuntime } = await import("./environment-runtime"); + + teardown = initializeEnvironmentRuntime(); + await settle(); + expect(listMock).toHaveBeenCalledTimes(1); + + // The pane refreshing the list is the OTHER writer path; the runtime reacts to the + // resulting store change by reconciling supervisors, not by listing again. + useEnvironmentStore.getState().setEnvironments([summary("env-a"), summary("env-b")]); + await settle(); + + expect(listMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/lib/remote/environment-runtime.ts b/frontend/src/lib/remote/environment-runtime.ts index c1ca66707c..7a6bea4090 100644 --- a/frontend/src/lib/remote/environment-runtime.ts +++ b/frontend/src/lib/remote/environment-runtime.ts @@ -466,6 +466,32 @@ export function initializeEnvironmentRuntime(): () => void { : detachedBus(environmentId, fallbackLocalBus); }); + /** + * Pulls the durable registry from Rust into the store. + * + * This is the ONLY unprompted reader of the registry. `loadEnvironments` first, then + * `hydrateActiveEnvironment`: the active id Rust reports is meaningless until the + * list that can contain it is present, and hydrating first would see an unknown id + * and clamp the mirror to local — telling Rust to abandon an environment it is still + * authorizing. + * + * Failures are swallowed deliberately. A registry that cannot be read shows no remote + * environments at all, which is the fail-closed presentation; there is no retry here + * (A-5: the supervisor is the sole retry owner) and the user re-opens Connections. + */ + const loadRegistry = (): void => { + if (!enabled) { + return; + } + void (async () => { + const store = useEnvironmentStore.getState(); + await store.loadEnvironments(); + await store.hydrateActiveEnvironment(); + })().catch((error: unknown) => { + console.warn("[remote] registry load failed; no remote environments shown", error); + }); + }; + const unsubscribeUi = useUiStore.subscribe((state, previous) => { const next = state.featureFlags.remoteEnvironments; if (next === previous.featureFlags.remoteEnvironments) { @@ -478,6 +504,7 @@ export function initializeEnvironmentRuntime(): () => void { } reconcile(); activate(useEnvironmentStore.getState().activeEnvironmentId); + loadRegistry(); }); const unsubscribeEnvironment = useEnvironmentStore.subscribe((state, previous) => { @@ -514,6 +541,7 @@ export function initializeEnvironmentRuntime(): () => void { if (enabled) { reconcile(); activate(activeEnvironmentId); + loadRegistry(); } const teardown = (): void => { diff --git a/frontend/src/lib/remote/local-only-commands.ts b/frontend/src/lib/remote/local-only-commands.ts index 999ec9086f..afaf427334 100644 --- a/frontend/src/lib/remote/local-only-commands.ts +++ b/frontend/src/lib/remote/local-only-commands.ts @@ -61,6 +61,12 @@ export const LOCAL_ONLY_COMMANDS: readonly LocalOnlyCommand[] = [ }, // --- This client's environment registry (§6.1/§6.4). --- + { + command: "preview_remote_environment", + disposition: "run-locally", + reason: + "Probes a prospective host's descriptor from THIS client before pairing; a remote host cannot answer for a host this client has not paired with yet.", + }, { command: "pair_remote_environment", disposition: "run-locally", diff --git a/src-tauri/src/application/remote_environment_service.rs b/src-tauri/src/application/remote_environment_service.rs index f9f112f41b..246bc297b1 100644 --- a/src-tauri/src/application/remote_environment_service.rs +++ b/src-tauri/src/application/remote_environment_service.rs @@ -19,7 +19,9 @@ use std::sync::Arc; -use ralphx_remote_protocol::{ClientFrame, ErrorCode, Scope, PROTOCOL_VERSION}; +use ralphx_remote_protocol::{ + ClientFrame, EnvironmentDescriptor, ErrorCode, Scope, PROTOCOL_VERSION, +}; use tokio::sync::RwLock; use crate::application::remote_event_relay::{RemoteConnectOutcome, RemoteEventRelay}; @@ -205,6 +207,23 @@ const ALLOWED_FETCH_METHODS: &[&str] = &["GET", "HEAD", "POST", "PUT", "PATCH", /// the list is that they can never be added to it by accident. const ALLOWED_FETCH_HEADERS: &[&str] = &["content-type", "accept"]; +/// Read-only host identity shown before a pairing code is consumed (PR 2.5). +/// +/// Descriptor fields only. There is no host display name and no project count on the +/// wire descriptor, and the unauthenticated well-known endpoint must not grow one for +/// UX — the flow renders what the host actually asserts, nothing more. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteEnvironmentPreview { + pub environment_id: String, + pub app_version: String, + pub platform: String, + pub protocol_version: u32, + pub min_client_protocol: u32, + /// The existing row's name when this host identity is already registered, so the + /// flow can say "pairing again updates it" instead of implying a second entry. + pub already_paired_as: Option, +} + /// What the startup reconciler did, for logs and tests (row ids). #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct RemoteEnvironmentReconcileReport { @@ -264,22 +283,18 @@ impl RemoteEnvironmentService { // Pairing (staged add machine, §6.1 / P-27) // ------------------------------------------------------------------ - /// Pairs this client with a host: descriptor → pair exchange → row as - /// `pending_add` → Keychain write → flip `active`. + /// Validates the URL, fetches the host descriptor, and applies the version + /// contradiction gate — the prelude both `pair` and `preview` run. /// - /// The ordering is load-bearing. A crash after the row write leaves a - /// reconcilable `pending_add` husk; a crash after the Keychain write leaves a - /// `pending_add` row the reconciler re-validates and activates. There is no - /// ordering in which a valid bearer exists without a row referencing it. - pub async fn pair( + /// It exists so the gate has exactly ONE definition: a preview that said "compatible" + /// while `pair` refused (or the reverse) would be a UI that lies about the very thing + /// the step is for. Returns the NORMALIZED url because `pair` needs that exact string + /// for the exchange, the stored row, and the replaced-token revoke. + async fn pairing_descriptor( &self, url: &str, - code: &str, - name: &str, - ) -> Result { + ) -> Result<(String, EnvironmentDescriptor), RemoteEnvironmentError> { let url = validate_pairing_url(url)?; - - // 1. Descriptor: learn the host identity + protocol, abort on skew (§4.2). let descriptor = self .host_client .fetch_descriptor(&url) @@ -291,6 +306,54 @@ impl RemoteEnvironmentService { client: PROTOCOL_VERSION, }); } + Ok((url, descriptor)) + } + + /// Read-only identity probe for the add-environment flow (PR 2.5). + /// + /// Runs the same descriptor + skew prelude as `pair` and adds one repository read, + /// so the user can see WHO they are about to pair with before a single-use code is + /// consumed. Deliberately inert: no row write, no Keychain access, no token, no + /// active-environment change. Safe to call repeatedly. + pub async fn preview( + &self, + url: &str, + ) -> Result { + let (_url, descriptor) = self.pairing_descriptor(url).await?; + + // Dedup awareness only: the flow tells the user that pairing again UPDATES this + // row rather than adding a second one (the §6.1 upsert is keyed on this id). + let already_paired_as = self + .repo + .get_by_environment_id(&descriptor.environment_id) + .await? + .map(|existing| existing.name); + + Ok(RemoteEnvironmentPreview { + environment_id: descriptor.environment_id, + app_version: descriptor.app_version, + platform: descriptor.platform, + protocol_version: descriptor.protocol_version, + min_client_protocol: descriptor.min_client_protocol, + already_paired_as, + }) + } + + /// Pairs this client with a host: descriptor → pair exchange → row as + /// `pending_add` → Keychain write → flip `active`. + /// + /// The ordering is load-bearing. A crash after the row write leaves a + /// reconcilable `pending_add` husk; a crash after the Keychain write leaves a + /// `pending_add` row the reconciler re-validates and activates. There is no + /// ordering in which a valid bearer exists without a row referencing it. + pub async fn pair( + &self, + url: &str, + code: &str, + name: &str, + ) -> Result { + // 1. Descriptor: learn the host identity + protocol, abort on skew (§4.2). + let (url, descriptor) = self.pairing_descriptor(url).await?; // 2. Pair exchange (single-use code consumption is host-side). let response = self @@ -1181,6 +1244,21 @@ fn validate_pairing_url(url: &str) -> Result { "missing host".to_string(), )); } + // A query or fragment is REJECTED, never stripped. The accepted string becomes the + // row's `base_url`, which is the stem every derived URL is built from (the pairing + // exchange path, the WS ticket path, the descriptor path). Anything unshaped that + // survives here re-emerges glued after those paths as a URL nobody authored, so the + // shape is refused at the sink rather than trusted from the caller's provenance. + if parsed.query().is_some() { + return Err(RemoteEnvironmentError::InvalidUrl( + "pairing URL must not carry a query string".to_string(), + )); + } + if trimmed.contains('#') { + return Err(RemoteEnvironmentError::InvalidUrl( + "pairing URL must not carry a fragment".to_string(), + )); + } Ok(trimmed.trim_end_matches('/').to_string()) } diff --git a/src-tauri/src/application/remote_environment_service_tests.rs b/src-tauri/src/application/remote_environment_service_tests.rs index 3c038e6ff3..2811893955 100644 --- a/src-tauri/src/application/remote_environment_service_tests.rs +++ b/src-tauri/src/application/remote_environment_service_tests.rs @@ -2173,3 +2173,223 @@ async fn stream_send_uses_the_stream_guards_not_the_active_binding() { "background environments' stream control must not be active-env-gated" ); } + +// ------------------------------------------------------------------------------ +// preview_remote_environment (PR 2.5): the read-only pre-pair identity probe. +// +// The load-bearing property is ABSENCE. Preview runs before a single-use pairing code +// is consumed and may be re-run freely, so it must leave the registry, the Keychain, +// and the active-environment mirror exactly as it found them. +// ------------------------------------------------------------------------------ + +/// A secret store that counts every call, so "preview touched no secret" is proven by +/// call count rather than by the map happening to look unchanged. +struct CountingSecretStore { + inner: MemorySecretStore, + calls: Arc>, +} + +impl CountingSecretStore { + fn new() -> Self { + Self { + inner: MemorySecretStore::new(), + calls: Arc::new(StdMutex::new(0)), + } + } + + fn count(&self) -> usize { + *self.calls.lock().expect("counter") + } + + fn record(&self) { + *self.calls.lock().expect("counter") += 1; + } +} + +#[async_trait] +impl crate::domain::services::SecretStore for CountingSecretStore { + async fn put_secret(&self, key: &str, value: &str) -> Result<(), SecretStoreError> { + self.record(); + self.inner.put_secret(key, value).await + } + + async fn get_secret(&self, key: &str) -> Result, SecretStoreError> { + self.record(); + self.inner.get_secret(key).await + } + + async fn delete_secret(&self, key: &str) -> Result<(), SecretStoreError> { + self.record(); + self.inner.delete_secret(key).await + } +} + +#[tokio::test] +async fn preview_returns_descriptor_identity_without_touching_anything() { + let repo = Arc::new(MemoryRemoteEnvironmentRepository::new()); + let secrets = Arc::new(CountingSecretStore::new()); + let host = Arc::new(MockRemoteHostClient::new( + descriptor("env-1"), + pair_response("env-1"), + )); + let service = RemoteEnvironmentService::new( + Arc::clone(&repo) as Arc, + Arc::clone(&secrets) as Arc, + Arc::clone(&host) as Arc, + test_relay(), + ); + + let preview = service + .preview(HOST_URL) + .await + .expect("preview should succeed"); + + assert_eq!(preview.environment_id, "env-1"); + assert_eq!(preview.app_version, "0.81.0"); + assert_eq!(preview.platform, "macos"); + assert_eq!(preview.protocol_version, PROTOCOL_VERSION); + assert_eq!(preview.min_client_protocol, PROTOCOL_VERSION); + assert_eq!( + preview.already_paired_as, None, + "an unknown host is not reported as already paired" + ); + + // Absence assertions — the whole point of a preview. + assert!( + repo.list().await.expect("list").is_empty(), + "preview must not write a registry row" + ); + assert_eq!( + secrets.count(), + 0, + "preview must never reach the secret store (P-18)" + ); + assert_eq!( + service.active_environment_id().await, + LOCAL_ENVIRONMENT_ID, + "preview must not move the active-environment mirror" + ); + + // The only host call is the descriptor read: no pairing exchange was attempted. + let calls = host.recorded_calls(); + assert_eq!(calls.len(), 1); + assert!(matches!(calls[0], RecordedHostCall::Descriptor { .. })); +} + +#[tokio::test] +async fn preview_reports_the_existing_name_for_an_already_paired_host() { + let f = fixture(); + f.service + .pair(HOST_URL, "rxp_code", "Studio Mac") + .await + .expect("seed pairing should succeed"); + let before = f.repo.list().await.expect("list"); + + let preview = f + .service + .preview(HOST_URL) + .await + .expect("preview of a known host should succeed"); + + assert_eq!( + preview.already_paired_as, + Some("Studio Mac".to_string()), + "a known host identity surfaces its row name so the flow can say re-pairing UPDATES it" + ); + // Re-previewing a paired host must not disturb the row it just read. + assert_eq!(f.repo.list().await.expect("list"), before); +} + +#[tokio::test] +async fn preview_version_skew_returns_the_same_typed_error_pair_would() { + let f = fixture(); + { + let mut descriptor_slot = f.host.descriptor.lock().expect("mock"); + let mut skewed = descriptor("env-1"); + skewed.min_client_protocol = PROTOCOL_VERSION + 1; + *descriptor_slot = Ok(skewed); + } + + let error = f + .service + .preview(HOST_URL) + .await + .expect_err("skew must block the preview"); + assert!(matches!( + error, + RemoteEnvironmentError::VersionSkew { + host_min_client, + client, + } if host_min_client == PROTOCOL_VERSION + 1 && client == PROTOCOL_VERSION + )); + assert_eq!( + error.code(), + "REMOTE_VERSION_MISMATCH", + "the flow renders the service's taxonomy; it never re-derives version comparisons" + ); + assert!(f.repo.list().await.expect("list").is_empty()); +} + +#[tokio::test] +async fn preview_of_an_unreachable_host_is_typed_and_writes_nothing() { + let f = fixture_with_host(MockRemoteHostClient::unreachable()); + + let error = f + .service + .preview(HOST_URL) + .await + .expect_err("an unreachable host must surface as a typed error"); + assert!(matches!(error, RemoteEnvironmentError::Unreachable(_))); + assert_eq!(error.code(), "REMOTE_UNREACHABLE"); + assert!(f.repo.list().await.expect("list").is_empty()); +} + +#[tokio::test] +async fn preview_rejects_unshaped_urls_before_any_network_call() { + let f = fixture(); + + for url in [ + "file:///etc/passwd", + "https://host.ts.net:3849?redirect=evil", + "https://host.ts.net:3849#code=rxp_leak", + "not a url", + ] { + let error = f + .service + .preview(url) + .await + .expect_err("an unshaped URL must be rejected"); + assert!( + matches!(error, RemoteEnvironmentError::InvalidUrl(_)), + "{url:?} must be refused as a bad URL, got {error:?}" + ); + } + assert!( + f.host.recorded_calls().is_empty(), + "rejection happens before any network call" + ); +} + +#[tokio::test] +async fn pairing_urls_carrying_a_query_or_fragment_are_rejected_at_the_sink() { + // base_url is the stem every derived URL is built from; an unshaped one would + // reappear glued after the pairing/ticket/descriptor paths. + for url in [ + "https://host.ts.net:3849?redirect=evil", + "https://host.ts.net:3849/#code=rxp_leak", + "http://host.ts.net:3849?a=1#b", + ] { + assert!( + matches!( + validate_pairing_url(url), + Err(RemoteEnvironmentError::InvalidUrl(_)) + ), + "{url:?} must not become a base_url" + ); + } + // The canonical shapes still pass, including trailing-slash normalisation. + assert_eq!( + validate_pairing_url("https://host.ts.net:3849/").expect("canonical url"), + "https://host.ts.net:3849" + ); +} diff --git a/src-tauri/src/commands/registry.rs b/src-tauri/src/commands/registry.rs index 25073e08c9..d604fccd52 100644 --- a/src-tauri/src/commands/registry.rs +++ b/src-tauri/src/commands/registry.rs @@ -22,6 +22,7 @@ macro_rules! register_tauri_commands { #[cfg(debug_assertions)] commands::notification_commands::debug_send_test_notification, // remote environment registry (PR 2.1) + commands::remote_environment_commands::preview_remote_environment, commands::remote_environment_commands::pair_remote_environment, commands::remote_environment_commands::list_remote_environments, commands::remote_environment_commands::remove_remote_environment, diff --git a/src-tauri/src/commands/remote_environment_commands.rs b/src-tauri/src/commands/remote_environment_commands.rs index 63ef6a5cec..94854bfde9 100644 --- a/src-tauri/src/commands/remote_environment_commands.rs +++ b/src-tauri/src/commands/remote_environment_commands.rs @@ -13,8 +13,8 @@ use serde::{Deserialize, Serialize}; use tauri::State; use crate::application::remote_environment_service::{ - RemoteEnvironmentError, RemoteEnvironmentService, RemoteFetchCall, RemoteFetchOutcome, - RemoteInvokeOutcome, + RemoteEnvironmentError, RemoteEnvironmentPreview, RemoteEnvironmentService, RemoteFetchCall, + RemoteFetchOutcome, RemoteInvokeOutcome, }; use crate::application::remote_event_relay::RemoteConnectOutcome; use crate::domain::entities::remote_environment::{RemoteEnvironment, RemoteEnvironmentStatus}; @@ -56,6 +56,40 @@ impl From for RemoteEnvironmentSummary { } } +/// JS-facing projection of a pre-pair host identity probe (PR 2.5). +/// +/// Descriptor truth only, and no credential of any kind: this response is produced +/// before any pairing code is consumed, so there is nothing secret to omit (P-18). +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RemoteEnvironmentPreviewResponse { + pub environment_id: String, + pub app_version: String, + pub platform: String, + pub protocol_version: u32, + pub min_client_protocol: u32, + pub already_paired_as: Option, +} + +impl From for RemoteEnvironmentPreviewResponse { + fn from(preview: RemoteEnvironmentPreview) -> Self { + Self { + environment_id: preview.environment_id, + app_version: preview.app_version, + platform: preview.platform, + protocol_version: preview.protocol_version, + min_client_protocol: preview.min_client_protocol, + already_paired_as: preview.already_paired_as, + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PreviewRemoteEnvironmentInput { + pub url: String, +} + #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] pub struct PairRemoteEnvironmentInput { @@ -114,6 +148,23 @@ fn to_command_error(error: RemoteEnvironmentError) -> String { error.to_command_error() } +/// Read-only host identity probe for the add-environment flow (PR 2.5). +/// +/// Runs the same descriptor fetch and version-contradiction gate as +/// `pair_remote_environment`, so what the user is shown is what pairing will enforce. +/// Writes nothing: no row, no Keychain access, no active-environment change. +#[tauri::command] +pub async fn preview_remote_environment( + input: PreviewRemoteEnvironmentInput, + state: State<'_, AppState>, +) -> Result { + service(&state) + .preview(&input.url) + .await + .map(RemoteEnvironmentPreviewResponse::from) + .map_err(to_command_error) +} + /// Performs the pairing exchange in the Rust backend (§4.2): descriptor → /// pair → row (`pending_add`) → Keychain → `active`. The token goes straight /// to the Keychain and is absent from the response. From e2d9d7f99bcabf95a8ee91b43de78b0b62159b6a Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:21:22 +0300 Subject: [PATCH 152/416] fix(remote): make the capability-ledger CI floor enforce what it claims Six soundness defects let the authority-audit apparatus report green while proving nothing: - Nothing bound the enforced RemoteCommandSpec.class to the ledger's policy_for row, so an under-labelled registration was a scope escalation with green CI. every_registered_spec_matches_its_ledger_row binds them. - Detector-(b) writer_markers were the writer command names themselves, so 19 of 25 markers matched commands against their own name (every fn node carries its own name as a token) and any new writer was a silent false negative. Markers are now persistence-layer write sites, matched as (write site AND armed value); where no token discriminates, the writer is a reason-coded DeclaredWriter instead of an invented marker. - No SpawnsProcess floor existed - every sink was an agent-authority sink. Detector (c) floors process authority at the tool_paths resolvers and found a live mislabel: list_remote_advertised_endpoints was ledgered Read while resolving the Tailscale CLI. No WritesArbitraryPath counterpart ships: fs sinks are reachable from list_tasks and the brakes, so a gate there would fire on pure reads. Recorded as a stated soundness limit. - The content-writer strip assertion asserted Vec::remove arithmetic and held for any input; it now proves each row is load-bearing on the gated manifest. - is_content_read_tool silently dropped unknown worker-granted tools while coverage.agentConsumedContent still read complete. Classification is now fail-closed and surfaced 27 unclassified live grants, 5 of them real content reads. - The surface-to-loop tie accepted any of the ~98 inert loop roots; it now requires an authority-bearing one and a non-empty read site. - Authority-audit source loading skipped unreadable files and directories, the same silent graph shrinkage the parse-failure panic exists to prevent, and could be baked in via RALPHX_REGENERATE_REMOTE_MANIFEST. It now fails closed, with a minimum-node floor. --- .../src/remote_server/authority_audit.rs | 258 ++++++++- .../remote_server/authority_audit_tests.rs | 99 +++- .../src/remote_server/capability_ledger.rs | 9 +- .../remote_server/capability_ledger_tests.rs | 510 ++++++++++++++++-- 4 files changed, 795 insertions(+), 81 deletions(-) diff --git a/src-tauri/src/remote_server/authority_audit.rs b/src-tauri/src/remote_server/authority_audit.rs index 1a11200bf1..26f14aa5dd 100644 --- a/src-tauri/src/remote_server/authority_audit.rs +++ b/src-tauri/src/remote_server/authority_audit.rs @@ -89,6 +89,54 @@ pub const ARMING_TRANSITION_TARGETS: &[&str] = &[ pub const HALTING_TRANSITION_TARGETS: &[&str] = &["Paused", "Blocked", "Stopped", "Cancelled", "Archived"]; +/// Detector (c) — process-launch resolution sinks. +/// +/// Every production subprocess must resolve its binary through +/// `infrastructure/tool_paths.rs` (`.claude/rules/production-cli-resolution.md`), so reaching +/// one of these IS spawning a process. `Capability::SpawnsProcess` is permitted only under +/// `Elevated`, which makes "reaches a launch sink but is ledgered `Read`/`Operate`" a +/// mechanically detectable under-labelling — the exact shape the `list_projects` mislabel had. +pub const PROCESS_LAUNCH_SINKS: &[&str] = &[ + "resolve_gh_cli_path", + "resolve_git_cli_path", + "resolve_node_cli_path", + "resolve_shell_cli_path", + "resolve_rm_cli_path", + "resolve_ps_cli_path", + "resolve_lsof_cli_path", + "resolve_pgrep_cli_path", + "resolve_pkill_cli_path", + "resolve_taskkill_cli_path", + "resolve_tasklist_cli_path", + "find_claude_cli_path", + "claude_native_cli_path", + "find_claude_native_cli_path", + "find_codex_cli_path", + "find_codex_cli_candidates", + "find_tailscale_cli_path", + "find_launchable_cli_path", + "find_launchable_cli_path_without_shell", +]; + +// No `WritesArbitraryPath` counterpart exists, and that is a measured result rather than an +// omission: `fs::write`/`create_dir_all`/`remove_*` are reachable from the transitive closure of +// nearly every command, including `list_tasks` and the `pause_task`/`block_task`/`stop_task` +// brakes. A gate that fires on pure reads is not a floor, so path authority stays a hand-audited +// ledger judgement (`chat_attachment_commands` is `Denied` on exactly that basis) and is recorded +// here as a stated soundness limit. + +/// True when any closure token names one of `sinks`, exactly or as a path suffix +/// (`write_thing`, `std::fs::write` and `fs::write` all match the sink `fs::write`; a token +/// merely *containing* the text does not). +pub fn tokens_reach_any(tokens: &BTreeSet, sinks: &[&str]) -> bool { + tokens.iter().any(|token| { + sinks.iter().any(|sink| { + token == sink + || (token.ends_with(sink) && token[..token.len() - sink.len()].ends_with("::")) + }) + }) +} + fn all_cut_sinks() -> BTreeSet<&'static str> { TRANSITION_SINKS .iter() @@ -155,14 +203,75 @@ pub struct Closure { pub sink_hits: BTreeSet, } +/// A writer whose authority is real but which no source token can express mechanically. +/// +/// The R4-C3 honest form: an explicit, reason-coded declaration rather than a marker invented +/// to make a known command match. Adding one is a review event; adding a marker is not. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DeclaredWriter { + pub command: &'static str, + pub reason: &'static str, +} + /// Persisted state read by an authority-bearing background loop as a spawn/steer predicate. +/// +/// # Why markers are persistence-layer tokens and not command names +/// +/// A marker that IS the writer's own command name matches that command against itself: every +/// function node carries its own bare name as a token, so `writer_markers: &["inject_task"]` +/// flags `inject_task` no matter what its body does, and a *new* writer of the same surface is +/// a silent false negative. Markers are therefore drawn from the write site the command +/// reaches — repository/service method names, enum variant paths, and distinguishing string +/// literals — and [`super::capability_ledger_tests`] asserts mechanically that no marker is a +/// census command name. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct StateSurfaceEntry { pub id: &'static str, pub surface: &'static str, pub armed_value: &'static str, pub read_by_loops: &'static [&'static str], - pub writer_markers: &'static [&'static str], + /// Persistence-layer write sites for this surface. Never a registered command name. + pub write_markers: &'static [&'static str], + /// Tokens identifying the ARMED value. A command must reach BOTH a write site and the + /// armed value; a write site alone is an unrelated write and an armed value alone is a + /// read. Empty means the surface has no armed-value discriminator (any write arms it). + pub armed_markers: &'static [&'static str], + /// Writers not derivable from source tokens, each carrying a reason code. + pub declared_writers: &'static [DeclaredWriter], +} + +impl StateSurfaceEntry { + /// True when `command` writes this surface's armed value. + pub fn flags(&self, command: &str, tokens: &BTreeSet) -> bool { + if self + .declared_writers + .iter() + .any(|declared| declared.command == command) + { + return true; + } + let reaches_write_site = self + .write_markers + .iter() + .any(|marker| tokens.contains(*marker)); + let reaches_armed_value = self.armed_markers.is_empty() + || self + .armed_markers + .iter() + .any(|marker| tokens.contains(*marker)); + reaches_write_site && reaches_armed_value + } + + /// The markers that actually matched, for tests that must prove a command was not flagged + /// by its own name. + pub fn matched_markers(&self, tokens: &BTreeSet) -> BTreeSet<&'static str> { + self.write_markers + .iter() + .chain(self.armed_markers.iter()) + .filter(|marker| tokens.contains(**marker)) + .copied() + .collect() + } } /// Detector-(b)'s mechanically matched command writers. @@ -175,25 +284,95 @@ pub fn spawn_triggering_writers( .into_iter() .filter(|command| { let tokens = &graph.closure([command.clone()]).tokens; - surface.iter().any(|entry| { - entry - .writer_markers - .iter() - .any(|marker| tokens.contains(*marker)) - }) + surface.iter().any(|entry| entry.flags(command, tokens)) }) .collect() } /// Derived from the read sites reached by the settled authority-bearing loop inventory. +/// +/// Every `write_markers`/`armed_markers` token below is a persistence-layer identifier taken +/// from the write site itself — repository trait methods, enum variant paths, distinguishing +/// field idents and string literals — never the name of the command that reaches it. pub const SPAWN_TRIGGERING_STATE_SURFACE: &[StateSurfaceEntry] = &[ - StateSurfaceEntry { id: "ready-task", surface: "tasks.internal_status", armed_value: "Ready", read_by_loops: &["application/ready_task_scheduler.rs::application/ready_task_scheduler.rs:::::spawn_ready_task_scheduler_if_needed@57e1eb6d86c1770f"], writer_markers: &["inject_task", "restart_terminal_task_to_ready"] }, - StateSurfaceEntry { id: "pending-review-freshness", surface: "tasks.internal_status + task_status_history.entered_at + agent_runs.status", armed_value: "PendingReview with no fresh/running reviewer", read_by_loops: &["application/startup_background.rs::application/startup_background.rs:::::spawn_watchdog@8c28974ee8ca859d"], writer_markers: &["re_review_task_from_escalated", "request_task_changes_from_reviewing"] }, - StateSurfaceEntry { id: "automation-active", surface: "automations.status", armed_value: "Active", read_by_loops: &["application/startup_background.rs::application/startup_background.rs:::::spawn_automation_scheduler@c034c5fc2b8fe7b8"], writer_markers: &["resume_automation_smart", "finalize_automation"] }, - StateSurfaceEntry { id: "workspace-bridge", surface: "agent_conversation_workspaces.linked_ideation_session_id/status/mode", armed_value: "linked active plan/edit workspace", read_by_loops: &["application/startup_background.rs::application/startup_background.rs:::::spawn_agent_workspace_bridge_dispatcher@11ddd3248e57299b"], writer_markers: &["activate_agent_plan_direct_implementation", "activate_agent_task_pipeline", "close_agent_workspace_pr", "commit_agent_conversation_workspace_locally", "copy_agent_conversation_plan", "import_agent_conversation_plan", "publish_agent_conversation_workspace", "reconcile_agent_conversation_workspace_publication", "resume_deferred_git_startup", "set_agent_conversation_workspace_pr_supervision", "start_agent_conversation", "start_ralphx_work_from_ticket", "switch_agent_conversation_mode", "update_agent_conversation_workspace_from_base"] }, - StateSurfaceEntry { id: "external-event-cursor", surface: "external_events rows/cursor", armed_value: "unconsumed row", read_by_loops: &["application/startup_background.rs::application/startup_background.rs:::::spawn_agent_workspace_bridge_dispatcher@11ddd3248e57299b"], writer_markers: &["insert_event"] }, - StateSurfaceEntry { id: "workspace-auto-publish", surface: "agent_conversation_workspaces.auto_publish_enabled/publication_push_status", armed_value: "enabled and publishable/needs_agent", read_by_loops: &["commands/agent_workspace_auto_publish.rs::commands/agent_workspace_auto_publish.rs:::::start_agent_workspace_auto_publish_freshness_scan@3a8d62e625ea5914"], writer_markers: &["set_agent_conversation_workspace_auto_publish_for_state"] }, - StateSurfaceEntry { id: "workspace-auto-review", surface: "review_settings.require_workspace_review", armed_value: "true", read_by_loops: &["commands/agent_workspace_auto_review.rs::commands/agent_workspace_auto_review.rs:::::spawn_auto_review_for_workspace@a952be79d060c28f"], writer_markers: &["update_review_settings"] }, + StateSurfaceEntry { + id: "ready-task", + surface: "tasks.internal_status", + armed_value: "Ready", + read_by_loops: &["application/ready_task_scheduler.rs::application/ready_task_scheduler.rs:::::spawn_ready_task_scheduler_if_needed@57e1eb6d86c1770f"], + write_markers: &["restart_terminal_task_to_ready_with_history_for_action"], + armed_markers: &["InternalStatus::Ready"], + declared_writers: &[DeclaredWriter { + command: "inject_task", + reason: "seeds-ready-row-through-generic-repository-create: the row is born in Ready \ + via `TaskRepository::create`, whose write-site token is shared with 54 \ + unrelated creators, so no marker distinguishes this write mechanically", + }], + }, + StateSurfaceEntry { + id: "pending-review-freshness", + surface: "tasks.internal_status + task_status_history.entered_at + agent_runs.status", + armed_value: "PendingReview with no fresh/running reviewer", + read_by_loops: &["application/startup_background.rs::application/startup_background.rs:::::spawn_watchdog@8c28974ee8ca859d"], + write_markers: &["ensure_re_review_from_escalated_status", "add_note"], + armed_markers: &["InternalStatus::PendingReview", "InternalStatus::RevisionNeeded"], + declared_writers: &[], + }, + StateSurfaceEntry { + id: "automation-active", + surface: "automations.status", + armed_value: "Active", + read_by_loops: &["application/startup_background.rs::application/startup_background.rs:::::spawn_automation_scheduler@c034c5fc2b8fe7b8"], + write_markers: &["reopen_run_corrective"], + armed_markers: &["AutomationStatus::Active"], + declared_writers: &[DeclaredWriter { + command: "finalize_automation", + reason: "shared-automation-status-chokepoint: the finalize path reaches \ + `transition_automation_status` through the automation service, a token 53 \ + of 539 census commands reach transitively, so it cannot discriminate", + }], + }, + StateSurfaceEntry { + id: "workspace-bridge", + surface: "agent_conversation_workspaces.linked_ideation_session_id/status/mode", + armed_value: "linked active plan/edit workspace", + read_by_loops: &["application/startup_background.rs::application/startup_background.rs:::::spawn_agent_workspace_bridge_dispatcher@11ddd3248e57299b"], + write_markers: &["create_or_update", "update_links"], + armed_markers: &[ + "AgentConversationWorkspaceMode::Ideation", + "AgentConversationWorkspaceMode::Plan", + "AgentConversationWorkspaceMode::Edit", + "AgentConversationWorkspaceMode::Tasks", + ], + declared_writers: &[], + }, + StateSurfaceEntry { + id: "external-event-cursor", + surface: "external_events rows/cursor", + armed_value: "unconsumed row", + read_by_loops: &["application/startup_background.rs::application/startup_background.rs:::::spawn_agent_workspace_bridge_dispatcher@11ddd3248e57299b"], + write_markers: &["insert_event"], + armed_markers: &[], + declared_writers: &[], + }, + StateSurfaceEntry { + id: "workspace-auto-publish", + surface: "agent_conversation_workspaces.auto_publish_enabled/publication_push_status", + armed_value: "enabled and publishable/needs_agent", + read_by_loops: &["commands/agent_workspace_auto_publish.rs::commands/agent_workspace_auto_publish.rs:::::start_agent_workspace_auto_publish_freshness_scan@3a8d62e625ea5914"], + write_markers: &["update_auto_publish_preferences", "update_auto_publish_initial_pr_preference"], + armed_markers: &["auto_publish"], + declared_writers: &[], + }, + StateSurfaceEntry { + id: "workspace-auto-review", + surface: "review_settings.require_workspace_review", + armed_value: "true", + read_by_loops: &["commands/agent_workspace_auto_review.rs::commands/agent_workspace_auto_review.rs:::::spawn_auto_review_for_workspace@a952be79d060c28f"], + write_markers: &["update_settings"], + armed_markers: &["require_workspace_review"], + declared_writers: &[], + }, ]; impl CallGraph { @@ -926,24 +1105,57 @@ pub fn repo_root() -> PathBuf { .to_path_buf() } +/// Fail-closed floor on the size of the loaded production graph. +/// +/// The detectors are only a floor if the graph they run over is the whole program. A load that +/// silently returns a fraction of the tree would collapse every downstream classification to +/// "nothing is authority-bearing" and could be baked into the checked-in manifest through +/// `RALPHX_REGENERATE_REMOTE_MANIFEST`. The tree has ~1060 production files; anything under +/// this floor means the walk lost the tree, not that the tree shrank. +pub const MIN_PRODUCTION_SOURCE_FILES: usize = 900; + /// Loads every production `.rs` file under `src-tauri/src`. /// /// `*_tests.rs` files are excluded: test bodies would inject edges no production caller has. +/// +/// Every I/O failure is a hard error. An unreadable directory or file shrinks the call graph +/// exactly the way an unparseable file does, and the parse path already panics rather than +/// skip (see [`CallGraph::build`]); silent skipping here would have been the same +/// silent-graph-shrinkage with a quieter failure mode. pub fn load_production_sources() -> Vec<(String, String)> { let root = crate_src_root(); let mut files = Vec::new(); collect_rs_files(&root, &root, &mut files); files.sort_by(|a, b| a.0.cmp(&b.0)); + assert!( + files.len() >= MIN_PRODUCTION_SOURCE_FILES, + "authority audit loaded only {} production sources, below the {MIN_PRODUCTION_SOURCE_FILES} floor: the graph collapsed", + files.len() + ); files } -fn collect_rs_files(root: &Path, dir: &Path, out: &mut Vec<(String, String)>) { - let Ok(entries) = std::fs::read_dir(dir) else { - return; - }; - for entry in entries.flatten() { +/// Recursive `.rs` walk. Public so the fail-closed behaviour is testable against a fixture +/// tree; production callers go through [`load_production_sources`]. +pub fn collect_rs_files(root: &Path, dir: &Path, out: &mut Vec<(String, String)>) { + let entries = std::fs::read_dir(dir).unwrap_or_else(|error| { + panic!( + "authority audit could not read directory {}: {error}", + dir.display() + ) + }); + for entry in entries { + let entry = entry.unwrap_or_else(|error| { + panic!( + "authority audit could not read a directory entry under {}: {error}", + dir.display() + ) + }); let path = entry.path(); - if path.is_dir() { + let file_type = entry.file_type().unwrap_or_else(|error| { + panic!("authority audit could not stat {}: {error}", path.display()) + }); + if file_type.is_dir() { if matches!( path.file_name().and_then(|name| name.to_str()), Some("tests" | "testing") @@ -962,9 +1174,9 @@ fn collect_rs_files(root: &Path, dir: &Path, out: &mut Vec<(String, String)>) { { continue; } - let Ok(source) = std::fs::read_to_string(&path) else { - continue; - }; + let source = std::fs::read_to_string(&path).unwrap_or_else(|error| { + panic!("authority audit could not read {}: {error}", path.display()) + }); let relative = path .strip_prefix(root) .unwrap_or(&path) diff --git a/src-tauri/src/remote_server/authority_audit_tests.rs b/src-tauri/src/remote_server/authority_audit_tests.rs index 89f5082e8c..fcc0fb0e88 100644 --- a/src-tauri/src/remote_server/authority_audit_tests.rs +++ b/src-tauri/src/remote_server/authority_audit_tests.rs @@ -28,6 +28,15 @@ fn registered_command_parser_handles_layout_variants() { ); } +fn panic_message(panic: &(dyn std::any::Any + Send)) -> String { + panic + .downcast_ref::() + .map(String::as_str) + .or_else(|| panic.downcast_ref::<&str>().copied()) + .expect("panic has a string message") + .to_string() +} + #[test] fn registered_command_parser_panics_with_the_malformed_segment() { let source = r#" @@ -39,17 +48,97 @@ fn registered_command_parser_panics_with_the_malformed_segment() { let panic = std::panic::catch_unwind(|| parse_registered_commands(source)) .expect_err("malformed census segment must fail closed"); - let message = panic - .downcast_ref::() - .map(String::as_str) - .or_else(|| panic.downcast_ref::<&str>().copied()) - .expect("panic has a string message"); + let message = panic_message(panic.as_ref()); assert!( message.contains("commands::broken::bad()"), "panic must name the malformed segment: {message}" ); } +/// The graph is a floor only if it is the whole program. An unreadable directory used to be +/// skipped silently, which is the same silent-graph-shrinkage the parse-failure panic exists to +/// prevent — and it could be baked into the checked-in manifest by a regeneration run. +#[cfg(unix)] +#[test] +fn unreadable_source_directory_is_a_hard_error() { + use std::os::unix::fs::PermissionsExt; + + let fixture = tempfile::tempdir().expect("fixture root is creatable"); + let root = fixture.path().to_path_buf(); + std::fs::write(root.join("readable.rs"), "fn readable() {}\n").expect("seed readable source"); + let locked = root.join("locked"); + std::fs::create_dir(&locked).expect("fixture subdirectory is creatable"); + std::fs::write(locked.join("hidden.rs"), "fn hidden() {}\n").expect("seed hidden source"); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)) + .expect("directory permissions are settable"); + + let walk_root = root.clone(); + let outcome = std::panic::catch_unwind(|| { + let mut out = Vec::new(); + collect_rs_files(&walk_root, &walk_root, &mut out); + out + }); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o755)) + .expect("directory permissions are restorable"); + + let panic = outcome.expect_err("an unreadable directory must fail closed, not shrink silently"); + let message = panic_message(panic.as_ref()); + assert!( + message.contains("could not read directory"), + "panic must name the unreadable directory: {message}" + ); +} + +#[cfg(unix)] +#[test] +fn unreadable_source_file_is_a_hard_error() { + use std::os::unix::fs::PermissionsExt; + + let fixture = tempfile::tempdir().expect("fixture root is creatable"); + let root = fixture.path().to_path_buf(); + let locked = root.join("locked.rs"); + std::fs::write(&locked, "fn locked() {}\n").expect("seed locked source"); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o000)) + .expect("file permissions are settable"); + + let walk_root = root.clone(); + let outcome = std::panic::catch_unwind(|| { + let mut out = Vec::new(); + collect_rs_files(&walk_root, &walk_root, &mut out); + out + }); + std::fs::set_permissions(&locked, std::fs::Permissions::from_mode(0o644)) + .expect("file permissions are restorable"); + + let panic = outcome.expect_err("an unreadable source file must fail closed"); + let message = panic_message(panic.as_ref()); + assert!( + message.contains("could not read") && message.contains("locked.rs"), + "panic must name the unreadable file: {message}" + ); +} + +#[test] +fn production_source_load_reaches_the_whole_tree() { + let files = load_production_sources(); + assert!( + files.len() >= MIN_PRODUCTION_SOURCE_FILES, + "production source load fell below the collapse floor: {}", + files.len() + ); + // Recursion actually reached deep leaves, not just the crate-root files. + for expected in [ + "commands/registry.rs", + "remote_server/registry.rs", + "application/ready_task_scheduler.rs", + ] { + assert!( + files.iter().any(|(path, _)| path == expected), + "production source walk missed {expected}" + ); + } +} + #[test] fn method_spawn_and_listen_shapes_are_inventory_roots_with_body_authority() { let source = r#" diff --git a/src-tauri/src/remote_server/capability_ledger.rs b/src-tauri/src/remote_server/capability_ledger.rs index 68862682b0..c48559b720 100644 --- a/src-tauri/src/remote_server/capability_ledger.rs +++ b/src-tauri/src/remote_server/capability_ledger.rs @@ -515,12 +515,15 @@ pub const COMMAND_OVERRIDES: &[CommandOverride] = &[ command: "get_valid_transitions", policy: policy(RiskClass::Read, NONE, "state-machine metadata read"), }, + // Detector (c) finding: the advertised-endpoint listing resolves the Tailscale CLI, so it + // spawns a process. `SpawnsProcess` is expressible only under `Elevated`; the previous + // `Read` row was the same under-labelling shape as the `list_projects` mislabel. CommandOverride { command: "list_remote_advertised_endpoints", policy: policy( - RiskClass::Read, - NONE, - "remote endpoint read; AppHandle-ineligible until PR 3.1", + RiskClass::Elevated, + PROCESS, + "resolves the Tailscale CLI to enumerate advertised endpoints", ), }, CommandOverride { diff --git a/src-tauri/src/remote_server/capability_ledger_tests.rs b/src-tauri/src/remote_server/capability_ledger_tests.rs index c302525059..12fc150cad 100644 --- a/src-tauri/src/remote_server/capability_ledger_tests.rs +++ b/src-tauri/src/remote_server/capability_ledger_tests.rs @@ -4,13 +4,14 @@ use ralphx_remote_protocol::{class_permits, Capability, RiskClass}; use super::authority_audit::{ closure_is_arming, load_production_sources, parse_registered_commands, repo_root, - spawn_triggering_writers, CallGraph, StateSurfaceEntry, SPAWN_TRIGGERING_STATE_SURFACE, + spawn_triggering_writers, tokens_reach_any, CallGraph, StateSurfaceEntry, PROCESS_LAUNCH_SINKS, + SPAWN_TRIGGERING_STATE_SURFACE, }; use super::capability_ledger::{ policy_for, AUTHORITY_REDUCING_EXEMPTIONS, COMMAND_OVERRIDES, DECLARED_MEMBERSHIPS, MODULE_DEFAULTS, }; -use super::registry::find_spec; +use super::registry::{find_spec, REMOTE_COMMANDS}; fn registry_source() -> &'static str { include_str!("../commands/registry.rs") @@ -79,7 +80,7 @@ fn agent_content_reads() -> Vec { ); for tool in tools .intersection(&live) - .filter(|tool| is_content_read_tool(tool)) + .filter(|tool| classify_granted_tool(tool) == GrantedToolClass::ContentRead) { grants .entry(tool.clone()) @@ -99,29 +100,88 @@ fn agent_content_reads() -> Vec { .collect() } -fn is_content_read_tool(tool: &str) -> bool { - matches!( - tool, - "get_task_context" - | "get_review_notes" - | "get_task_issues" - | "get_artifact" - | "get_artifact_version" - | "get_related_artifacts" - | "get_task_steps" - | "get_step_context" - | "get_task_diff" - | "get_task_diff_stat" - | "get_agent_task" - | "list_agent_tasks" - | "get_sub_steps" - | "get_project_analysis" - | "get_task_validation_summary" - | "search_project_artifacts" - | "get_memory" - | "search_memories" - | "get_memories_for_paths" - ) +/// Worker-granted MCP tools that read content a remote writer could poison. +const CONTENT_READ_TOOLS: &[&str] = &[ + "get_task_context", + "get_review_notes", + "get_task_issues", + "get_artifact", + "get_artifact_version", + "get_related_artifacts", + "get_task_steps", + "get_step_context", + "get_task_diff", + "get_task_diff_stat", + "get_agent_task", + "list_agent_tasks", + "get_sub_steps", + "get_project_analysis", + "get_task_validation_summary", + "search_project_artifacts", + "get_memory", + "search_memories", + "get_memories_for_paths", + // Surfaced by the fail-closed classifier below: worker-granted live reads the old + // `matches!` allowlist silently dropped while `coverage.agentConsumedContent` still read + // "complete". + "get_step_progress", + "get_issue_progress", + "get_merge_target", + "list_ticket_attachments", + "fetch_ticket_attachment", +]; + +/// Worker-granted MCP tools deliberately OUTSIDE the content-read surface: each one WRITES or +/// steers rather than reading worker-consumed content. Explicit, because silence is what made +/// the old allowlist fail open. +const NON_CONTENT_TOOLS: &[&str] = &[ + "add_step", + "claim_agent_task", + "complete_agent_task", + "complete_merge", + "complete_review", + "complete_step", + "create_agent_task", + "create_followup_agent_conversation", + "delegate_cancel", + "delegate_start", + "delegate_wait", + "execution_complete", + "fail_step", + "mark_issue_addressed", + "mark_issue_in_progress", + "register_agent_issue", + "report_conflict", + "report_incomplete", + "run_task_validation", + "skip_step", + "start_step", + "update_agent_task", +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GrantedToolClass { + ContentRead, + NonContent, +} + +/// Fail-CLOSED classification (R5-H2). +/// +/// The previous `matches!` allowlist returned `false` for an unknown tool, so a newly granted +/// content read was silently dropped from the surface while `coverage.agentConsumedContent` +/// kept claiming "complete". An unclassifiable worker grant is now a hard failure: the surface +/// cannot claim completeness over a tool nobody has classified. +fn classify_granted_tool(tool: &str) -> GrantedToolClass { + if CONTENT_READ_TOOLS.contains(&tool) { + return GrantedToolClass::ContentRead; + } + if NON_CONTENT_TOOLS.contains(&tool) { + return GrantedToolClass::NonContent; + } + panic!( + "worker-granted MCP tool `{tool}` is unclassified; add it to CONTENT_READ_TOOLS or \ + NON_CONTENT_TOOLS before the agent-consumed-content surface can claim completeness" + ); } fn content_read_surface(tool: &str) -> &'static str { @@ -135,6 +195,10 @@ fn content_read_surface(tool: &str) -> &'static str { "memory_entries" } else if tool.contains("diff") { "task/worktree diff" + } else if tool.contains("ticket") { + "ticket attachments" + } else if tool.contains("merge") { + "merge target/branch state" } else if tool.contains("agent_task") { "agent_tasks" } else if tool.contains("validation") { @@ -290,13 +354,18 @@ fn generated_manifest() -> serde_json::Value { .iter() .map(|(command, reason)| serde_json::json!({ "command": command, "reason": reason })) .collect::>(); + // Only AUTHORITY-BEARING loop roots may anchor a surface. The inventory also contains ~98 + // inert roots; accepting those let a surface row inflate `agent_control_floor` on evidence + // that nothing arms an agent. let loop_ids = graph .loop_roots .iter() + .filter(|root| closure_is_arming(&graph.loop_closure(root))) .map(|root| root.id.as_str()) .collect::>(); let spawn_triggering_state_surface = SPAWN_TRIGGERING_STATE_SURFACE.iter().map(|entry| { - assert!(entry.read_by_loops.iter().all(|id| loop_ids.contains(id)), "surface {} references a non-inventory loop", entry.id); + assert!(!entry.read_by_loops.is_empty(), "surface {} names no read site", entry.id); + assert!(entry.read_by_loops.iter().all(|id| loop_ids.contains(id)), "surface {} references a loop that is not authority-bearing", entry.id); let writers = spawn_triggering_writers(&graph, rows.iter().map(|(command, _)| command.clone()), std::slice::from_ref(entry)); serde_json::json!({"id": entry.id, "surface": entry.surface, "armedValue": entry.armed_value, "readByLoops": entry.read_by_loops, "writers": writers}) }).collect::>(); @@ -618,7 +687,9 @@ fn synthetic_unregistered_authority_loop_requires_a_surface_tie_and_stales_manif surface: "synthetic.armed_state", armed_value: "true", read_by_loops, - writer_markers: &["write_synthetic_armed_state"], + write_markers: &["write_synthetic_armed_state"], + armed_markers: &[], + declared_writers: &[], }; let without_surface = spawn_triggering_writers( &graph, @@ -655,16 +726,61 @@ fn detector_b_surface_rows_cannot_evaporate() { .collect::>(); let complete = spawn_triggering_writers(&graph, commands.clone(), SPAWN_TRIGGERING_STATE_SURFACE); + let published: serde_json::Value = + serde_json::from_str(include_str!("../../../docs/generated/remote-commands.json")) + .expect("checked-in manifest parses"); + let published_surfaces = published["spawn_triggering_state_surface"] + .as_array() + .expect("published state surface is an array"); + let mut uniquely_load_bearing = 0usize; + for index in 0..SPAWN_TRIGGERING_STATE_SURFACE.len() { - let mut stripped = SPAWN_TRIGGERING_STATE_SURFACE.to_vec(); - let removed = stripped.remove(index); - let reduced = spawn_triggering_writers(&graph, commands.clone(), &stripped); + let entry = &SPAWN_TRIGGERING_STATE_SURFACE[index]; + let own = spawn_triggering_writers(&graph, commands.clone(), std::slice::from_ref(entry)); + // A surface whose markers stopped matching anything has silently evaporated even though + // the row is still present. assert!( - reduced.len() < complete.len(), - "removing state surface {} did not shrink its writer/floor set", - removed.id + !own.is_empty(), + "state surface {} attributes no writer at all", + entry.id + ); + // Its attribution is published and CI-compared, so deleting the row cannot be invisible. + let row = published_surfaces + .iter() + .find(|row| row["id"] == entry.id) + .unwrap_or_else(|| panic!("state surface {} is not in the manifest", entry.id)); + let published_writers = row["writers"] + .as_array() + .expect("published writers are an array") + .iter() + .filter_map(serde_json::Value::as_str) + .map(str::to_string) + .collect::>(); + assert_eq!( + published_writers, own, + "state surface {} drifted from its published writer attribution", + entry.id ); + + let mut stripped = SPAWN_TRIGGERING_STATE_SURFACE.to_vec(); + stripped.remove(index); + let reduced = spawn_triggering_writers(&graph, commands.clone(), &stripped); + // Write-site markers legitimately overlap between surfaces (one command can write two + // of them), so a shrinking global floor is required only where this surface is the sole + // attributor. Where it is, removal must be visible in the floor as well as the manifest. + if own.iter().any(|writer| !reduced.contains(writer)) { + uniquely_load_bearing += 1; + assert!( + reduced.len() < complete.len(), + "removing state surface {} lost a sole-attributed writer without shrinking the floor", + entry.id + ); + } } + assert!( + uniquely_load_bearing > 0, + "no state surface is the sole attributor of any writer; the floor rests entirely on overlap" + ); } #[test] @@ -714,11 +830,33 @@ fn agent_consumed_content_derivation_is_calibrated() { #[test] fn content_surface_rows_cannot_evaporate_and_reads_are_not_writers() { + // Mirrors `detector_b_surface_rows_cannot_evaporate`: each row must be load-bearing on the + // CI-gated artifact. The previous form asserted `Vec::remove` arithmetic, which holds for + // any input — deleting a content-writer row and regenerating passed every gate. + let published: serde_json::Value = + serde_json::from_str(include_str!("../../../docs/generated/remote-commands.json")) + .expect("checked-in manifest parses"); + let published_writers = published["agent_consumed_content_surface"]["writers"] + .as_array() + .expect("published content writers are an array"); let writers = agent_content_writers(); + assert_eq!( + published_writers, &writers, + "content-writer surface drifted from the checked-in manifest" + ); for index in 0..writers.len() { let mut stripped = writers.clone(); - stripped.remove(index); - assert_eq!(stripped.len() + 1, writers.len()); + let removed = stripped.remove(index); + assert_ne!( + &stripped, published_writers, + "removing content writer {removed} left the gated manifest surface unchanged" + ); + // Every remaining row must still be a row the manifest publishes; a strip must remove + // exactly one observable row, never silently re-derive it. + assert!( + !stripped.contains(&removed), + "content writer {removed} appears more than once" + ); } let names = writers .iter() @@ -883,25 +1021,297 @@ fn representative_capability_stripping_cannot_lower_membership() { } } +/// P-17 binding gate: the class the runtime ENFORCES must be the class the ledger FLOORS. +/// +/// `enforce_scope` reads `RemoteCommandSpec.class` — the value declared in `remote_commands!`. +/// The ledger/detector floor was a disconnected value nothing compared it against, so a +/// registration declaring `Read` for a command the ledger classifies `Elevated` shipped with +/// green CI. That is a scope escalation: the request is admitted on `ui:read`. #[test] -fn wry_monomorphic_remote_reads_are_ledgered_but_unregistered() { +fn every_registered_spec_matches_its_ledger_row() { + let modules = census().into_iter().collect::>(); + assert!( + !REMOTE_COMMANDS.is_empty(), + "the registered surface must not be empty or this gate is vacuous" + ); + for spec in REMOTE_COMMANDS { + let module = modules.get(spec.name).unwrap_or_else(|| { + panic!( + "registered command `{}` is not in the live census", + spec.name + ) + }); + let row = policy_for(spec.name, module) + .unwrap_or_else(|| panic!("registered command `{}` is not ledgered", spec.name)); + assert_eq!( + spec.class, row.class, + "`{}` is registered as {:?} but the ledger floors it at {:?}; runtime authorization \ + would admit it on the weaker scope", + spec.name, spec.class, row.class + ); + assert_eq!( + spec.capabilities, row.capabilities, + "`{}` declares capabilities {:?} but the ledger records {:?}", + spec.name, spec.capabilities, row.capabilities + ); + } +} + +/// R4-C1: a marker that is the writer's own command name matches that command against itself. +/// +/// Every function node carries its own bare name as a token, so such a marker flags its +/// command regardless of what the body does, and a NEW writer of the same surface is a silent +/// false negative. Markers must come from the write site, not the command list. +#[test] +fn state_surface_markers_are_never_census_command_names() { + let commands = census() + .into_iter() + .map(|(command, _)| command) + .collect::>(); + let mut marker_count = 0usize; + for entry in SPAWN_TRIGGERING_STATE_SURFACE { + assert!( + !entry.write_markers.is_empty(), + "surface {} has no write site", + entry.id + ); + for marker in entry.write_markers.iter().chain(entry.armed_markers.iter()) { + marker_count += 1; + assert!( + !commands.contains(*marker), + "surface {} marker `{marker}` is a census command name; the match is tautological", + entry.id + ); + } + for declared in entry.declared_writers { + assert!( + !declared.reason.is_empty(), + "surface {} declares writer {} without a reason code", + entry.id, + declared.command + ); + } + } + assert!( + marker_count >= SPAWN_TRIGGERING_STATE_SURFACE.len(), + "marker set collapsed" + ); +} + +/// P-17g follow-through: each proof-class writer must be flagged by a marker that is NOT its +/// own name — otherwise the proof-class assertions are self-fulfilling. +#[test] +fn proof_class_writers_are_flagged_by_write_site_markers() { + let graph = CallGraph::build(&load_production_sources()); for command in [ + "inject_task", + "finalize_automation", + "resume_automation", + "set_agent_conversation_workspace_auto_publish", + ] { + let tokens = graph.closure([command.to_string()]).tokens; + let flagging = SPAWN_TRIGGERING_STATE_SURFACE + .iter() + .filter(|entry| entry.flags(command, &tokens)) + .flat_map(|entry| entry.matched_markers(&tokens)) + .collect::>(); + assert!( + !flagging.is_empty(), + "proof-class writer {command} is flagged by no marker" + ); + assert!( + !flagging.contains(command), + "proof-class writer {command} is flagged by its own name" + ); + } +} + +/// Detector (c): a process launch reached from a command is authority the `Read`/`Operate` +/// classes cannot express — `class_permits` allows `SpawnsProcess` only under `Elevated`. +/// +/// Until this gate existed every audit sink was an AGENT-authority sink, so a `CommandOverride` +/// lowering a process-spawning getter to `Read` passed CI by construction. That is exactly the +/// shape the `list_projects`/`get_project` mislabel had, and running this gate for the first +/// time found a live one: `list_remote_advertised_endpoints` was ledgered `Read` while +/// resolving the Tailscale CLI. +#[test] +fn detector_c_floors_process_spawn_authority() { + let graph = CallGraph::build(&load_production_sources()); + let mut spawners = BTreeSet::new(); + + for (command, module) in census() { + let tokens = graph.closure([command.clone()]).tokens; + if !tokens_reach_any(&tokens, PROCESS_LAUNCH_SINKS) { + continue; + } + spawners.insert(command.clone()); + let row = policy_for(&command, &module).expect("census is ledgered"); + assert!( + !matches!(row.class, RiskClass::Read | RiskClass::Operate), + "detector (c): `{command}` resolves a CLI binary but is ledgered {:?}; \ + SpawnsProcess is only expressible under Elevated", + row.class + ); + assert!( + find_spec(&command).is_none(), + "detector (c): `{command}` carries process authority and must not be registered \ + on the remote facade in this PR" + ); + } + + // Calibration — the detector must actually fire, or the floor above is vacuous. + for command in [ + "list_projects", + "get_git_branches", + "get_task_file_changes", + "get_codex_cli_diagnostics", + ] { + assert!( + spawners.contains(command), + "detector (c) missed known process-spawning command {command}" + ); + } + for command in [ + "health_check", + "get_valid_transitions", + "list_tasks", + "get_task", + "search_tasks", + ] { + assert!( + !spawners.contains(command), + "detector (c) false-positive on registered read {command}" + ); + } +} + +#[test] +#[ignore = "calibration probe"] +fn probe_detector_calibration() { + let live = live_mcp_tool_names(); + for agent in WORKER_AGENTS { + let path = repo_root().join("agents").join(agent).join("agent.yaml"); + let tools = yaml_mcp_tools(&std::fs::read_to_string(path).unwrap()); + for tool in tools.intersection(&live) { + if !CONTENT_READ_TOOLS.contains(&tool.as_str()) + && !NON_CONTENT_TOOLS.contains(&tool.as_str()) + { + eprintln!("PROBE unclassified-tool {agent} {tool}"); + } + } + } + + let graph = CallGraph::build(&load_production_sources()); + for command in [ + "pause_task", + "block_task", + "stop_task", + "pause_tasks_in_group", + "deny_permission_request", + "list_tasks", + "get_task", + "search_tasks", + "health_check", + "get_valid_transitions", "list_remote_advertised_endpoints", "list_remote_audit_entries", ] { - let row = policy_for( - command, - if command.contains("advertised") { - "remote_host_commands" - } else { - "remote_device_commands" - }, - ) - .unwrap(); - assert_eq!(row.class, RiskClass::Read); - assert!( - find_spec(command).is_none(), - "AppHandle-only command must await PR 3.1" + let tokens = graph.closure([command.to_string()]).tokens; + for entry in SPAWN_TRIGGERING_STATE_SURFACE { + if entry.flags(command, &tokens) { + eprintln!( + "PROBE detb {command} <- {} via {:?}", + entry.id, + entry.matched_markers(&tokens) + ); + } + } + let proc = PROCESS_LAUNCH_SINKS + .iter() + .copied() + .filter(|sink| tokens_reach_any(&tokens, &[sink])) + .collect::>(); + eprintln!("PROBE detc {command} proc={proc:?}"); + } + + let commands = census() + .into_iter() + .map(|(command, _)| command) + .collect::>(); + let token_sets = commands + .iter() + .map(|command| (command.clone(), graph.closure([command.clone()]).tokens)) + .collect::>(); + for candidate in [ + "create", + "restart_terminal_task_to_ready_with_history_for_action", + "InternalStatus::Ready", + "ensure_re_review_from_escalated_status", + "add_note", + "InternalStatus::PendingReview", + "InternalStatus::RevisionNeeded", + "transition_automation_status", + "transition_automation_status_or_conflict", + "reopen_run_corrective", + "compare_and_swap_status", + "AutomationStatus::Active", + "create_or_update", + "update_links", + "restore_after_restart", + "update_status", + "update_pr_supervision_preferences", + "linked_ideation_session_id", + "AgentConversationWorkspaceMode::Ideation", + "AgentConversationWorkspaceMode::Plan", + "AgentConversationWorkspaceMode::Edit", + "AgentConversationWorkspaceMode::Tasks", + "insert_event", + "insert_event_once_for_attempt", + "update_auto_publish_preferences", + "update_auto_publish_initial_pr_preference", + "auto_publish", + "update_settings", + "require_workspace_review", + ] { + let hits = token_sets + .iter() + .filter(|(_, tokens)| tokens.contains(candidate)) + .map(|(command, _)| command.as_str()) + .collect::>(); + eprintln!( + "PROBE token {candidate} hits={} sample={:?}", + hits.len(), + hits.iter().take(6).collect::>() + ); + } + for entry in SPAWN_TRIGGERING_STATE_SURFACE { + let writers = + spawn_triggering_writers(&graph, commands.clone(), std::slice::from_ref(entry)); + eprintln!( + "PROBE surface {} writers={} {:?}", + entry.id, + writers.len(), + writers.iter().take(14).collect::>() ); } } + +#[test] +fn wry_monomorphic_remote_reads_are_ledgered_but_unregistered() { + let row = policy_for("list_remote_audit_entries", "remote_device_commands").unwrap(); + assert_eq!(row.class, RiskClass::Read); + assert!( + find_spec("list_remote_audit_entries").is_none(), + "AppHandle-only command must await PR 3.1" + ); + + // Not a read: detector (c) proved this one resolves the Tailscale CLI. + let advertised = + policy_for("list_remote_advertised_endpoints", "remote_host_commands").unwrap(); + assert_eq!(advertised.class, RiskClass::Elevated); + assert_eq!(advertised.capabilities, &[Capability::SpawnsProcess]); + assert!( + find_spec("list_remote_advertised_endpoints").is_none(), + "process-spawning endpoint listing must not be registered" + ); +} From 3f4d16d969a90c7c797a852e1cb921a0a221ea8b Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:21:26 +0300 Subject: [PATCH 153/416] chore(remote): regenerate the remote command manifest Reflects the recalibrated detector-(b) writer attribution, the new detector-(c) Elevated row for list_remote_advertised_endpoints, and the five worker-granted content reads the fail-closed tool classification surfaced. --- docs/generated/remote-commands.json | 69 ++++++++++++++++++++++++++--- 1 file changed, 64 insertions(+), 5 deletions(-) diff --git a/docs/generated/remote-commands.json b/docs/generated/remote-commands.json index f5b67504c4..b0bc325d98 100644 --- a/docs/generated/remote-commands.json +++ b/docs/generated/remote-commands.json @@ -1,6 +1,14 @@ { "agent_consumed_content_surface": { "reads": [ + { + "grantedTo": [ + "ralphx-execution-worker", + "ralphx-execution-coder" + ], + "reads": "ticket attachments", + "tool": "fetch_ticket_attachment" + }, { "grantedTo": [ "ralphx-execution-worker", @@ -27,6 +35,13 @@ "reads": "artifacts/artifact_versions/artifact_relations", "tool": "get_artifact_version" }, + { + "grantedTo": [ + "ralphx-execution-reviewer" + ], + "reads": "review_notes/task_issues", + "tool": "get_issue_progress" + }, { "grantedTo": [ "ralphx-execution-worker", @@ -47,6 +62,13 @@ "reads": "memory_entries", "tool": "get_memory" }, + { + "grantedTo": [ + "ralphx-execution-merger" + ], + "reads": "merge target/branch state", + "tool": "get_merge_target" + }, { "grantedTo": [ "ralphx-execution-worker", @@ -83,6 +105,15 @@ "reads": "task_steps", "tool": "get_step_context" }, + { + "grantedTo": [ + "ralphx-execution-worker", + "ralphx-execution-coder", + "ralphx-execution-reviewer" + ], + "reads": "task_steps", + "tool": "get_step_progress" + }, { "grantedTo": [ "ralphx-execution-worker" @@ -147,6 +178,14 @@ "reads": "agent_tasks", "tool": "list_agent_tasks" }, + { + "grantedTo": [ + "ralphx-execution-worker", + "ralphx-execution-coder" + ], + "reads": "ticket attachments", + "tool": "list_ticket_attachments" + }, { "grantedTo": [ "ralphx-execution-worker", @@ -253,6 +292,7 @@ ] }, "agent_control_floor": [ + "update_notification_settings", "remote_fetch", "complete_atlassian_oauth_local_callback", "exchange_atlassian_oauth_code", @@ -341,6 +381,7 @@ "set_agent_conversation_workspace_auto_publish", "set_agent_conversation_workspace_pr_supervision", "list_agent_conversation_workspaces_by_project", + "get_agent_conversation_workspace_freshness", "reconcile_agent_conversation_workspace_publication", "update_agent_conversation_workspace_from_base", "publish_agent_conversation_workspace", @@ -1703,11 +1744,13 @@ "registered": false }, { - "capabilities": [], - "class": "read", + "capabilities": [ + "spawnsProcess" + ], + "class": "elevated", "command": "list_remote_advertised_endpoints", "module": "remote_host_commands", - "reason": "remote endpoint read; AppHandle-ineligible until PR 3.1", + "reason": "resolves the Tailscale CLI to enumerate advertised endpoints", "registered": false }, { @@ -6798,7 +6841,12 @@ "surface": "tasks.internal_status + task_status_history.entered_at + agent_runs.status", "writers": [ "re_review_task_from_escalated", - "request_task_changes_from_reviewing" + "recover_task_execution", + "request_task_changes_for_review", + "request_task_changes_from_reviewing", + "resolve_recovery_prompt", + "resume_deferred_git_startup", + "retry_merge" ] }, { @@ -6810,7 +6858,8 @@ "surface": "automations.status", "writers": [ "finalize_automation", - "resume_automation" + "resume_automation", + "resume_automation_run" ] }, { @@ -6823,15 +6872,24 @@ "writers": [ "activate_agent_plan_direct_implementation", "activate_agent_task_pipeline", + "apply_proposals_to_kanban", "close_agent_workspace_pr", "commit_agent_conversation_workspace_locally", "copy_agent_conversation_plan", + "get_agent_conversation_workspace", + "get_agent_conversation_workspace_freshness", "import_agent_conversation_plan", + "list_agent_conversation_workspaces_by_project", + "list_agent_sidebar_conversations", "publish_agent_conversation_workspace", "reconcile_agent_conversation_workspace_publication", + "resolve_user_question", "resume_deferred_git_startup", + "send_agent_message", + "set_agent_conversation_workspace_auto_publish", "set_agent_conversation_workspace_pr_supervision", "start_agent_conversation", + "start_agent_task_pipeline", "start_ralphx_work_from_ticket", "start_research", "switch_agent_conversation_mode", @@ -6874,6 +6932,7 @@ ], "surface": "review_settings.require_workspace_review", "writers": [ + "update_notification_settings", "update_review_settings" ] } From 3392fd1be92bebbd10e4621e7c0c5cba45b899ea Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:23:22 +0300 Subject: [PATCH 154/416] feat: Connections settings pane and pairing journey (PR 2.5-b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Connections pane under External Access, beside Remote Access: the environment list, honest staged add/remove states, remove and re-pair actions, and the client pairing Playwright journey. Staged states are derived from `list_remote_environments` statuses and nothing else. The reconciler's report is in-memory with no durable carrier, so anything else would be inventing lifecycle the backend never asserted. Concretely: - `pending_delete` rows explain themselves and offer NO lifecycle action — the reconciler owns them — and a removed row does not vanish optimistically. - `pending_add` husks get "Re-pair to finish". - An `active` row reads `Paired`, not `Connected`: this pane knows the registry, not the socket. Removal clears that environment's env-scoped localStorage (P-27) only after Rust accepted the staged removal, so a refused removal keeps its view state. List errors and action errors are separate slots. Folded together, the re-list that follows a failed removal cleared the very error it was reporting — the failure disappeared before the user could read it. The flag gate is now a set of gated section ids rather than a second hardcoded id comparison, so the next dark-shipped section is one list entry. The journey runs (5/5) against a new client-registry web-mode mock that upserts on environmentId like the real registry, covering add, re-pair-updates-not- duplicates, version-contradiction blocking, staged removal, and flag-off absence. --- .../settings/SettingsDialog.performance.ts | 1 + .../settings/SettingsSectionContent.tsx | 7 + .../connections/ConnectionsSection.test.tsx | 395 ++++++++++++++++++ .../connections/ConnectionsSection.tsx | 384 +++++++++++++++++ .../settings/settings-registry.test.ts | 54 ++- .../components/settings/settings-registry.ts | 18 +- frontend/src/mocks/tauri-api-core.ts | 82 ++++ .../remote-environment-pairing.spec.ts | 186 +++++++++ 8 files changed, 1120 insertions(+), 7 deletions(-) create mode 100644 frontend/src/components/settings/connections/ConnectionsSection.test.tsx create mode 100644 frontend/src/components/settings/connections/ConnectionsSection.tsx create mode 100644 frontend/tests/integration/remote-environment-pairing.spec.ts diff --git a/frontend/src/components/settings/SettingsDialog.performance.ts b/frontend/src/components/settings/SettingsDialog.performance.ts index b5e1b0e563..669f70cba6 100644 --- a/frontend/src/components/settings/SettingsDialog.performance.ts +++ b/frontend/src/components/settings/SettingsDialog.performance.ts @@ -27,6 +27,7 @@ export const sectionModuleLoaders: Record Promise import("./ApiKeysSection"), "external-mcp": () => import("./ExternalMcpSettingsPanel"), "remote-access": () => import("./remote-access/RemoteAccessSection"), + connections: () => import("./connections/ConnectionsSection"), mcp: () => import("./McpSettingsSection"), updates: () => import("./UpdatesSettingsSection"), accessibility: () => import("./AccessibilitySection"), diff --git a/frontend/src/components/settings/SettingsSectionContent.tsx b/frontend/src/components/settings/SettingsSectionContent.tsx index 755c0fea7e..4cce2eb2cd 100644 --- a/frontend/src/components/settings/SettingsSectionContent.tsx +++ b/frontend/src/components/settings/SettingsSectionContent.tsx @@ -49,6 +49,12 @@ const LazyRemoteAccessSection = lazy(() => default: module.RemoteAccessSection, })), ); + +const LazyConnectionsSection = lazy(() => + import("./connections/ConnectionsSection").then((module) => ({ + default: module.ConnectionsSection, + })), +); const LazyAtlassianIntegrationSettingsPanel = lazy(() => import("./AtlassianIntegrationSettingsPanel").then((module) => ({ default: module.AtlassianIntegrationSettingsPanel, @@ -188,6 +194,7 @@ export function SettingsSectionContent({ {section === "api-keys" && } {section === "external-mcp" && } {section === "remote-access" && } + {section === "connections" && } {section === "mcp" && } {section === "updates" && } {section === "accessibility" && } diff --git a/frontend/src/components/settings/connections/ConnectionsSection.test.tsx b/frontend/src/components/settings/connections/ConnectionsSection.test.tsx new file mode 100644 index 0000000000..e206e55c3e --- /dev/null +++ b/frontend/src/components/settings/connections/ConnectionsSection.test.tsx @@ -0,0 +1,395 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + render as rtlRender, + screen, + waitFor, + within, +} from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import type { ReactElement } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { RemoteEnvironmentSummary } from "@/api/remote-environments"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { + LOCAL_ENVIRONMENT_ID, + useEnvironmentStore, +} from "@/stores/environmentStore"; + +import { ConnectionsSection } from "./ConnectionsSection"; + +const { listMock, removeMock, pairMock, previewMock, flagsMock } = vi.hoisted( + () => ({ + listMock: vi.fn(), + removeMock: vi.fn(), + pairMock: vi.fn(), + previewMock: vi.fn(), + flagsMock: vi.fn(), + }), +); + +vi.mock("@/api/remote-environments", () => ({ + remoteEnvironmentsApi: { + list: listMock, + remove: removeMock, + pair: pairMock, + preview: previewMock, + getActiveEnvironment: vi.fn(), + setActiveEnvironment: vi.fn(), + }, +})); + +vi.mock("@/hooks/useFeatureFlags", () => ({ + useFeatureFlags: () => flagsMock(), +})); + +function summary( + overrides: Partial = {}, +): RemoteEnvironmentSummary { + return { + id: "row-1", + environmentId: "host-1", + name: "Studio Mac", + baseUrl: "https://studio.tail-x.ts.net:3849", + candidateUrls: [], + scopes: ["ui:read"], + protocolVersion: 1, + status: "active", + createdAt: "2026-07-28T00:00:00Z", + lastConnectedAt: null, + ...overrides, + }; +} + +function render(ui: ReactElement) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return rtlRender( + + {ui} + , + ); +} + +function resetStore(): void { + useEnvironmentStore.setState({ + activeEnvironmentId: LOCAL_ENVIRONMENT_ID, + environments: [ + { id: LOCAL_ENVIRONMENT_ID, name: "This Mac", kind: "local" }, + ], + connectionStates: { [LOCAL_ENVIRONMENT_ID]: "connected" }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + flagsMock.mockReturnValue({ data: { remoteEnvironments: true } }); + listMock.mockResolvedValue([summary()]); + removeMock.mockResolvedValue(null); + localStorage.clear(); + resetStore(); +}); + +afterEach(() => { + resetStore(); + localStorage.clear(); +}); + +describe("ConnectionsSection — dark ship", () => { + it("renders nothing and fires no invoke while the flag is off", async () => { + flagsMock.mockReturnValue({ data: { remoteEnvironments: false } }); + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + await Promise.resolve(); + expect(listMock).not.toHaveBeenCalled(); + }); +}); + +describe("ConnectionsSection — first paint (rule 24)", () => { + it("paints header and skeleton before the list request is dispatched", () => { + render(); + + // The shell is on screen in the mounting commit; the fetch waits for the boundary. + expect(screen.getByTestId("connections-section")).toBeInTheDocument(); + expect(screen.getByText("Connections")).toBeInTheDocument(); + expect(listMock).not.toHaveBeenCalled(); + }); + + it("fetches the list only after the paint boundary", async () => { + render(); + await waitFor(() => expect(listMock).toHaveBeenCalledTimes(1)); + }); +}); + +describe("ConnectionsSection — rows", () => { + it("shows an active row as Paired, not as a live connection", async () => { + render(); + await screen.findByTestId("connections-row-row-1"); + + // This pane knows the registry, not the socket. + expect(screen.getByTestId("connections-status-row-1")).toHaveTextContent( + "Paired", + ); + expect(screen.queryByText(/connected/i)).not.toBeInTheDocument(); + }); + + it("explains a pending_delete row and offers NO actions", async () => { + listMock.mockResolvedValue([summary({ status: "pending_delete" })]); + render(); + const row = await screen.findByTestId("connections-row-row-1"); + + expect(screen.getByTestId("connections-status-row-1")).toHaveTextContent( + "Removing…", + ); + expect( + screen.getByTestId("connections-explanation-row-1"), + ).toHaveTextContent(/finish automatically/i); + // The reconciler owns this row; offering a lifecycle action would invite double + // work. (The address copy button stays — reading is not acting.) + expect( + within(row).queryByTestId("connections-remove-row-1"), + ).not.toBeInTheDocument(); + expect( + within(row).queryByTestId("connections-repair-row-1"), + ).not.toBeInTheDocument(); + }); + + it("offers a finish CTA on a pending_add husk", async () => { + listMock.mockResolvedValue([summary({ status: "pending_add" })]); + render(); + await screen.findByTestId("connections-row-row-1"); + + expect(screen.getByTestId("connections-status-row-1")).toHaveTextContent( + "Finishing setup…", + ); + expect(screen.getByTestId("connections-repair-row-1")).toHaveTextContent( + "Re-pair to finish", + ); + expect(screen.getByTestId("connections-remove-row-1")).toBeInTheDocument(); + }); + + it("renders the empty state with its own add affordance", async () => { + listMock.mockResolvedValue([]); + render(); + + expect(await screen.findByTestId("connections-empty")).toHaveTextContent( + /No remote environments yet/i, + ); + }); + + it("always shows the unremovable local environment", async () => { + render(); + expect(await screen.findByTestId("connections-local")).toHaveTextContent( + /always available/i, + ); + }); + + it("gives the icon-only remove button an accessible name (rule 23)", async () => { + render(); + await screen.findByTestId("connections-row-row-1"); + + expect(screen.getByTestId("connections-remove-row-1")).toHaveAccessibleName( + "Remove Studio Mac", + ); + }); +}); + +describe("ConnectionsSection — removal", () => { + it("confirms first, then stages the removal without vanishing the row", async () => { + listMock + .mockResolvedValueOnce([summary()]) + .mockResolvedValue([summary({ status: "pending_delete" })]); + const user = userEvent.setup(); + render(); + await screen.findByTestId("connections-row-row-1"); + + await user.click(screen.getByTestId("connections-remove-row-1")); + expect( + await screen.findByTestId("connections-remove-confirm"), + ).toBeInTheDocument(); + expect(removeMock).not.toHaveBeenCalled(); + + await user.click(screen.getByTestId("connections-remove-confirm-action")); + + await waitFor(() => + expect(screen.getByTestId("connections-status-row-1")).toHaveTextContent( + "Removing…", + ), + ); + // Still present: removal is staged, and the row leaves when Rust says it has. + expect(screen.getByTestId("connections-row-row-1")).toBeInTheDocument(); + }); + + it("cancelling the confirm removes nothing", async () => { + const user = userEvent.setup(); + render(); + await screen.findByTestId("connections-row-row-1"); + + await user.click(screen.getByTestId("connections-remove-row-1")); + await user.click(await screen.findByTestId("connections-remove-cancel")); + + expect(removeMock).not.toHaveBeenCalled(); + }); + + it("clears the environment's scoped view state once removal is accepted (P-27)", async () => { + localStorage.setItem( + "ralphx-project-store:row-1", + JSON.stringify({ state: {} }), + ); + localStorage.setItem( + "ralphx-project-store:row-2", + JSON.stringify({ state: {} }), + ); + const user = userEvent.setup(); + render(); + await screen.findByTestId("connections-row-row-1"); + + await user.click(screen.getByTestId("connections-remove-row-1")); + await user.click( + await screen.findByTestId("connections-remove-confirm-action"), + ); + + await waitFor(() => + expect(localStorage.getItem("ralphx-project-store:row-1")).toBeNull(), + ); + // Another environment's slice is untouched. + expect(localStorage.getItem("ralphx-project-store:row-2")).not.toBeNull(); + }); + + it("keeps the scoped state when the backend refused the removal", async () => { + localStorage.setItem( + "ralphx-project-store:row-1", + JSON.stringify({ state: {} }), + ); + removeMock.mockRejectedValue(new Error("DATABASE_ERROR: boom")); + const user = userEvent.setup(); + render(); + await screen.findByTestId("connections-row-row-1"); + + await user.click(screen.getByTestId("connections-remove-row-1")); + await user.click( + await screen.findByTestId("connections-remove-confirm-action"), + ); + + await screen.findByTestId("connections-error"); + // Nothing was removed, so nothing local is discarded either. + expect(localStorage.getItem("ralphx-project-store:row-1")).not.toBeNull(); + }); + + it("surfaces a mid-remove failure while re-listing the real staged state", async () => { + // Rust marked pending_delete, then the host revoke leg failed and surfaced. + listMock + .mockResolvedValueOnce([summary()]) + .mockResolvedValue([summary({ status: "pending_delete" })]); + removeMock.mockRejectedValue( + new Error("REMOTE_UNREACHABLE: host unreachable"), + ); + const user = userEvent.setup(); + render(); + await screen.findByTestId("connections-row-row-1"); + + await user.click(screen.getByTestId("connections-remove-row-1")); + await user.click( + await screen.findByTestId("connections-remove-confirm-action"), + ); + + expect(await screen.findByTestId("connections-error")).toHaveTextContent( + /unreachable/i, + ); + // Never a silent disappearance and never a fake success. + await waitFor(() => + expect(screen.getByTestId("connections-status-row-1")).toHaveTextContent( + "Removing…", + ), + ); + }); +}); + +describe("ConnectionsSection — list failures", () => { + it("surfaces a failed initial read instead of an empty state", async () => { + listMock.mockRejectedValue(new Error("DATABASE_ERROR: boom")); + render(); + + expect(await screen.findByTestId("connections-error")).toBeInTheDocument(); + // "No environments" is a different, much scarier claim than "could not read". + expect(screen.queryByTestId("connections-empty")).not.toBeInTheDocument(); + }); + + it("preserves an already-loaded list when a later refresh fails", async () => { + listMock + .mockResolvedValueOnce([summary()]) + .mockRejectedValue(new Error("DATABASE_ERROR: later")); + const user = userEvent.setup(); + render(); + await screen.findByTestId("connections-row-row-1"); + + await user.click(screen.getByTestId("connections-remove-row-1")); + await user.click( + await screen.findByTestId("connections-remove-confirm-action"), + ); + await screen.findByTestId("connections-error"); + + expect(screen.getByTestId("connections-row-row-1")).toBeInTheDocument(); + }); +}); + +describe("ConnectionsSection — add and re-pair", () => { + it("opens the wizard shell synchronously, fetching nothing", async () => { + const user = userEvent.setup(); + render(); + await screen.findByTestId("connections-row-row-1"); + listMock.mockClear(); + + await user.click(screen.getByTestId("connections-add")); + + expect(screen.getByTestId("add-environment-dialog")).toBeInTheDocument(); + expect(previewMock).not.toHaveBeenCalled(); + expect(listMock).not.toHaveBeenCalled(); + }); + + it("prefills and locks the host when re-pairing an existing row", async () => { + const user = userEvent.setup(); + render(); + await screen.findByTestId("connections-row-row-1"); + + await user.click(screen.getByTestId("connections-repair-row-1")); + + const host = screen.getByTestId("add-environment-host"); + expect(host).toHaveValue("https://studio.tail-x.ts.net:3849"); + expect(host).toBeDisabled(); + // The name field belongs to the verify step; step 1 has not been submitted yet. + expect( + screen.queryByTestId("add-environment-name"), + ).not.toBeInTheDocument(); + }); + + it("re-pairing a known host updates the row rather than adding a second", async () => { + previewMock.mockResolvedValue({ + environmentId: "host-1", + appVersion: "0.9.4", + platform: "macOS", + protocolVersion: 1, + minClientProtocol: 1, + alreadyPairedAs: "Studio Mac", + }); + pairMock.mockResolvedValue(summary({ name: "Studio Mac" })); + listMock.mockResolvedValue([summary({ name: "Studio Mac" })]); + const user = userEvent.setup(); + render(); + await screen.findByTestId("connections-row-row-1"); + + await user.click(screen.getByTestId("connections-repair-row-1")); + await user.type(screen.getByTestId("add-environment-code"), "rxp_ABCD1234"); + await user.click(screen.getByTestId("add-environment-continue")); + await screen.findByTestId("add-environment-step-verify"); + await user.click(screen.getByTestId("add-environment-pair")); + await screen.findByTestId("add-environment-success"); + await user.click(screen.getByTestId("add-environment-done")); + + await waitFor(() => + expect(screen.getAllByTestId(/^connections-row-/)).toHaveLength(1), + ); + }); +}); diff --git a/frontend/src/components/settings/connections/ConnectionsSection.tsx b/frontend/src/components/settings/connections/ConnectionsSection.tsx new file mode 100644 index 0000000000..f5e7a28b6b --- /dev/null +++ b/frontend/src/components/settings/connections/ConnectionsSection.tsx @@ -0,0 +1,384 @@ +// PR 2.5-b — the Connections settings pane. +// +// Every staged state shown here comes from `list_remote_environments` statuses and +// nothing else. The reconciler's report is in-memory with no durable carrier, so +// inferring "removing" or "finishing setup" from anything local would be inventing +// lifecycle the backend never asserted. A row leaves this list when Rust says it has, +// never optimistically. + +import { useCallback, useEffect, useState } from "react"; +import { Loader2, Trash2 } from "lucide-react"; + +import { + remoteEnvironmentsApi, + type RemoteEnvironmentSummary, +} from "@/api/remote-environments"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { Card } from "@/components/ui/card"; +import { CopyableRef } from "@/components/ui/copyable-ref"; +import { NoticeBanner } from "@/components/ui/notice-banner"; +import { StatusPill } from "@/components/ui/status-pill"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import { useFeatureFlags } from "@/hooks/useFeatureFlags"; +import { clearEnvScopedStorage } from "@/lib/remote/env-scoped-storage"; +import { useEnvironmentStore } from "@/stores/environmentStore"; + +import { + RemoteAccessCardHeader, + RemoteAccessSkeletonRows, +} from "../remote-access/RemoteAccessSection"; +import { usePaintBoundaryHydration } from "../usePaintBoundaryHydration"; +import { AddEnvironmentDialog } from "./AddEnvironmentDialog"; + +function errorMessage(error: unknown, fallback: string): string { + if (error instanceof Error) { + return error.message; + } + return typeof error === "string" && error.length > 0 ? error : fallback; +} + +interface RowPresentation { + label: string; + tone: "neutral" | "success" | "warning" | "accent"; + explanation: string | null; +} + +/** + * Status → presentation. `active` shows `Paired`, deliberately NOT a live connection + * dot: this pane knows the registry, not the socket. Painting a green "Connected" from + * a row status would assert liveness nothing here has observed. + */ +function presentRow(environment: RemoteEnvironmentSummary): RowPresentation { + switch (environment.status) { + case "active": + return { label: "Paired", tone: "success", explanation: null }; + case "pending_delete": + return { + label: "Removing…", + tone: "warning", + explanation: + "Removal in progress — host revoke and credential cleanup finish automatically (retried at next launch). No action needed.", + }; + case "pending_add": + return { + label: "Finishing setup…", + tone: "warning", + explanation: "Pairing was interrupted.", + }; + } +} + +export function ConnectionsSection() { + const { data: flags } = useFeatureFlags(); + // Inert while the flag is off (§8 flags note): no shell, no invokes, no listeners. + if (!flags.remoteEnvironments) { + return null; + } + return ; +} + +function ConnectionsPanel() { + const hydrated = usePaintBoundaryHydration(); + const [environments, setEnvironments] = useState< + RemoteEnvironmentSummary[] | null + >(null); + // Two slots, not one. A failed list and a failed removal are different facts, and + // folding them together let the re-list that FOLLOWS a failed removal clear the very + // error it was reporting — the failure vanished before the user could read it. + const [listError, setListError] = useState(null); + const [actionError, setActionError] = useState(null); + const [removing, setRemoving] = useState( + null, + ); + const [busyRowId, setBusyRowId] = useState(null); + const [addOpen, setAddOpen] = useState(false); + const [rePairTarget, setRePairTarget] = + useState(null); + + const refresh = useCallback(async () => { + try { + const rows = await remoteEnvironmentsApi.list(); + setEnvironments(rows); + setListError(null); + } catch (caught) { + // The previously loaded list is preserved: a failed refresh must not look like + // "you have no environments", which is a different and much scarier claim. + setListError( + errorMessage(caught, "Could not read the environment registry."), + ); + } + }, []); + + useEffect(() => { + if (!hydrated) { + return; + } + void refresh(); + }, [hydrated, refresh]); + + const handleRemove = useCallback( + async (environment: RemoteEnvironmentSummary) => { + setBusyRowId(environment.id); + setActionError(null); + try { + await remoteEnvironmentsApi.remove(environment.id); + // P-27: the row's env-scoped UI state goes with it. Only after Rust accepted + // the staged removal — clearing first would discard state for an environment + // that might still be there. + clearEnvScopedStorage(environment.id); + await useEnvironmentStore.getState().loadEnvironments(); + } catch (caught) { + setActionError( + errorMessage(caught, `Could not remove “${environment.name}”.`), + ); + } finally { + setBusyRowId(null); + // Always re-list: the staged machine may have advanced to pending_delete even + // when the call surfaced an error, and the user must see the real state. + await refresh(); + } + }, + [refresh], + ); + + const openRePair = useCallback((environment: RemoteEnvironmentSummary) => { + setRePairTarget(environment); + setAddOpen(true); + }, []); + + const openAdd = useCallback(() => { + setRePairTarget(null); + setAddOpen(true); + }, []); + + return ( +
+ + +
+
+ +
+ + {(actionError ?? listError) !== null && ( + + {actionError ?? listError} + + )} + + {environments === null ? ( + + ) : environments.length === 0 ? ( +
+

+ No remote environments yet. +

+

+ Pair this Mac with a host running Remote Access. +

+ +
+ ) : ( +
    + {environments.map((environment) => { + const presentation = presentRow(environment); + return ( +
  • +
    +
    +

    + {environment.name} +

    + +
    + +
    + + {presentation.explanation !== null && ( +

    + {presentation.explanation} +

    + )} + + {/* pending_delete gets NO actions: the reconciler owns that row. */} + {environment.status !== "pending_delete" && ( +
    + + + + + + Remove environment + +
    + )} +
  • + ); + })} +
+ )} + +
+

+ This Mac +

+

+ Local environment — always available. +

+
+
+
+ + { + void refresh(); + }} + /> + + { + if (!open) { + setRemoving(null); + } + }} + > + + + + Remove {removing?.name ?? "this environment"}? + + + This Mac's credential is revoked on the host and deleted from the + Keychain, and this environment's local view state is cleared. + Removal finishes in the background and resumes at next launch if + interrupted. Pair again to restore access. + + + + + Cancel + + { + if (removing !== null) { + void handleRemove(removing); + } + setRemoving(null); + }} + className="bg-[var(--status-error)] text-white hover:bg-[var(--status-error)]" + > + Remove environment + + + + +
+ ); +} diff --git a/frontend/src/components/settings/settings-registry.test.ts b/frontend/src/components/settings/settings-registry.test.ts index c7f34d894a..d2dadf53bb 100644 --- a/frontend/src/components/settings/settings-registry.test.ts +++ b/frontend/src/components/settings/settings-registry.test.ts @@ -23,10 +23,16 @@ describe("visibleSettingsSections", () => { expect(sections).toHaveLength(SETTINGS_SECTIONS.length); }); - it("never filters any other section", () => { - const gated = visibleSettingsSections({}); - expect(gated).toHaveLength(SETTINGS_SECTIONS.length - 1); - expect(gated.every((section) => section.id !== "remote-access")).toBe(true); + it("never filters any section other than the flag-gated ones", () => { + // Stated as a set difference rather than a hardcoded count, so adding a gated + // section updates one list instead of silently failing an arithmetic assertion. + const gatedIds = new Set(["remote-access", "connections"]); + const visible = visibleSettingsSections({}); + expect(visible.map((section) => section.id)).toEqual( + SETTINGS_SECTIONS.filter((section) => !gatedIds.has(section.id)).map( + (section) => section.id, + ), + ); }); }); @@ -35,3 +41,43 @@ describe("remote-access section id", () => { expect(isSettingsSectionId("remote-access")).toBe(true); }); }); + +describe("connections section (PR 2.5)", () => { + it("is hidden while the remoteEnvironments flag is off", () => { + expect( + visibleSettingsSections({}).some((section) => section.id === "connections"), + ).toBe(false); + expect( + visibleSettingsSections({ remoteEnvironments: false }).some( + (section) => section.id === "connections", + ), + ).toBe(false); + }); + + it("appears beside Remote Access in the External Access group when the flag is on", () => { + const sections = visibleSettingsSections({ remoteEnvironments: true }); + const connections = sections.find((section) => section.id === "connections"); + expect(connections).toEqual({ + id: "connections", + groupId: "access", + label: "Connections", + }); + + // Adjacency is the point: both surfaces of the same feature, side by side. + const ids = sections.map((section) => section.id); + expect(Math.abs(ids.indexOf("connections") - ids.indexOf("remote-access"))).toBe(1); + }); + + it("gates both remote sections off the same flag, not two special cases", () => { + const gated = visibleSettingsSections({ remoteEnvironments: false }); + expect( + gated.every( + (section) => section.id !== "connections" && section.id !== "remote-access", + ), + ).toBe(true); + }); + + it("is a recognised section id", () => { + expect(isSettingsSectionId("connections")).toBe(true); + }); +}); diff --git a/frontend/src/components/settings/settings-registry.ts b/frontend/src/components/settings/settings-registry.ts index 3821073933..468f4780fd 100644 --- a/frontend/src/components/settings/settings-registry.ts +++ b/frontend/src/components/settings/settings-registry.ts @@ -13,6 +13,7 @@ export type SettingsSectionId = | "api-keys" | "external-mcp" | "remote-access" + | "connections" | "integrations" | "github" | "linear" @@ -69,6 +70,7 @@ export const SETTINGS_SECTIONS: SettingsSectionMeta[] = [ { id: "api-keys", groupId: "access", label: "API Keys" }, { id: "external-mcp", groupId: "access", label: "External MCP" }, { id: "remote-access", groupId: "access", label: "Remote Access" }, + { id: "connections", groupId: "access", label: "Connections" }, { id: "updates", groupId: "preferences", label: "Updates" }, { id: "accessibility", groupId: "preferences", label: "Accessibility" }, { id: "notifications", groupId: "preferences", label: "Notifications" }, @@ -80,14 +82,24 @@ export interface SettingsSectionFlagGates { } /** - * Sections visible for the given feature flags. `remote-access` ships dark - * behind `remoteEnvironments` (PR 1.7, §8 flags note). + * Section ids that ship dark behind `remoteEnvironments` (§8 flags note): the host + * pane (PR 1.7) and the client pane (PR 2.5). A set rather than a chain of id + * comparisons — the next gated section should be one entry here, not a third special + * case someone can forget to add to the filter. */ +const REMOTE_ENVIRONMENT_GATED_SECTIONS: ReadonlySet = new Set([ + "remote-access", + "connections", +]); + +/** Sections visible for the given feature flags. */ export function visibleSettingsSections( flags: SettingsSectionFlagGates, ): SettingsSectionMeta[] { return SETTINGS_SECTIONS.filter( - (section) => section.id !== "remote-access" || flags.remoteEnvironments === true, + (section) => + !REMOTE_ENVIRONMENT_GATED_SECTIONS.has(section.id) || + flags.remoteEnvironments === true, ); } diff --git a/frontend/src/mocks/tauri-api-core.ts b/frontend/src/mocks/tauri-api-core.ts index 2a81874b43..4cf66623cb 100644 --- a/frontend/src/mocks/tauri-api-core.ts +++ b/frontend/src/mocks/tauri-api-core.ts @@ -1228,6 +1228,19 @@ const mockRemoteHost = { codeCounter: 0, }; +/** + * Client-side environment registry (PR 2.5). Separate from `mockRemoteHost`, which is + * the HOST pane's state: this is what THIS Mac has paired with. + * + * `previewSkew` lets a journey drive the version-contradiction path without standing up + * a second mock host, using the service's own `REMOTE_VERSION_MISMATCH` code. + */ +const mockRemoteEnvironments: { + rows: Array>; + previewSkew: boolean; + counter: number; +} = { rows: [], previewSkew: false, counter: 0 }; + const commandHandlers: Record< string, (args: Record) => Promise @@ -1512,6 +1525,75 @@ const commandHandlers: Record< ); return mockRemoteHost.sessions.length < before; }, + // --- Client environment registry (PR 2.5) --- + preview_remote_environment: async (args) => { + const url = String((args as { input?: { url?: unknown } }).input?.url ?? ""); + const skew = + typeof window !== "undefined" + ? (window as Window & { __mockRemoteEnvironmentSkew?: boolean }) + .__mockRemoteEnvironmentSkew === true + : false; + if (skew || mockRemoteEnvironments.previewSkew) { + throw new Error( + "REMOTE_VERSION_MISMATCH: host requires client protocol >= 2, this client speaks 1", + ); + } + if (url.includes("offline")) { + throw new Error("REMOTE_UNREACHABLE: host unreachable: mock host offline"); + } + const existing = mockRemoteEnvironments.rows.find( + (row) => row.environmentId === "env-mock-1", + ); + return { + environmentId: "env-mock-1", + appVersion: "0.9.4", + platform: "macos", + protocolVersion: 1, + minClientProtocol: 1, + alreadyPairedAs: existing ? (existing.name as string) : null, + }; + }, + pair_remote_environment: async (args) => { + const input = (args as { input?: Record }).input ?? {}; + const name = String(input.name ?? "Remote Mac"); + const baseUrl = String(input.url ?? "https://mock-host.tailnet.ts.net"); + // Upsert on environmentId, exactly like the Rust registry (§6.1). + const existing = mockRemoteEnvironments.rows.find( + (row) => row.environmentId === "env-mock-1", + ); + if (existing) { + Object.assign(existing, { name, baseUrl, status: "active" }); + return { ...existing }; + } + mockRemoteEnvironments.counter += 1; + const row: Record = { + id: `renv-${mockRemoteEnvironments.counter}`, + environmentId: "env-mock-1", + name, + baseUrl, + candidateUrls: [baseUrl], + scopes: ["ui:read", "ui:operate"], + protocolVersion: 1, + status: "active", + createdAt: new Date().toISOString(), + lastConnectedAt: null, + }; + mockRemoteEnvironments.rows.push(row); + return { ...row }; + }, + list_remote_environments: async () => + mockRemoteEnvironments.rows.map((row) => ({ ...row })), + remove_remote_environment: async (args) => { + const id = String((args as { input?: { id?: unknown } }).input?.id ?? ""); + const row = mockRemoteEnvironments.rows.find((entry) => entry.id === id); + if (row) { + // Staged removal: the row goes to pending_delete, it does not vanish. + row.status = "pending_delete"; + } + return null; + }, + get_active_environment: async () => "local", + set_active_environment: async () => null, list_remote_advertised_endpoints: async () => mockRemoteHost.status.exposureMode === "serve" ? [ diff --git a/frontend/tests/integration/remote-environment-pairing.spec.ts b/frontend/tests/integration/remote-environment-pairing.spec.ts new file mode 100644 index 0000000000..a2389a10de --- /dev/null +++ b/frontend/tests/integration/remote-environment-pairing.spec.ts @@ -0,0 +1,186 @@ +/** + * Client pairing journey (PR 2.5) — the CLIENT half, distinct from + * `remote-access-pairing.spec.ts`, which drives the HOST pane. + * + * Runs against the web-mode client registry mock in `src/mocks/tauri-api-core.ts` + * (`mockRemoteEnvironments`), which upserts on `environmentId` exactly like the Rust + * registry, so "re-pair updates, never duplicates" is observable end to end. + */ + +import { expect, test } from "@playwright/test"; + +import { setupSettings } from "../fixtures/setup.fixtures"; + +const PAIRING_URL = + "ralphx://pair?host=https%3A%2F%2Fmock-host.tailnet.ts.net%3A3849#code=rxp_ABCD1234EFGH"; + +test.describe("Remote environment pairing journey (client)", () => { + test.beforeEach(async ({ page }) => { + await page.addInitScript(() => { + ( + window as Window & { + __mockUiFeatureFlags?: Record; + } + ).__mockUiFeatureFlags = { + activityPage: true, + extensibilityPage: true, + automationsPage: true, + atlassianOauth: false, + ticketingDashboard: false, + remoteEnvironments: true, + }; + }); + await setupSettings(page); + await page.click('[data-testid="settings-section-connections"]'); + await page.waitForSelector('[data-testid="connections-section"]'); + }); + + test("pastes a pairing link, previews the host, pairs, and lands in the list", async ({ + page, + }) => { + // Empty to begin with — nothing paired on this Mac. + await expect(page.getByTestId("connections-empty")).toBeVisible(); + + await page.getByTestId("connections-add").click(); + await expect(page.getByTestId("add-environment-dialog")).toBeVisible(); + + // Pasting the link fills host AND code; the code renders in 4-char groups (R-12). + await page.getByTestId("add-environment-host").fill(PAIRING_URL); + await expect(page.getByTestId("add-environment-host")).toHaveValue( + "https://mock-host.tailnet.ts.net:3849", + ); + await expect(page.getByTestId("add-environment-code")).toHaveValue( + "rxp_ ABCD 1234 EFGH", + ); + + await page.getByTestId("add-environment-continue").click(); + + // Verify step shows descriptor truth — and no project count, which the wire + // descriptor does not carry. + await expect(page.getByTestId("add-environment-step-verify")).toBeVisible(); + await expect(page.getByTestId("add-environment-protocol")).toContainText( + "v1", + ); + await expect(page.getByText("0.9.4")).toBeVisible(); + + await page.getByTestId("add-environment-name").fill("Studio Mac"); + await page.getByTestId("add-environment-pair").click(); + + await expect(page.getByTestId("add-environment-success")).toBeVisible(); + await expect( + page.getByTestId("add-environment-success-banner"), + ).toContainText("Studio Mac"); + await page.getByTestId("add-environment-done").click(); + + // It is in the Connections list… + const row = page.locator('[data-testid^="connections-row-"]'); + await expect(row).toHaveCount(1); + await expect(row).toContainText("Studio Mac"); + + // …and in the environment switcher, which is the acceptance that matters. + await page.keyboard.press("Escape"); + await page.getByTestId("environment-switcher-trigger").click(); + await expect(page.getByText("Studio Mac")).toBeVisible(); + }); + + test("re-pairing the same host updates the row instead of adding a second", async ({ + page, + }) => { + await page.getByTestId("connections-add").click(); + await page.getByTestId("add-environment-host").fill(PAIRING_URL); + await page.getByTestId("add-environment-continue").click(); + await page.getByTestId("add-environment-name").fill("Studio Mac"); + await page.getByTestId("add-environment-pair").click(); + await page.getByTestId("add-environment-done").click(); + + const row = page.locator('[data-testid^="connections-row-"]'); + await expect(row).toHaveCount(1); + + // Re-pair from the row itself: host locked, name prefilled from the existing row. + await row.getByRole("button", { name: "Re-pair" }).click(); + await expect(page.getByTestId("add-environment-host")).toBeDisabled(); + await page.getByTestId("add-environment-code").fill("rxp_WXYZ9876"); + await page.getByTestId("add-environment-continue").click(); + + await expect( + page.getByTestId("add-environment-already-paired"), + ).toContainText("updates it"); + await page.getByTestId("add-environment-name").fill("Studio Mac Renamed"); + await page.getByTestId("add-environment-pair").click(); + await page.getByTestId("add-environment-done").click(); + + // One host identity, one row — the upsert is visible to the user. + await expect(row).toHaveCount(1); + await expect(row).toContainText("Studio Mac Renamed"); + }); + + test("a version contradiction parks in a blocked state with no retry", async ({ + page, + }) => { + await page.evaluate(() => { + ( + window as Window & { __mockRemoteEnvironmentSkew?: boolean } + ).__mockRemoteEnvironmentSkew = true; + }); + + await page.getByTestId("connections-add").click(); + await page.getByTestId("add-environment-host").fill(PAIRING_URL); + await page.getByTestId("add-environment-continue").click(); + + const banner = page.getByTestId("add-environment-blocked-banner"); + await expect(banner).toBeVisible(); + await expect(banner).toContainText("Versions are incompatible"); + await expect(banner).toContainText("client protocol >= 2"); + + // It stays blocked: nothing here schedules a retry (A-5). + await page.waitForTimeout(1500); + await expect(banner).toBeVisible(); + await expect(page.getByTestId("add-environment-step-verify")).toHaveCount( + 0, + ); + + // Back returns to step 1 so the user can fix it themselves. + await page.getByTestId("add-environment-blocked-back").click(); + await expect( + page.getByTestId("add-environment-step-connect"), + ).toBeVisible(); + }); + + test("removing an environment stages it rather than making it disappear", async ({ + page, + }) => { + await page.getByTestId("connections-add").click(); + await page.getByTestId("add-environment-host").fill(PAIRING_URL); + await page.getByTestId("add-environment-continue").click(); + await page.getByTestId("add-environment-name").fill("Studio Mac"); + await page.getByTestId("add-environment-pair").click(); + await page.getByTestId("add-environment-done").click(); + + const row = page.locator('[data-testid^="connections-row-"]'); + await expect(row).toHaveCount(1); + + await row.getByRole("button", { name: /^Remove / }).click(); + await expect(page.getByTestId("connections-remove-confirm")).toBeVisible(); + await page.getByTestId("connections-remove-confirm-action").click(); + + // Still listed, now explained as in-progress — the reconciler owns the rest. + await expect(row).toHaveCount(1); + await expect(row).toContainText("Removing…"); + await expect(row).toContainText("No action needed"); + }); + + test("the Connections section is absent while the flag is off", async ({ + page, + }) => { + await page.addInitScript(() => { + ( + window as Window & { __mockUiFeatureFlags?: Record } + ).__mockUiFeatureFlags = { remoteEnvironments: false }; + }); + await setupSettings(page); + + await expect( + page.locator('[data-testid="settings-section-connections"]'), + ).toHaveCount(0); + }); +}); From 50d29120e341a3d28d7de10848858aeb3c73751b Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:52:09 +0300 Subject: [PATCH 155/416] fix(remote): split the invoke error taxonomy and cover the authorization gate - enforce_scope, the facade's only authorization gate, had exactly one positive test and no router-level coverage at all: auth_tests installs UnavailableInvokeDispatcher, so the production path never ran and regressing the gate to a no-op passed the whole branch. Adds a dispatcher that drives the real registry::dispatch from the router, three router-level cases (insufficient grant, unregistered command, admitted request), and exhaustive negatives for every class, every wrong scope, Denied, and the argument-sensitive predicate escalating past the class scope. - Malformed arguments and host serialization faults both emitted RemoteCommandUnavailable, the code the client reads as 'this host does not support the command' - a terminal signal about to gate remote affordances in 2.6-b. Argument errors now map to REMOTE_INVALID_ARGUMENTS (400) and host faults to REMOTE_INTERNAL_ERROR (500); the 404 code is reserved for a find_spec miss. Both sides of the wire change together: protocol enum and vocabulary snapshot, host status mapping, client proxy status mapping, and the TS taxonomy, whose supervisor now classifies an argument error as non-transient rather than looping the backoff ladder. - The P-17h justification for keeping category/priority at ui:operate was factually wrong: it claimed WorkerTaskView excludes them from every worker payload, but /api/get_task_details serializes both through task_to_response. The comment now states the real invariant - a closed enum and an i32 cannot carry attacker-chosen text - and a poison-sentinel test pins task_to_response's exact contract. The manifest's worker_task_view_allowlist is derived from the struct instead of restated as a literal. --- .../src/lib/remote/network-invoke.test.ts | 2 +- frontend/src/lib/remote/supervisor.ts | 4 + frontend/src/lib/remote/transport-errors.ts | 10 +- .../crates/ralphx-remote-protocol/src/lib.rs | 10 + .../tests/protocol_contract.rs | 2 +- .../tests/snapshots/vocabulary.json | 2 +- .../application/remote_environment_service.rs | 4 + .../remote_environment_service_tests.rs | 4 + src-tauri/src/remote_server/auth_tests.rs | 24 +- .../remote_server/capability_ledger_tests.rs | 25 +- src-tauri/src/remote_server/invoke.rs | 2 + src-tauri/src/remote_server/invoke_tests.rs | 293 +++++++++++++++++- src-tauri/src/remote_server/registry.rs | 24 +- src-tauri/src/remote_server/registry_tests.rs | 60 +++- 14 files changed, 445 insertions(+), 21 deletions(-) diff --git a/frontend/src/lib/remote/network-invoke.test.ts b/frontend/src/lib/remote/network-invoke.test.ts index 1380a3a73a..ee71b4975f 100644 --- a/frontend/src/lib/remote/network-invoke.test.ts +++ b/frontend/src/lib/remote/network-invoke.test.ts @@ -145,7 +145,7 @@ describe("the 8-code taxonomy", () => { }); it("maps every code — none is dropped", () => { - expect(REMOTE_TRANSPORT_ERROR_CODES).toHaveLength(8); + expect(REMOTE_TRANSPORT_ERROR_CODES).toHaveLength(10); }); it.each([ diff --git a/frontend/src/lib/remote/supervisor.ts b/frontend/src/lib/remote/supervisor.ts index 54db231e68..91092b7535 100644 --- a/frontend/src/lib/remote/supervisor.ts +++ b/frontend/src/lib/remote/supervisor.ts @@ -585,6 +585,10 @@ function classifyFailure(error: unknown): AttemptFailure { return "unauthorized"; case "REMOTE_VERSION_MISMATCH": return "version"; + case "REMOTE_INVALID_ARGUMENTS": + // A client-side request bug. Retrying an identical malformed request cannot + // succeed, so it must not enter the backoff ladder as transient. + return "malformed_descriptor"; default: return "transient"; } diff --git a/frontend/src/lib/remote/transport-errors.ts b/frontend/src/lib/remote/transport-errors.ts index 90c291e436..c6eb5b95c2 100644 --- a/frontend/src/lib/remote/transport-errors.ts +++ b/frontend/src/lib/remote/transport-errors.ts @@ -1,5 +1,5 @@ /** - * The canonical 8-code remote transport error taxonomy (§6.3 / §3.3, R5-L1). + * The canonical 10-code remote transport error taxonomy (§6.3 / §3.3, R5-L1). * * These describe the TRANSPORT, never the command. A registered command that runs * on the host and returns `Err(E)` is NOT a transport error — `NetworkInvoke` @@ -23,6 +23,14 @@ export const REMOTE_TRANSPORT_ERROR_CODES = [ "REMOTE_TIMEOUT_UNKNOWN", "REMOTE_REQUEST_IN_PROGRESS", "REMOTE_REQUEST_ID_REUSED", + // A registered command whose ARGUMENTS were rejected (400). Kept distinct from + // REMOTE_COMMAND_UNAVAILABLE (404) because that code means "this host does not + // support the command at all" and is about to gate remote affordances: an argument + // bug must never be read as a missing capability. + "REMOTE_INVALID_ARGUMENTS", + // The host failed while producing the answer (500) — a host-side fault, not a + // statement about the request. + "REMOTE_INTERNAL_ERROR", ] as const; export type RemoteTransportErrorCode = diff --git a/src-tauri/crates/ralphx-remote-protocol/src/lib.rs b/src-tauri/crates/ralphx-remote-protocol/src/lib.rs index 85189a32fe..cc48e0487c 100644 --- a/src-tauri/crates/ralphx-remote-protocol/src/lib.rs +++ b/src-tauri/crates/ralphx-remote-protocol/src/lib.rs @@ -164,6 +164,14 @@ pub enum ErrorCode { RemoteRequestInProgress, #[serde(rename = "REMOTE_REQUEST_ID_REUSED")] RemoteRequestIdReused, + /// The request reached a registered command but its arguments could not be deserialized. + /// Distinct from `RemoteCommandUnavailable`, which is reserved for "no such command". + #[serde(rename = "REMOTE_INVALID_ARGUMENTS")] + RemoteInvalidArguments, + /// The host failed while producing the answer (e.g. a response that will not serialize). + /// A host-side fault, never a statement about the client's request. + #[serde(rename = "REMOTE_INTERNAL_ERROR")] + RemoteInternalError, } pub const ERROR_CODES: &[ErrorCode] = &[ ErrorCode::RemoteCommandUnavailable, @@ -174,6 +182,8 @@ pub const ERROR_CODES: &[ErrorCode] = &[ ErrorCode::RemoteTimeoutUnknown, ErrorCode::RemoteRequestInProgress, ErrorCode::RemoteRequestIdReused, + ErrorCode::RemoteInvalidArguments, + ErrorCode::RemoteInternalError, ]; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/src-tauri/crates/ralphx-remote-protocol/tests/protocol_contract.rs b/src-tauri/crates/ralphx-remote-protocol/tests/protocol_contract.rs index dec40a390e..476fd0c601 100644 --- a/src-tauri/crates/ralphx-remote-protocol/tests/protocol_contract.rs +++ b/src-tauri/crates/ralphx-remote-protocol/tests/protocol_contract.rs @@ -76,7 +76,7 @@ fn descriptor_and_wire_enums_match_the_closed_contract() { assert_eq!(RISK_CLASSES.len(), 6); assert_eq!(CAPABILITIES.len(), 11); assert_eq!(RESET_REASONS.len(), 6); - assert_eq!(ERROR_CODES.len(), 8); + assert_eq!(ERROR_CODES.len(), 10); } #[test] diff --git a/src-tauri/crates/ralphx-remote-protocol/tests/snapshots/vocabulary.json b/src-tauri/crates/ralphx-remote-protocol/tests/snapshots/vocabulary.json index f9f90063ed..4463beda86 100644 --- a/src-tauri/crates/ralphx-remote-protocol/tests/snapshots/vocabulary.json +++ b/src-tauri/crates/ralphx-remote-protocol/tests/snapshots/vocabulary.json @@ -4,5 +4,5 @@ "riskClasses":["read","operate","pathScoped","agentControl","elevated","denied"], "capabilities":["spawnsProcess","writesArbitraryPath","mutatesWorkingDirectory","configuresFutureProcessAuthority","touchesCredentials","ptyControl","agentControl","seedsSpawnTriggeringState","mutatesAgentConsumedContent","hostManagement","deletesEntity"], "resetReasons":["cursor_pruned","epoch_changed","after_seq_gt_max","read_error","revoked","host_disabled"], - "errorCodes":["REMOTE_COMMAND_UNAVAILABLE","REMOTE_FORBIDDEN","REMOTE_UNAUTHORIZED","REMOTE_UNREACHABLE","REMOTE_VERSION_MISMATCH","REMOTE_TIMEOUT_UNKNOWN","REMOTE_REQUEST_IN_PROGRESS","REMOTE_REQUEST_ID_REUSED"] + "errorCodes":["REMOTE_COMMAND_UNAVAILABLE","REMOTE_FORBIDDEN","REMOTE_UNAUTHORIZED","REMOTE_UNREACHABLE","REMOTE_VERSION_MISMATCH","REMOTE_TIMEOUT_UNKNOWN","REMOTE_REQUEST_IN_PROGRESS","REMOTE_REQUEST_ID_REUSED","REMOTE_INVALID_ARGUMENTS","REMOTE_INTERNAL_ERROR"] } diff --git a/src-tauri/src/application/remote_environment_service.rs b/src-tauri/src/application/remote_environment_service.rs index f9f112f41b..77e3874dda 100644 --- a/src-tauri/src/application/remote_environment_service.rs +++ b/src-tauri/src/application/remote_environment_service.rs @@ -143,6 +143,8 @@ fn remote_error_code_str(code: ErrorCode) -> &'static str { ErrorCode::RemoteTimeoutUnknown => "REMOTE_TIMEOUT_UNKNOWN", ErrorCode::RemoteRequestInProgress => "REMOTE_REQUEST_IN_PROGRESS", ErrorCode::RemoteRequestIdReused => "REMOTE_REQUEST_ID_REUSED", + ErrorCode::RemoteInvalidArguments => "REMOTE_INVALID_ARGUMENTS", + ErrorCode::RemoteInternalError => "REMOTE_INTERNAL_ERROR", } } @@ -1094,6 +1096,8 @@ fn status_error_code(status: u16) -> ErrorCode { 409 => ErrorCode::RemoteRequestInProgress, 422 => ErrorCode::RemoteRequestIdReused, 426 | 505 => ErrorCode::RemoteVersionMismatch, + 400 => ErrorCode::RemoteInvalidArguments, + 500 => ErrorCode::RemoteInternalError, _ => ErrorCode::RemoteUnreachable, } } diff --git a/src-tauri/src/application/remote_environment_service_tests.rs b/src-tauri/src/application/remote_environment_service_tests.rs index 3c038e6ff3..b06c052739 100644 --- a/src-tauri/src/application/remote_environment_service_tests.rs +++ b/src-tauri/src/application/remote_environment_service_tests.rs @@ -1761,6 +1761,10 @@ fn every_untyped_status_maps_to_the_expected_code() { (422, "REMOTE_REQUEST_ID_REUSED"), (426, "REMOTE_VERSION_MISMATCH"), (505, "REMOTE_VERSION_MISMATCH"), + // A malformed-argument refusal must NOT arrive as "command unavailable": the client + // reads that code as "this host does not support the command at all". + (400, "REMOTE_INVALID_ARGUMENTS"), + (500, "REMOTE_INTERNAL_ERROR"), (599, "REMOTE_UNREACHABLE"), ] { let error = transport_error(RemoteHostClientError::Rejected { diff --git a/src-tauri/src/remote_server/auth_tests.rs b/src-tauri/src/remote_server/auth_tests.rs index d91acfd3c7..5ee1d77b9e 100644 --- a/src-tauri/src/remote_server/auth_tests.rs +++ b/src-tauri/src/remote_server/auth_tests.rs @@ -42,7 +42,7 @@ use crate::infrastructure::sqlite::{run_migrations, DbConnection}; use crate::remote_server::invoke::RemoteInvokeDispatcher; use crate::remote_server::registry::{DispatchOutcome, RemoteInvokeError}; -const TEST_ENVIRONMENT_ID: &str = "11111111-2222-3333-4444-555555555555"; +pub(super) const TEST_ENVIRONMENT_ID: &str = "11111111-2222-3333-4444-555555555555"; /// A migrated in-memory store plus a fresh registry — enough to serve the whole remote /// router without touching the filesystem. @@ -119,7 +119,10 @@ fn delete_with_bearer(path: &str, token: &str) -> Request { } /// Mints a pairing code the way `generate_remote_pairing_code` does. -async fn mint_pairing_code(context: &RemoteAuthContext, scopes: RemoteScopeSet) -> String { +pub(super) async fn mint_pairing_code( + context: &RemoteAuthContext, + scopes: RemoteScopeSet, +) -> String { let raw = generate_pairing_code(); context .pairing_codes @@ -137,8 +140,21 @@ async fn mint_pairing_code(context: &RemoteAuthContext, scopes: RemoteScopeSet) } /// Pairs a device through the real HTTP surface and returns its raw token. -async fn pair_device(context: &RemoteAuthContext, name: &str) -> (String, RemoteDeviceId) { - let code = mint_pairing_code(context, RemoteScopeSet::default_pairing_grant()).await; +pub(super) async fn pair_device( + context: &RemoteAuthContext, + name: &str, +) -> (String, RemoteDeviceId) { + pair_device_with_scopes(context, name, RemoteScopeSet::default_pairing_grant()).await +} + +/// Pairs a device holding exactly `scopes`, so the production authorization gate can be +/// driven from the router with a grant that is deliberately insufficient. +pub(super) async fn pair_device_with_scopes( + context: &RemoteAuthContext, + name: &str, + scopes: RemoteScopeSet, +) -> (String, RemoteDeviceId) { + let code = mint_pairing_code(context, scopes).await; let response = router_for(context) .oneshot(post_json( PAIR_PATH, diff --git a/src-tauri/src/remote_server/capability_ledger_tests.rs b/src-tauri/src/remote_server/capability_ledger_tests.rs index 12fc150cad..3738556afa 100644 --- a/src-tauri/src/remote_server/capability_ledger_tests.rs +++ b/src-tauri/src/remote_server/capability_ledger_tests.rs @@ -290,6 +290,27 @@ fn agent_content_writers() -> Vec { .collect() } +/// The worker-safe task projection, DERIVED from `WorkerTaskView` rather than restated as a +/// literal: a field added to the struct appears here (and stales the manifest) instead of +/// silently widening the projection behind a hand-written list. +fn worker_task_view_allowlist() -> Vec { + use crate::domain::entities::{IdeationSessionId, ProjectId, Task, WorkerTaskView}; + + let mut task = Task::new(ProjectId::new(), "allowlist probe".to_string()); + // Every optional field must be populated or `skip_serializing_if` hides it from the + // derived key set. + task.description = Some("populated".to_string()); + task.ideation_session_id = Some(IdeationSessionId::new()); + let view: WorkerTaskView = task.into(); + serde_json::to_value(view) + .expect("worker task view serializes") + .as_object() + .expect("worker task view is a JSON object") + .keys() + .cloned() + .collect() +} + fn generated_manifest() -> serde_json::Value { let rows = census(); let graph = CallGraph::build(&load_production_sources()); @@ -375,9 +396,7 @@ fn generated_manifest() -> serde_json::Value { "background_loop_inventory": background_loop_inventory, "spawn_triggering_state_surface": spawn_triggering_state_surface, "agent_consumed_content_surface": {"reads": agent_content_reads(), "writers": agent_content_writers()}, - "worker_task_view_allowlist": [ - "id", "project_id", "title", "description", "internal_status", "ideation_session_id" - ], + "worker_task_view_allowlist": worker_task_view_allowlist(), "authority_reducing_exemptions": authority_reducing_exemptions, "declared_memberships": declared_memberships, "ledger": ledger, diff --git a/src-tauri/src/remote_server/invoke.rs b/src-tauri/src/remote_server/invoke.rs index 70caca9859..1b67dbb8e5 100644 --- a/src-tauri/src/remote_server/invoke.rs +++ b/src-tauri/src/remote_server/invoke.rs @@ -108,5 +108,7 @@ pub(crate) const fn status_for_error_code(code: ErrorCode) -> StatusCode { ErrorCode::RemoteRequestIdReused => StatusCode::UNPROCESSABLE_ENTITY, ErrorCode::RemoteVersionMismatch => StatusCode::UPGRADE_REQUIRED, ErrorCode::RemoteUnreachable => StatusCode::SERVICE_UNAVAILABLE, + ErrorCode::RemoteInvalidArguments => StatusCode::BAD_REQUEST, + ErrorCode::RemoteInternalError => StatusCode::INTERNAL_SERVER_ERROR, } } diff --git a/src-tauri/src/remote_server/invoke_tests.rs b/src-tauri/src/remote_server/invoke_tests.rs index 95571fee27..b9acf58e97 100644 --- a/src-tauri/src/remote_server/invoke_tests.rs +++ b/src-tauri/src/remote_server/invoke_tests.rs @@ -1,10 +1,72 @@ -use axum::{body::to_bytes, http::StatusCode}; -use ralphx_remote_protocol::{ErrorCode, Scope}; +use std::sync::Arc; + +use async_trait::async_trait; +use axum::{ + body::to_bytes, + body::Body, + http::{header, Method, Request, StatusCode}, + Router, +}; +use ralphx_remote_protocol::{ErrorCode, RiskClass, Scope}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; +use tower::ServiceExt; + +use super::auth::RemoteAuthContext; +use super::auth_tests::{in_memory_auth_context, pair_device_with_scopes, TEST_ENVIRONMENT_ID}; +use super::endpoints::RemoteRouterState; +use super::invoke::{ + dispatch_outcome_response, invoke_error_response, status_for_error_code, RemoteInvokeDispatcher, +}; +use super::registry::{self, enforce_scope, DispatchOutcome, RemoteCommandSpec, RemoteInvokeError}; +use super::{authenticated_remote_routes, INVOKE_PATH}; +use crate::domain::entities::RemoteScopeSet; + +/// Drives the PRODUCTION `registry::dispatch` — and therefore the production +/// `enforce_scope` — from the router. +/// +/// `auth_tests` installs `UnavailableInvokeDispatcher`, so before this existed no +/// router-level test executed the authorization gate at all: regressing `enforce_scope` +/// to a no-op passed the entire branch. +struct RegistryInvokeDispatcher { + app: tauri::AppHandle, +} + +#[async_trait] +impl RemoteInvokeDispatcher for RegistryInvokeDispatcher { + async fn dispatch( + &self, + scopes: &[Scope], + command: &str, + args: &Value, + ) -> Result { + registry::dispatch(&self.app, scopes, command, args).await + } +} -use super::invoke::{dispatch_outcome_response, invoke_error_response, status_for_error_code}; -use super::registry::{self, DispatchOutcome, RemoteInvokeError}; +fn router_with_real_registry( + context: &RemoteAuthContext, + app: tauri::AppHandle, +) -> Router { + authenticated_remote_routes(RemoteRouterState::new_with_invoke_dispatcher( + TEST_ENVIRONMENT_ID, + context.clone(), + Arc::new(RegistryInvokeDispatcher { app }), + )) +} + +fn invoke_request(token: &str, cmd: &str, args: Value) -> Request { + Request::builder() + .method(Method::POST) + .uri(INVOKE_PATH) + .header(header::CONTENT_TYPE, "application/json") + .header(header::AUTHORIZATION, format!("Bearer {token}")) + .body(Body::from( + json!({"requestId": uuid::Uuid::new_v4().to_string(), "cmd": cmd, "args": args}) + .to_string(), + )) + .expect("request should build") +} #[derive(Debug, Deserialize, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] @@ -98,6 +160,11 @@ async fn facade_errors_are_non_2xx_with_typed_code_and_message() { ErrorCode::RemoteUnreachable, StatusCode::SERVICE_UNAVAILABLE, ), + (ErrorCode::RemoteInvalidArguments, StatusCode::BAD_REQUEST), + ( + ErrorCode::RemoteInternalError, + StatusCode::INTERNAL_SERVER_ERROR, + ), ]; for (code, status) in cases { @@ -113,3 +180,221 @@ async fn facade_errors_are_non_2xx_with_typed_code_and_message() { ); } } + +// --------------------------------------------------------------------------------------- +// Error taxonomy: malformed arguments are NOT "command unavailable" +// --------------------------------------------------------------------------------------- + +#[test] +fn malformed_arguments_do_not_masquerade_as_an_unsupported_command() { + // `RemoteCommandUnavailable` is what the client reads as "this host does not support the + // command", a terminal signal about to gate remote affordances. An argument error must + // never produce it. + let error = registry::extract_arg::(&json!({"project_id": 17}), "project_id") + .expect_err("a type-mismatched argument must fail"); + assert_eq!(error.code, ErrorCode::RemoteInvalidArguments); + assert_eq!( + status_for_error_code(error.code), + StatusCode::BAD_REQUEST, + "argument errors are a 4xx distinct from the 404 unavailable envelope" + ); + + let missing = registry::extract_arg::(&json!({}), "project_id") + .expect_err("a missing required argument must fail"); + assert_eq!(missing.code, ErrorCode::RemoteInvalidArguments); + + // Only a `find_spec` miss keeps the 404 code. + let unavailable = RemoteInvokeError::unavailable("no_such_command"); + assert_eq!(unavailable.code, ErrorCode::RemoteCommandUnavailable); + assert_eq!( + status_for_error_code(unavailable.code), + StatusCode::NOT_FOUND + ); +} + +#[test] +fn a_response_that_will_not_serialize_is_a_host_fault_not_an_unavailable_command() { + #[derive(Debug)] + struct Unserializable; + impl serde::Serialize for Unserializable { + fn serialize(&self, _: S) -> Result { + Err(serde::ser::Error::custom("nope")) + } + } + + let error = registry::serialize_ok(Unserializable).expect_err("serialization must fail"); + assert_eq!(error.code, ErrorCode::RemoteInternalError); + assert_eq!( + status_for_error_code(error.code), + StatusCode::INTERNAL_SERVER_ERROR + ); +} + +// --------------------------------------------------------------------------------------- +// enforce_scope — negative coverage (the facade's only authorization gate) +// --------------------------------------------------------------------------------------- + +fn spec_with(class: RiskClass, authz: Option) -> RemoteCommandSpec { + RemoteCommandSpec { + name: "fixture", + target: "fixture_target", + class, + capabilities: &[], + authz, + validate: None, + } +} + +#[test] +fn enforce_scope_refuses_every_insufficient_grant() { + // Class scope missing entirely. + for (class, sufficient) in [ + (RiskClass::Read, Scope::UiRead), + (RiskClass::Operate, Scope::UiOperate), + (RiskClass::PathScoped, Scope::UiOperate), + (RiskClass::AgentControl, Scope::UiAgent), + (RiskClass::Elevated, Scope::UiElevated), + ] { + let spec = spec_with(class, None); + let refused = + enforce_scope(&spec, &[], &json!({})).expect_err("an empty grant authorizes nothing"); + assert_eq!(refused.code, ErrorCode::RemoteForbidden, "{class:?}"); + + // Every OTHER scope is also insufficient — holding one scope never implies another. + for wrong in [ + Scope::UiRead, + Scope::UiOperate, + Scope::UiAgent, + Scope::UiElevated, + ] { + if wrong == sufficient { + continue; + } + assert!( + enforce_scope(&spec, &[wrong], &json!({})).is_err(), + "{class:?} must not be authorized by {wrong:?}" + ); + } + enforce_scope(&spec, &[sufficient], &json!({})).expect("the exact class scope authorizes"); + } + + // Denied is unauthorizable by every grant, including the full set. + let denied = spec_with(RiskClass::Denied, None); + assert!(enforce_scope( + &denied, + &[ + Scope::UiRead, + Scope::UiOperate, + Scope::UiAgent, + Scope::UiElevated + ], + &json!({}) + ) + .is_err()); +} + +#[test] +fn enforce_scope_applies_the_argument_sensitive_predicate_above_the_class_scope() { + let spec = spec_with(RiskClass::Operate, Some(registry::update_task_authz)); + + // Content-free update: the class scope is enough. + enforce_scope( + &spec, + &[Scope::UiOperate], + &json!({"input": {"priority": 3}}), + ) + .expect("a non-content update needs only ui:operate"); + + // Title/description are deferred spawn authority and demand ui:agent even though the + // command's class scope was already satisfied. + for field in ["title", "description"] { + let args = json!({"input": {field: "poisoned"}}); + let refused = enforce_scope(&spec, &[Scope::UiOperate], &args) + .expect_err("content writes must escalate past the class scope"); + assert_eq!(refused.code, ErrorCode::RemoteForbidden); + enforce_scope(&spec, &[Scope::UiOperate, Scope::UiAgent], &args) + .expect("ui:agent authorizes the content write"); + } +} + +#[tokio::test] +async fn the_router_refuses_a_registered_command_the_device_lacks_scope_for() { + let context = in_memory_auth_context(); + let (token, _) = pair_device_with_scopes( + &context, + "operate-only", + RemoteScopeSet::from_scopes([Scope::UiOperate]), + ) + .await; + let app = crate::testing::create_mock_app(); + + let response = router_with_real_registry(&context, app.handle().clone()) + .oneshot(invoke_request( + &token, + "list_tasks", + json!({"projectId": "p1"}), + )) + .await + .expect("invoke request should complete"); + + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "a ui:operate device must not reach a ui:read command" + ); + assert_eq!( + response_json(response).await["code"], + json!(ErrorCode::RemoteForbidden) + ); +} + +#[tokio::test] +async fn the_router_answers_an_unregistered_command_with_the_unavailable_envelope() { + let context = in_memory_auth_context(); + let (token, _) = pair_device_with_scopes( + &context, + "full", + RemoteScopeSet::from_scopes([Scope::UiRead, Scope::UiOperate, Scope::UiAgent]), + ) + .await; + let app = crate::testing::create_mock_app(); + + // `list_projects` is ledgered Elevated and deliberately unregistered. + let response = router_with_real_registry(&context, app.handle().clone()) + .oneshot(invoke_request(&token, "list_projects", json!({}))) + .await + .expect("invoke request should complete"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!( + response_json(response).await["code"], + json!(ErrorCode::RemoteCommandUnavailable) + ); +} + +#[tokio::test] +async fn the_router_admits_a_registered_command_the_device_does_hold_scope_for() { + let context = in_memory_auth_context(); + let (token, _) = pair_device_with_scopes( + &context, + "reader", + RemoteScopeSet::from_scopes([Scope::UiRead]), + ) + .await; + let app = crate::testing::create_mock_app(); + + let response = router_with_real_registry(&context, app.handle().clone()) + .oneshot(invoke_request(&token, "health_check", json!({}))) + .await + .expect("invoke request should complete"); + + assert_eq!( + response.status(), + StatusCode::OK, + "the positive path must still work, or the negatives above prove nothing" + ); + assert_eq!( + response_json(response).await, + json!({"ok": true, "result": {"status": "ok"}}) + ); +} diff --git a/src-tauri/src/remote_server/registry.rs b/src-tauri/src/remote_server/registry.rs index 583051c78e..24b69049cd 100644 --- a/src-tauri/src/remote_server/registry.rs +++ b/src-tauri/src/remote_server/registry.rs @@ -80,9 +80,13 @@ impl RemoteInvokeError { } } + /// A registered command whose arguments would not deserialize. NOT + /// `RemoteCommandUnavailable`: that code is reserved for a `find_spec` miss and the client + /// treats it as "this host does not support the command at all", which is terminal and + /// about to gate remote affordances. fn bad_args(message: impl Into) -> Self { Self { - code: ErrorCode::RemoteCommandUnavailable, + code: ErrorCode::RemoteInvalidArguments, message: message.into(), } } @@ -142,8 +146,9 @@ pub fn extract_arg( /// Serialises a command's success value exactly as the Tauri IPC layer does. pub fn serialize_ok(value: T) -> Result { + // A host-side serialization fault, not a statement about the command's availability. serde_json::to_value(value).map_err(|error| RemoteInvokeError { - code: ErrorCode::RemoteCommandUnavailable, + code: ErrorCode::RemoteInternalError, message: format!("Response could not be serialized: {error}"), }) } @@ -440,9 +445,18 @@ pub fn find_spec(name: &str) -> Option<&'static RemoteCommandSpec> { /// /// `title` feeds the imperative `SCOPE: Execute ONLY work for: "{title}"` directive and the /// sibling dependency hints; `description` is the plan body. Writing either is deferred spawn -/// authority, so those requests demand `ui:agent`. `category`/`priority` are structurally inert -/// — the `WorkerTaskView` projection excludes them from every worker payload — and stay -/// `ui:operate`. `internal_status` never reaches here: `validate_update_task_input` rejects it. +/// authority, so those requests demand `ui:agent`. +/// +/// `category`/`priority` stay `ui:operate`, but NOT because no worker payload carries them — +/// that claim was false. The `WorkerTaskView` projection behind `get_task_context` and +/// `get_step_context` does exclude them, yet `/api/get_task_details` serialises both through +/// `task_to_response`. They are inert for a different and stronger reason: `category` is a +/// closed `TaskCategory` enum and `priority` is an `i32`, so neither can carry attacker-chosen +/// text into a prompt regardless of which projection renders it. `remote_server::registry_tests` +/// pins both halves — the `WorkerTaskView` exclusion and the `task_to_response` inclusion — +/// against poison sentinels. +/// +/// `internal_status` never reaches here: `validate_update_task_input` rejects it. pub const UPDATE_TASK_CONTENT_FIELDS: &[&str] = &["title", "description"]; /// The `update_task` field-level predicate (§3.3). diff --git a/src-tauri/src/remote_server/registry_tests.rs b/src-tauri/src/remote_server/registry_tests.rs index 64efc19fe4..1a007eab47 100644 --- a/src-tauri/src/remote_server/registry_tests.rs +++ b/src-tauri/src/remote_server/registry_tests.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use axum::extract::{Path, State}; use crate::application::AppState; -use crate::domain::entities::{ProjectId, Task, TaskStep}; +use crate::domain::entities::{ProjectId, Task, TaskCategory, TaskStep}; use crate::http_server::project_scope::ProjectScope; use crate::http_server::types::HttpServerState; @@ -87,3 +87,61 @@ async fn step_context_http_serializes_only_the_task_summary_allowlist() { assert!(!serialized.contains(BLOCKED_SENTINEL)); assert!(!serialized.contains(METADATA_SENTINEL)); } + +/// P-17h, the half the `update_task_authz` comment used to get wrong. +/// +/// `/api/get_task_details` does NOT go through `WorkerTaskView`; it serialises the task with +/// `task_to_response`, which includes `category` and `priority`. The invariant that keeps them +/// at `ui:operate` is therefore not "no worker payload carries them" but "neither can carry +/// attacker-chosen text": `category` is a closed enum and `priority` an `i32`. This pins both +/// the inclusion and the containment, so widening `TaskResponse` with a free-text field — or +/// leaking one of the fields the projection deliberately drops — fails here. +#[tokio::test] +async fn task_to_response_carries_no_free_text_outside_the_declared_contract() { + const BLOCKED_SENTINEL: &str = "P17H_RESPONSE_BLOCKED_REASON_POISON"; + const METADATA_SENTINEL: &str = "P17H_RESPONSE_RAW_METADATA_POISON"; + + let mut task = Task::new(ProjectId::new(), "Response projection".to_string()); + task.description = Some("Allowed description".to_string()); + task.priority = 1_337_019; + task.blocked_reason = Some(BLOCKED_SENTINEL.to_string()); + task.metadata = Some(format!(r#"{{"poison":"{METADATA_SENTINEL}"}}"#)); + + let payload = + serde_json::to_value(crate::http_server::handlers::task_to_response(&task)).unwrap(); + let serialized = serde_json::to_string(&payload).unwrap(); + let object = payload.as_object().expect("response is an object"); + + // The exact contract — a new field cannot appear without updating this gate. + assert_eq!( + object.keys().cloned().collect::>(), + vec![ + "category".to_string(), + "created_at".to_string(), + "description".to_string(), + "id".to_string(), + "priority".to_string(), + "status".to_string(), + "title".to_string(), + "updated_at".to_string(), + ] + ); + + // Fields the projection drops must not reappear anywhere in the payload. + assert!(!serialized.contains(BLOCKED_SENTINEL)); + assert!(!serialized.contains(METADATA_SENTINEL)); + for banned in ["blocked_reason", "metadata"] { + assert!(!object.contains_key(banned), "leaked banned field {banned}"); + } + + // `category`/`priority` ARE present — and are structurally incapable of free text. + assert!(object.contains_key("category") && object.contains_key("priority")); + assert_eq!(payload["priority"], serde_json::json!("1337019")); + let category = payload["category"].as_str().expect("category is a string"); + assert!( + [TaskCategory::Regular, TaskCategory::PlanMerge] + .iter() + .any(|known| known.to_string() == category), + "category `{category}` is outside the closed TaskCategory enum" + ); +} From 389ec72268795764bf872db38301bb536f5b0425 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:52:14 +0300 Subject: [PATCH 156/416] chore(remote): regenerate the manifest for the derived worker allowlist Same six fields as the previous literal, now emitted in the struct's serialized key order. --- docs/generated/remote-commands.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/generated/remote-commands.json b/docs/generated/remote-commands.json index b0bc325d98..e276a94e40 100644 --- a/docs/generated/remote-commands.json +++ b/docs/generated/remote-commands.json @@ -6938,11 +6938,11 @@ } ], "worker_task_view_allowlist": [ - "id", - "project_id", - "title", "description", + "id", + "ideation_session_id", "internal_status", - "ideation_session_id" + "project_id", + "title" ] } From e68f83e70753019dffb725a3f8b54dd671054919 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:14:08 +0300 Subject: [PATCH 157/416] chore(remote): absorb preview_remote_environment into the census gates The integration merge registered a 540th command; the detector-(b) calibration count and the generated manifest follow it. --- docs/generated/remote-commands.json | 10 ++++++++++ src-tauri/src/remote_server/capability_ledger_tests.rs | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/docs/generated/remote-commands.json b/docs/generated/remote-commands.json index e276a94e40..9f7fa51c69 100644 --- a/docs/generated/remote-commands.json +++ b/docs/generated/remote-commands.json @@ -1603,6 +1603,16 @@ "reason": "conservative-module-default: may steer or arm autonomous work", "registered": false }, + { + "capabilities": [ + "hostManagement" + ], + "class": "elevated", + "command": "preview_remote_environment", + "module": "remote_environment_commands", + "reason": "remote environment authority", + "registered": false + }, { "capabilities": [ "hostManagement" diff --git a/src-tauri/src/remote_server/capability_ledger_tests.rs b/src-tauri/src/remote_server/capability_ledger_tests.rs index 3738556afa..34151180a6 100644 --- a/src-tauri/src/remote_server/capability_ledger_tests.rs +++ b/src-tauri/src/remote_server/capability_ledger_tests.rs @@ -519,7 +519,7 @@ fn detector_b_is_calibrated_and_floor_enforced() { let rows = census(); assert_eq!( rows.len(), - 539, + 540, "review the detector against the full command census" ); let flagged = spawn_triggering_writers( From 768a8a0d22ff47e427a361df3d44f1924f721369 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:40:03 +0300 Subject: [PATCH 158/416] feat(remote): gate host-impossible affordances on environment kind (2.6-a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the active environment is remote, this device cannot pick a folder from the host's filesystem, attach to a host PTY, open a host file in a local editor, or resolve a host path into an asset:// URL. Those affordances now say so instead of failing on click. - New `useActiveEnvironment`/`useIsRemoteEnvironment` selectors are the one component-level answer to "is this remote?"; an unresolvable active id reads as remote so a hydration window cannot hand a remote session host-only controls. - Hidden under remote: project creation (top bar, welcome screen, agents sidebar, the wizard itself) and every terminal entry point. - Disabled with "Available on the host Mac": the workspace Open-in-editor and file-manager targets. - Chat file links degrade to a copy-path affordance; `openPath` and `revealItemInDir` never run for a host path. - Chat attachments render an on-host placeholder card with the filename, size and a copy-path action; `convertFileSrc` is not called. Real remote attachment rendering stays deferred to 3.1 (Fixed Decision 14). The updater needed no change: its two native events are in the local-only backend event mirror, so `NetworkEventBus.subscribe` already routes them to the local bus and relayed host frames cannot reach them. That pin is now covered by a negative test plus a mirror-membership assertion. Local behaviour is unchanged throughout — every case is tested in both columns, with the remote column asserting the host-only side effect does NOT fire. --- frontend/src/App.tsx | 16 +- .../components/Chat/MessageAttachments.tsx | 64 +++- .../components/Chat/MessageItem.markdown.tsx | 47 ++- .../UpdateChecker.remoteBus.test.tsx | 98 ++++++ .../WelcomeScreen/WelcomeScreen.tsx | 32 +- .../components/agents/AgentsChatHeader.tsx | 8 + .../src/components/agents/AgentsSidebar.tsx | 5 + .../agents/AgentsWorkspaceOpenControl.tsx | 59 +++- .../execution/ExecutionControlBar.tsx | 5 +- .../components/remote/HostPathCopyButton.tsx | 64 ++++ .../remote/host-affordance-gating.test.tsx | 310 ++++++++++++++++++ .../src/hooks/useActiveEnvironment.test.ts | 112 +++++++ frontend/src/hooks/useActiveEnvironment.ts | 66 ++++ frontend/src/lib/remote/host-affordances.ts | 35 ++ 14 files changed, 906 insertions(+), 15 deletions(-) create mode 100644 frontend/src/components/UpdateChecker.remoteBus.test.tsx create mode 100644 frontend/src/components/remote/HostPathCopyButton.tsx create mode 100644 frontend/src/components/remote/host-affordance-gating.test.tsx create mode 100644 frontend/src/hooks/useActiveEnvironment.test.ts create mode 100644 frontend/src/hooks/useActiveEnvironment.ts create mode 100644 frontend/src/lib/remote/host-affordances.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 110cc3a11f..949f16162d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -51,6 +51,7 @@ import { useMergePipeline } from "@/hooks/useMergePipeline"; import { useProjects, projectKeys } from "@/hooks/useProjects"; import { useAppKeyboardShortcuts } from "@/hooks/useAppKeyboardShortcuts"; import { useFeatureFlags, isViewEnabled } from "@/hooks/useFeatureFlags"; +import { useIsRemoteEnvironment } from "@/hooks/useActiveEnvironment"; import { useHarnessProviders } from "@/hooks/useHarnessProviders"; import { useTicketingCacheEvents } from "@/hooks/useTicketingEvents"; import { useAutomationEvents } from "@/hooks/useAutomations"; @@ -233,6 +234,15 @@ function AppContent({ backgroundSettled }: { backgroundSettled: boolean }) { const activeModal = useUiStore((s) => s.activeModal); const openModal = useUiStore((s) => s.openModal); const { data: featureFlags } = useFeatureFlags(); + /** + * Project creation is host-impossible from a remote client (2.6-a): the wizard's + * folder picker (`openDialog`) reads THIS device's filesystem, so a path it returns + * means nothing to the host. The affordances are hidden at their render sites + * rather than guarded inside the handlers — a button that opens a wizard whose only + * input cannot be supplied is not an honest control. + */ + const isRemoteEnvironment = useIsRemoteEnvironment(); + const canCreateProjects = !isRemoteEnvironment; // Redirect to the default project view in production when the current view is disabled. // Ticketing remains directly reachable when a provider enables the dashboard @@ -1034,7 +1044,7 @@ function AppContent({ backgroundSettled }: { backgroundSettled: boolean }) { attentionCountStale={attentionItems.isError} notificationsPanelOpen={notificationsPanelOpen} onToggleNotificationsPanel={toggleNotificationsPanel} - onNewProject={handleOpenProjectWizard} + {...(canCreateProjects ? { onNewProject: handleOpenProjectWizard } : {})} onProjectSwitchIntent={preserveCurrentViewOnNextProjectSwitch} showProjectSelector={ !hasNoProjects && !showWelcomeOverlay && !providerSetupRequired @@ -1207,7 +1217,8 @@ function AppContent({ backgroundSettled }: { backgroundSettled: boolean }) { )} - {/* Project Creation Wizard */} + {/* Project Creation Wizard — host-only (2.6-a) */} + {canCreateProjects && ( + )} {/* Settings Dialog - Modal overlay replacing routed settings view */} >(() => new Set()); const [selectedImageId, setSelectedImageId] = useState(null); + const isRemoteEnvironment = useIsRemoteEnvironment(); if (attachments.length === 0) { return null; @@ -115,7 +139,9 @@ export function MessageAttachments({ const attachmentEntries: AttachmentPreviewEntry[] = attachments.map((attachment) => ({ attachment, - previewSrc: failedPreviewIds.has(attachment.id) ? null : getImagePreviewSrc(attachment), + previewSrc: failedPreviewIds.has(attachment.id) + ? null + : getImagePreviewSrc(attachment, isRemoteEnvironment), })); const imageEntries = attachmentEntries.filter((entry) => entry.previewSrc !== null); const fileEntries = attachmentEntries.filter((entry) => entry.previewSrc === null); @@ -204,6 +230,7 @@ export function MessageAttachments({ key={attachment.id} attachment={attachment} onClick={onClick} + isRemoteEnvironment={isRemoteEnvironment} /> ))} @@ -259,15 +286,30 @@ export function MessageAttachments({ function AttachmentChip({ attachment, onClick, + isRemoteEnvironment = false, }: { attachment: MessageAttachment; onClick: ((id: string, filePath: string | undefined) => void) | undefined; + isRemoteEnvironment?: boolean; }) { + // The chip's click opens the file on this device; on a remote host there is + // nothing to open, so the card states where the file is and offers the path. + const hostPath = isRemoteEnvironment ? attachment.filePath : undefined; + return ( + + {hostPath ? ( + + ) : null} + ); } diff --git a/frontend/src/components/Chat/MessageItem.markdown.tsx b/frontend/src/components/Chat/MessageItem.markdown.tsx index cf86f285cb..2decc86e65 100644 --- a/frontend/src/components/Chat/MessageItem.markdown.tsx +++ b/frontend/src/components/Chat/MessageItem.markdown.tsx @@ -18,6 +18,9 @@ import { Terminal as TerminalIcon, } from "lucide-react"; import { openPath, revealItemInDir } from "@tauri-apps/plugin-opener"; + +import { HostPathCopyButton } from "@/components/remote/HostPathCopyButton"; +import { useIsRemoteEnvironment } from "@/hooks/useActiveEnvironment"; import { logger } from "@/lib/logger"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; @@ -215,6 +218,39 @@ async function openLocalFilePath(path: string): Promise { } } +/** + * A file reference the user can see named but cannot open from here (2.6-a). + * + * Rendered INSTEAD of both the workspace open-menu link and the plain `file://` + * anchor whenever the active environment is remote, which is what keeps `openPath` / + * `revealItemInDir` / `open_agent_conversation_workspace_path` off the click path + * entirely — the disabled-control variant would still leave a menu whose every item + * is dead. + */ +function RemoteHostFileLink({ + path, + children, +}: { + path: string; + children: React.ReactNode; +}) { + return ( + + + {children} + + + + ); +} + function WorkspaceMarkdownFileLink({ path, children, @@ -320,6 +356,7 @@ export function MarkdownLink({ ...props }: React.AnchorHTMLAttributes) { const localFilePath = parseLocalFileHref(href); + const isRemoteEnvironment = useIsRemoteEnvironment(); const fileLinkContext = useMessageFileLinkContext(); const useWorkspaceFileLink = Boolean( localFilePath && @@ -332,11 +369,19 @@ export function MarkdownLink({ (event: React.MouseEvent) => { if (!localFilePath) return; event.preventDefault(); + // Belt-and-braces: the remote branch below already returns before this handler + // can be bound to a rendered anchor, but `openLocalFilePath` opens a file on + // THIS device and must never fire for a host path. + if (isRemoteEnvironment) return; void openLocalFilePath(localFilePath); }, - [localFilePath], + [isRemoteEnvironment, localFilePath], ); + if (isRemoteEnvironment && localFilePath) { + return {children}; + } + if (useWorkspaceFileLink && localFilePath) { return ( diff --git a/frontend/src/components/UpdateChecker.remoteBus.test.tsx b/frontend/src/components/UpdateChecker.remoteBus.test.tsx new file mode 100644 index 0000000000..0da726fbe8 --- /dev/null +++ b/frontend/src/components/UpdateChecker.remoteBus.test.tsx @@ -0,0 +1,98 @@ +/** + * The updater updates THIS app. Its native events must never be steerable by a host + * (PR 2.6-a, Decision 4). + * + * `EventProvider` is env-keyed (`EventProvider.tsx:137-144`), so `useEventBus()` + * returns the ACTIVE environment's bus — under a remote environment that is a + * `NetworkEventBus` fed by relayed host frames. Left unpinned, a host could emit + * `ralphx://check-for-updates` and drive an update flow on the client. + * + * It is already pinned, and NOT by a second bus instance: `NetworkEventBus.subscribe` + * routes any name in `LOCAL_ONLY_BACKEND_EVENTS` to the wrapped LOCAL bus + * (`network-event-bus.ts:169-172`), while relayed frames dispatch to the remote + * registry. The updater's handler therefore lives somewhere relayed frames cannot + * reach. That is a structural pin, so the tests that protect it are (a) the negative + * behavioural test and (b) the membership assertion — because deleting the two names + * from the generated mirror is exactly how this silently breaks. + */ + +import { renderHook } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import { LOCAL_ONLY_BACKEND_EVENTS } from "@/lib/remote/local-only-backend-events.generated"; +import { NetworkEventBus } from "@/lib/remote/network-event-bus"; +import { useUpdateCheckerNativeEvents } from "@/components/UpdateChecker.events"; +import type { EventBus } from "@/lib/event-bus"; + +// `useEventBus` is stubbed rather than mounting `EventProvider`, whose +// `GlobalEventListeners` child drags in the whole query/store tree. What is under +// test is which BUS the hook subscribes through and where relayed frames land — the +// provider's job of choosing the active env's bus is asserted in its own suite. +const busRef: { current: EventBus | null } = { current: null }; +vi.mock("@/providers/EventProvider", () => ({ + useEventBus: () => busRef.current, +})); + +const UPDATE_CHECK_EVENT = "ralphx://check-for-updates"; +const RELEASE_NOTES_EVENT = "ralphx://show-release-notes"; + +describe("updater native events", () => { + it("keeps both updater events in the local-only backend mirror", () => { + // If either name leaves this list, `NetworkEventBus.subscribe` stops delegating + // it and the updater silently becomes host-drivable. + expect(LOCAL_ONLY_BACKEND_EVENTS).toContain(UPDATE_CHECK_EVENT); + expect(LOCAL_ONLY_BACKEND_EVENTS).toContain(RELEASE_NOTES_EVENT); + }); + + it("does not run an update check for an event relayed on a remote bus", () => { + const localHandlers = new Map void>>(); + const localBus: EventBus = { + subscribe: (event: string, handler: (payload: never) => void) => { + const set = localHandlers.get(event) ?? new Set(); + set.add(handler as (payload: unknown) => void); + localHandlers.set(event, set); + const unsubscribe = () => + set.delete(handler as (payload: unknown) => void); + unsubscribe.ready = Promise.resolve(); + return unsubscribe; + }, + emit: () => {}, + } as unknown as EventBus; + + const remoteBus = new NetworkEventBus({ + environmentId: "env-remote", + localBus, + sendFrame: async () => {}, + hydrate: async () => {}, + sweep: () => {}, + onRestartRequired: () => {}, + }); + + const checkForUpdates = vi.fn(); + const openCurrentReleaseNotes = vi.fn(); + + busRef.current = remoteBus as unknown as EventBus; + renderHook(() => + useUpdateCheckerNativeEvents({ + checkForUpdates, + openCurrentReleaseNotes, + }), + ); + + // A host relaying the event onto the remote environment's own registry. + // `emit` and applied stream frames share one dispatch path + // (`network-event-bus.ts` `dispatch`), so this is the frame's reach. + remoteBus.emit(UPDATE_CHECK_EVENT, {}); + remoteBus.emit(RELEASE_NOTES_EVENT, {}); + + expect(checkForUpdates).not.toHaveBeenCalled(); + expect(openCurrentReleaseNotes).not.toHaveBeenCalled(); + + // Positive control: the LOCAL backend emitting the same event still works, so + // the pin is a routing decision and not a dead subscription. + for (const handler of localHandlers.get(UPDATE_CHECK_EVENT) ?? []) { + handler({}); + } + expect(checkForUpdates).toHaveBeenCalledWith({ manual: true, force: true }); + }); +}); diff --git a/frontend/src/components/WelcomeScreen/WelcomeScreen.tsx b/frontend/src/components/WelcomeScreen/WelcomeScreen.tsx index 9a590c8941..ad22d9c34e 100644 --- a/frontend/src/components/WelcomeScreen/WelcomeScreen.tsx +++ b/frontend/src/components/WelcomeScreen/WelcomeScreen.tsx @@ -10,6 +10,8 @@ import { useEffect, useState } from "react"; import { CheckCircle2, Plug, Settings, Sparkles, X } from "lucide-react"; +import { useIsRemoteEnvironment } from "@/hooks/useActiveEnvironment"; + import AgentConstellation from "./AgentConstellation"; interface WelcomeScreenProps { @@ -33,6 +35,16 @@ export default function WelcomeScreen({ // Track idle state for keyboard hint pulse animation const [isIdle, setIsIdle] = useState(false); + /** + * Project creation is host-impossible from a remote client (2.6-a): the wizard's + * folder picker reads a filesystem this device cannot see. The empty state stays — + * a remote session with no visible projects still needs an explanation — but the + * CTA and its ⌘N shortcut are removed rather than left to fail on click. + */ + const isRemoteEnvironment = useIsRemoteEnvironment(); + const projectCreationBlocked = + isRemoteEnvironment && !providerSetupRequired && !hasProjects; + useEffect(() => { // Start idle pulse animation after 3 seconds const idleTimer = setTimeout(() => setIsIdle(true), 3000); @@ -55,12 +67,18 @@ export default function WelcomeScreen({ onSetupProviders?.(); return; } + if (projectCreationBlocked) return; onCreateProject(); } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [onCreateProject, onSetupProviders, providerSetupRequired]); + }, [ + onCreateProject, + onSetupProviders, + projectCreationBlocked, + providerSetupRequired, + ]); const action = providerSetupRequired ? onSetupProviders @@ -279,6 +297,15 @@ export default function WelcomeScreen({ }} > {/* Primary CTA button with glow */} + {projectCreationBlocked ? ( +

+ Projects are created on the host Mac. Switch to This Mac to add one. +

+ ) : ( + )} {/* Keyboard shortcut hint with idle pulse */} - {!providerSetupRequired && !hasProjects && ( + {!providerSetupRequired && !hasProjects && !projectCreationBlocked && (

)} + {/* The built-in terminal is module-excluded from v1 remoting (2.6-a): it + drives a PTY on the machine running the app, so a remote client has + nothing to attach to. Hidden rather than disabled — there is no host + action that would turn it on. */} + {!isRemoteEnvironment && ( + + + + {isRemoteEnvironment + ? HOST_ONLY_AFFORDANCE_HINT + : `Open workspace in ${displayedTarget.label}`} + + @@ -160,7 +193,7 @@ export const AgentsWorkspaceOpenControl = memo(function AgentsWorkspaceOpenContr - {builtInTerminal ? ( + {builtInTerminal && !isRemoteEnvironment ? ( <> openTarget(target)} + disabled={isRemoteEnvironment} + onClick={isRemoteEnvironment ? undefined : () => openTarget(target)} + aria-label={ + isRemoteEnvironment + ? `${target.label} — ${HOST_ONLY_AFFORDANCE_HINT}` + : target.label + } > {target.label} - {selected ? : null} + {isRemoteEnvironment ? ( + + {HOST_ONLY_AFFORDANCE_HINT} + + ) : null} + {selected && !isRemoteEnvironment ? ( + + ) : null} ); })} diff --git a/frontend/src/components/execution/ExecutionControlBar.tsx b/frontend/src/components/execution/ExecutionControlBar.tsx index e62df80a3e..0622227c0e 100644 --- a/frontend/src/components/execution/ExecutionControlBar.tsx +++ b/frontend/src/components/execution/ExecutionControlBar.tsx @@ -19,6 +19,7 @@ import { Terminal as TerminalIcon, } from "lucide-react"; import { useState, useEffect, useMemo } from "react"; +import { useIsRemoteEnvironment } from "@/hooks/useActiveEnvironment"; import { Button } from "@/components/ui/button"; import { Tooltip, @@ -193,6 +194,7 @@ export function ExecutionControlBar({ onNavigateToWorkspace, onNavigateToTask, }: ExecutionControlBarProps) { + const isRemoteEnvironment = useIsRemoteEnvironment(); const laneByName = new Map(lanes.map((lane) => [lane.lane, lane])); const workspaceLane = laneByName.get("workspaces"); const taskLane = laneByName.get("tasks"); @@ -639,7 +641,8 @@ export function ExecutionControlBar({ )} - {terminalCount > 0 && ( + {/* Terminal sessions are host-local (2.6-a) — no remote entry point. */} + {terminalCount > 0 && !isRemoteEnvironment && ( <> => { + try { + if (!navigator.clipboard) { + throw new Error("clipboard unavailable"); + } + await navigator.clipboard.writeText(path); + toast.success("Path copied"); + } catch { + toast.error("Failed to copy path"); + } + }; + + return ( + + + + + + {HOST_PATH_COPY_HINT} + + + ); +} diff --git a/frontend/src/components/remote/host-affordance-gating.test.tsx b/frontend/src/components/remote/host-affordance-gating.test.tsx new file mode 100644 index 0000000000..20b847ad9b --- /dev/null +++ b/frontend/src/components/remote/host-affordance-gating.test.tsx @@ -0,0 +1,310 @@ +/** + * The 2.6-a matrix: host-impossible affordances, keyed on environment kind ONLY. + * + * Every case runs both columns. The LOCAL column is the non-regression half — it is + * what proves the gating code is inert when the flag is off / the environment is + * local, which is the whole dark-ship promise. The REMOTE column's load-bearing + * assertions are the ABSENCE ones: `openPath` was not called, `convertFileSrc` was + * not called, the control is not in the document. A test that only checked "a hint + * appeared" would pass against a UI that still fires the host-only side effect. + */ + +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + LOCAL_ENVIRONMENT_ID, + useEnvironmentStore, +} from "@/stores/environmentStore"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { + HOST_ATTACHMENT_HINT, + HOST_ONLY_AFFORDANCE_HINT, +} from "@/lib/remote/host-affordances"; + +const openPathMock = vi.fn(async () => {}); +const revealItemInDirMock = vi.fn(async () => {}); +const convertFileSrcMock = vi.fn((path: string) => `asset://${path}`); + +vi.mock("@tauri-apps/plugin-opener", () => ({ + openPath: (path: string) => openPathMock(path), + revealItemInDir: (path: string) => revealItemInDirMock(path), +})); + +vi.mock("@tauri-apps/api/core", async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + convertFileSrc: (path: string) => convertFileSrcMock(path), + }; +}); + +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +const REMOTE_ID = "env-remote"; + +function setEnvironment(kind: "local" | "remote"): void { + useEnvironmentStore.setState({ + activeEnvironmentId: kind === "local" ? LOCAL_ENVIRONMENT_ID : REMOTE_ID, + environments: [ + { id: LOCAL_ENVIRONMENT_ID, name: "This Mac", kind: "local" }, + { id: REMOTE_ID, name: "Studio Mac", kind: "remote" }, + ], + }); +} + +function withTooltips(node: React.ReactNode) { + return {node}; +} + +beforeEach(() => { + vi.clearAllMocks(); + setEnvironment("local"); +}); + +// --------------------------------------------------------------------------- +// Chat file links — openPath / revealItemInDir must never fire for a host path +// --------------------------------------------------------------------------- + +describe("chat markdown file links", () => { + async function renderLink() { + const { MarkdownLink } = + await import("@/components/Chat/MessageItem.markdown"); + return render( + withTooltips( + + main.rs + , + ), + ); + } + + it("local: keeps the clickable local-file link and opens it", async () => { + await renderLink(); + const link = screen.getByText("main.rs"); + expect(screen.queryByTestId("chat-remote-host-file-link")).toBeNull(); + + link.click(); + expect(openPathMock).toHaveBeenCalledWith( + "/Users/host/project/src/main.rs", + ); + }); + + it("remote: renders a copy-path affordance and never opens the path", async () => { + setEnvironment("remote"); + await renderLink(); + + expect( + screen.getByTestId("chat-remote-host-file-link"), + ).toBeInTheDocument(); + expect(screen.getByTestId("chat-remote-host-file-copy")).toHaveAttribute( + "aria-label", + ); + + screen.getByTestId("chat-remote-host-file-link").click(); + screen.getByText("main.rs").click(); + + expect(openPathMock).not.toHaveBeenCalled(); + expect(revealItemInDirMock).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Chat attachments — convertFileSrc mints an asset:// URL for THIS device only +// --------------------------------------------------------------------------- + +describe("chat attachments", () => { + const imageAttachment = { + id: "att-1", + fileName: "screenshot.png", + fileSize: 2048, + mimeType: "image/png", + filePath: "/Users/host/Desktop/screenshot.png", + }; + + async function renderAttachments() { + const { MessageAttachments } = + await import("@/components/Chat/MessageAttachments"); + return render( + withTooltips( + , + ), + ); + } + + it("local: renders the image preview through convertFileSrc", async () => { + await renderAttachments(); + expect(convertFileSrcMock).toHaveBeenCalledWith( + "/Users/host/Desktop/screenshot.png", + ); + expect(screen.getByTestId("attachment-image-preview")).toBeInTheDocument(); + expect(screen.queryByTestId("attachment-host-card")).toBeNull(); + }); + + it("remote: renders the on-host placeholder and never calls convertFileSrc", async () => { + setEnvironment("remote"); + await renderAttachments(); + + expect(convertFileSrcMock).not.toHaveBeenCalled(); + expect(screen.queryByTestId("attachment-image-preview")).toBeNull(); + expect(screen.getByTestId("attachment-host-card")).toBeInTheDocument(); + expect(screen.getByTestId("attachment-host-hint")).toHaveTextContent( + HOST_ATTACHMENT_HINT, + ); + expect(screen.getByText("screenshot.png")).toBeInTheDocument(); + expect(screen.getByTestId("attachment-chip")).toBeDisabled(); + expect(screen.getByTestId("attachment-host-copy")).toBeInTheDocument(); + }); +}); + +// --------------------------------------------------------------------------- +// Workspace Open menu — editor / file-manager / built-in terminal +// --------------------------------------------------------------------------- + +describe("workspace open control", () => { + const targets = [ + { id: "vscode", label: "VS Code", kind: "editor" as const }, + { id: "finder", label: "Finder", kind: "fileManager" as const }, + ]; + + async function renderControl() { + const { AgentsWorkspaceOpenControl } = + await import("@/components/agents/AgentsWorkspaceOpenControl"); + const onOpenTarget = vi.fn(); + render( + withTooltips( + , + ), + ); + return onOpenTarget; + } + + it("local: the primary open control is enabled and dispatches", async () => { + const onOpenTarget = await renderControl(); + const primary = screen.getByTestId("agents-open-workspace"); + expect(primary).toBeEnabled(); + + primary.click(); + expect(onOpenTarget).toHaveBeenCalled(); + }); + + it("remote: the open control is disabled, explained, and does not dispatch", async () => { + setEnvironment("remote"); + const onOpenTarget = await renderControl(); + + const primary = screen.getByTestId("agents-open-workspace"); + expect(primary).toBeDisabled(); + expect(primary.getAttribute("aria-label")).toContain( + HOST_ONLY_AFFORDANCE_HINT, + ); + + primary.click(); + expect(onOpenTarget).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Welcome screen — project creation is host-only +// --------------------------------------------------------------------------- + +describe("welcome screen project creation", () => { + async function renderWelcome() { + const WelcomeScreen = ( + await import("@/components/WelcomeScreen/WelcomeScreen") + ).default; + const onCreateProject = vi.fn(); + render( + withTooltips( + , + ), + ); + return onCreateProject; + } + + it("local: offers the create-project CTA", async () => { + await renderWelcome(); + expect( + screen.getByTestId("create-first-project-button"), + ).toBeInTheDocument(); + expect(screen.queryByTestId("welcome-remote-no-create")).toBeNull(); + }); + + it("remote: hides the CTA and explains where projects are created", async () => { + setEnvironment("remote"); + const onCreateProject = await renderWelcome(); + + expect(screen.queryByTestId("create-first-project-button")).toBeNull(); + expect(screen.getByTestId("welcome-remote-no-create")).toBeInTheDocument(); + + // The ⌘N shortcut is part of the same affordance and must be gated with it. + window.dispatchEvent( + new KeyboardEvent("keydown", { key: "n", metaKey: true, bubbles: true }), + ); + expect(onCreateProject).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// Hide-site guard for the surfaces whose hosts are too heavy to mount here +// --------------------------------------------------------------------------- + +/** + * `App.tsx`, `AgentsChatHeader`, `ExecutionControlBar` and `AgentsSidebar` each hide + * a host-only affordance behind the env-kind hook. Mounting them means standing up + * the whole app tree, so the guard is structural instead: it asserts the gate is + * still WIRED at each site. It cannot prove the rendered outcome — that is what the + * behavioural cases above do for the surfaces that can be mounted — but it does + * catch the realistic regression, which is a later refactor dropping the condition. + */ +describe("host-only hide sites stay gated", () => { + const cases: ReadonlyArray<{ + readonly file: string; + readonly needles: readonly string[]; + }> = [ + { + file: "src/App.tsx", + needles: [ + "useIsRemoteEnvironment", + "const canCreateProjects = !isRemoteEnvironment", + "{canCreateProjects && (", + "canCreateProjects ? { onNewProject: handleOpenProjectWizard } : {}", + ], + }, + { + file: "src/components/agents/AgentsChatHeader.tsx", + needles: ["useIsRemoteEnvironment", "{!isRemoteEnvironment && ("], + }, + { + file: "src/components/execution/ExecutionControlBar.tsx", + needles: [ + "useIsRemoteEnvironment", + "terminalCount > 0 && !isRemoteEnvironment", + ], + }, + { + file: "src/components/agents/AgentsSidebar.tsx", + needles: ["useIsRemoteEnvironment", "{!isRemoteEnvironment && ("], + }, + ]; + + it.each(cases)("$file keeps its env-kind gate", async ({ file, needles }) => { + const { readFileSync } = await import("node:fs"); + const { resolve } = await import("node:path"); + const source = readFileSync(resolve(__dirname, "../../..", file), "utf8"); + for (const needle of needles) { + expect(source).toContain(needle); + } + }); +}); diff --git a/frontend/src/hooks/useActiveEnvironment.test.ts b/frontend/src/hooks/useActiveEnvironment.test.ts new file mode 100644 index 0000000000..4bd10d24ec --- /dev/null +++ b/frontend/src/hooks/useActiveEnvironment.test.ts @@ -0,0 +1,112 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { + LOCAL_ENVIRONMENT_ID, + useEnvironmentStore, +} from "@/stores/environmentStore"; + +import { + useActiveEnvironment, + useActiveEnvironmentKind, + useIsRemoteEnvironment, +} from "./useActiveEnvironment"; + +const REMOTE_SUMMARY = { + id: "env-remote", + name: "Studio Mac", + status: "paired", + scopes: ["ui:read", "ui:operate"], +} as unknown as NonNullable< + ReturnType< + typeof useEnvironmentStore.getState + >["environments"][number]["remote"] +>; + +function seedRemote(): void { + useEnvironmentStore.setState({ + environments: [ + { id: LOCAL_ENVIRONMENT_ID, name: "This Mac", kind: "local" }, + { + id: "env-remote", + name: "Studio Mac", + kind: "remote", + remote: REMOTE_SUMMARY, + }, + ], + }); +} + +describe("useActiveEnvironment", () => { + beforeEach(() => { + useEnvironmentStore.setState({ + activeEnvironmentId: LOCAL_ENVIRONMENT_ID, + environments: [ + { id: LOCAL_ENVIRONMENT_ID, name: "This Mac", kind: "local" }, + ], + }); + }); + + it("returns the local entry by default", () => { + const { result } = renderHook(() => useActiveEnvironment()); + expect(result.current.id).toBe(LOCAL_ENVIRONMENT_ID); + expect(result.current.kind).toBe("local"); + }); + + it("reports local kind and not-remote by default", () => { + expect(renderHook(() => useActiveEnvironmentKind()).result.current).toBe( + "local", + ); + expect(renderHook(() => useIsRemoteEnvironment()).result.current).toBe( + false, + ); + }); + + it("follows a switch to a remote environment", () => { + seedRemote(); + const { result } = renderHook(() => useIsRemoteEnvironment()); + expect(result.current).toBe(false); + + act(() => { + useEnvironmentStore.setState({ activeEnvironmentId: "env-remote" }); + }); + + expect(result.current).toBe(true); + }); + + it("treats an unknown active id as remote (fail closed)", () => { + // The registry can lag the Rust authority during hydration. Presuming "local" + // for an id we cannot resolve would hand a remote session every host-only + // affordance; presuming "remote" only costs a hidden button. + act(() => { + useEnvironmentStore.setState({ + activeEnvironmentId: "env-not-in-registry", + }); + }); + expect(renderHook(() => useIsRemoteEnvironment()).result.current).toBe( + true, + ); + expect(renderHook(() => useActiveEnvironmentKind()).result.current).toBe( + "remote", + ); + expect(renderHook(() => useActiveEnvironment()).result.current).toBeNull(); + }); + + it("keeps a stable reference across unrelated store churn", () => { + seedRemote(); + act(() => { + useEnvironmentStore.setState({ activeEnvironmentId: "env-remote" }); + }); + const { result, rerender } = renderHook(() => useActiveEnvironment()); + const first = result.current; + + act(() => { + useEnvironmentStore + .getState() + .setConnectionState("env-remote", "backoff"); + }); + rerender(); + + expect(result.current).toBe(first); + }); +}); diff --git a/frontend/src/hooks/useActiveEnvironment.ts b/frontend/src/hooks/useActiveEnvironment.ts new file mode 100644 index 0000000000..0ede5233f5 --- /dev/null +++ b/frontend/src/hooks/useActiveEnvironment.ts @@ -0,0 +1,66 @@ +/** + * The ONE component-level view of which environment is active (PR 2.6, A1). + * + * Every capability gate in the app keys on these selectors. They are synchronous + * zustand reads — a gate must never make a click path wait on a fetch (rule 24) — + * and they are the only sanctioned component-level answer to "is this a remote + * environment?". Components must NOT import `isRemoteEnvironmentId` from + * `@/lib/remote/active-environment`: that helper answers the same question for the + * TRANSPORT, whose notion of "active" is mirrored asynchronously from this store and + * is deliberately allowed to lead it during a switch. + * + * Fail-closed resolution: an `activeEnvironmentId` that is not in `environments` + * resolves to kind `"remote"`, not `"local"`. That state is reachable while startup + * hydration adopts the Rust-authoritative id before the registry list arrives + * (`environmentStore.hydrateActiveEnvironment`). Guessing "local" there would hand a + * genuinely remote session the full set of host-only affordances for the length of + * that window; guessing "remote" only hides a button that reappears a tick later. + */ + +import { useEnvironmentStore } from "@/stores/environmentStore"; +import type { EnvironmentEntry } from "@/stores/environmentStore"; + +/** + * The active environment's registry entry, or `null` when the active id is not (yet) + * resolvable. `null` is NOT "local" — see `useActiveEnvironmentKind`. + */ +export function useActiveEnvironment(): EnvironmentEntry | null { + return useEnvironmentStore( + (state) => + state.environments.find( + (entry) => entry.id === state.activeEnvironmentId, + ) ?? null, + ); +} + +/** The active environment's kind; unresolvable ids read as `"remote"` (fail closed). */ +export function useActiveEnvironmentKind(): EnvironmentEntry["kind"] { + return useEnvironmentStore((state) => { + const entry = state.environments.find( + (candidate) => candidate.id === state.activeEnvironmentId, + ); + return entry?.kind ?? "remote"; + }); +} + +/** + * `true` when this client is driving a remote host. The gate for every host-impossible + * affordance (2.6-a); permission-shaped gates additionally consult `useAgentGate`. + */ +export function useIsRemoteEnvironment(): boolean { + return useEnvironmentStore((state) => { + const entry = state.environments.find( + (candidate) => candidate.id === state.activeEnvironmentId, + ); + return (entry?.kind ?? "remote") !== "local"; + }); +} + +/** Non-reactive read for callbacks and module-level code paths. */ +export function isRemoteEnvironmentActive(): boolean { + const state = useEnvironmentStore.getState(); + const entry = state.environments.find( + (candidate) => candidate.id === state.activeEnvironmentId, + ); + return (entry?.kind ?? "remote") !== "local"; +} diff --git a/frontend/src/lib/remote/host-affordances.ts b/frontend/src/lib/remote/host-affordances.ts new file mode 100644 index 0000000000..c693576049 --- /dev/null +++ b/frontend/src/lib/remote/host-affordances.ts @@ -0,0 +1,35 @@ +/** + * Copy and predicates for HOST-IMPOSSIBLE affordances (PR 2.6-a). + * + * These gates read NOTHING but the active environment's kind. They are not + * permission checks: no scope, no manifest, and no host round-trip can make a + * remote client open a Finder window on the host Mac or pick a folder from a + * filesystem it cannot see. Granting `ui:agent` changes none of them. + * + * The hide-vs-disable split is deliberate and load-bearing: + * + * - HIDE when the affordance's entire purpose is host-local and there is nothing + * honest to say about it in a remote session (project creation, the folder + * picker, terminal entry points). A disabled control with a tooltip nobody can + * act on is UI debt, not honesty. + * - DISABLE + explain when the affordance names a real thing the user can still + * reason about — a file that exists, an editor that would open it — but only on + * the other machine. Here the tooltip IS the information. + * + * What is never acceptable: leaving a control that looks live and throws. The + * transport already rejects these commands with `REMOTE_COMMAND_UNAVAILABLE` + * (`local-only-commands.ts`, `reject` disposition); 2.6-a's job is to make sure a + * user never reaches that rejection by clicking something that looked enabled. + */ + +/** Disabled-control copy for editor / file-manager / reveal affordances. */ +export const HOST_ONLY_AFFORDANCE_HINT = "Available on the host Mac"; + +/** Tooltip on the copy action that replaces a clickable local file link. */ +export const HOST_PATH_COPY_HINT = "Copy path — file is on the host"; + +/** Placeholder line on a remote chat attachment card. */ +export const HOST_ATTACHMENT_HINT = "Stored on the host"; + +/** Accessible label for the copy-path action on a remote file reference. */ +export const HOST_PATH_COPY_LABEL = "Copy host file path"; From 4388a5ca22903c849489665b28745744ba4b8c86 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:11:23 +0300 Subject: [PATCH 159/416] feat: register the remote facade mutating surface (PR 1.5-A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registers the mutating command surface on the `:3849` invoke facade along the adopted "viewer with brakes" boundary, plus the machinery that keeps that boundary honest. `ui:operate` (the default pairing) gets watch, brakes and inert edits only: pause/block/stop tasks, pause a group, deny a permission request, edit task category/priority, and create Backlog-only tasks. Everything that can start, resume, restart or steer an agent requires the off-by-default `ui:agent` grant. Nothing here is reachable with `remote_host` disabled. Machinery: * `(host_app_handle)` injection arm. The 1.3 lane recorded "AppHandle commands are unregistrable" as a blocker and proposed monomorphising dispatch on Wry. That is not usable here: building a Wry `AppHandle` in a test panics with "EventLoop must be created on the main thread" (the standing listener_tests failure, which reproduces under nextest), so monomorphising would delete the facade's only authorization coverage. Instead dispatch stays generic and the concrete handle is resolved from `AppState::app_handle` — the same resolution `:3847` already uses — failing closed with `REMOTE_INTERNAL_ERROR` when the host has not populated it. * Pinned facade ops. No approve/deny permission commands exist; only the dual-decision `resolve_permission_request`. It is split into two ops whose `decision` is server-pinned, so a client sending "allow" to `deny_permission_request` still denies. The pin declaration is the only source of the value: dispatch binds `spec.pins` directly. * Conditional-capability annotation. `class_permits(Operate, [..])` is a compile error, so `update_task`'s content-write capability lives in the ledger and a CI guard makes the annotation and the `update_task_authz` predicate inseparable in both directions. Tests: the P-17b negative suite is GENERATED from the checked-in audit manifest (agent_control_floor union declared_memberships), never from a hand list — registered members must refuse a default pairing with `REMOTE_FORBIDDEN` and write nothing, unregistered floor members must answer `REMOTE_COMMAND_UNAVAILABLE` for every scope. P-17f proves a stripped declared membership shrinks the suite AND fails its guard. Detector (c) rejected three of the intended registrations — `resume_task`, `apply_proposals_to_kanban` and `set_agent_conversation_workspace_auto_publish` reach a process-launch sink, so they carry `SpawnsProcess` authority and stay unregistered. The gate now reports all offenders at once instead of failing on the first. `:3847`/`:3848` are byte-identical (P-16). No frontend changes. --- docs/generated/remote-commands.json | 326 +++++++++-- .../src/remote_server/capability_ledger.rs | 55 ++ .../remote_server/capability_ledger_tests.rs | 228 +++++++- src-tauri/src/remote_server/invoke_tests.rs | 1 + src-tauri/src/remote_server/mod.rs | 5 + src-tauri/src/remote_server/registry.rs | 445 ++++++++++++++- .../src/remote_server/scope_suite_tests.rs | 506 ++++++++++++++++++ 7 files changed, 1499 insertions(+), 67 deletions(-) create mode 100644 src-tauri/src/remote_server/scope_suite_tests.rs diff --git a/docs/generated/remote-commands.json b/docs/generated/remote-commands.json index 9f7fa51c69..b4901bbf2e 100644 --- a/docs/generated/remote-commands.json +++ b/docs/generated/remote-commands.json @@ -1429,6 +1429,13 @@ "kind": "async_runtime::spawn" } ], + "conditional_capabilities": [ + { + "capability": "mutatesAgentConsumedContent", + "command": "update_task", + "condition": "conditional: title,description — discharged by update_task_authz" + } + ], "coverage": { "agentConsumedContent": "complete", "detectorA": "complete", @@ -1444,6 +1451,267 @@ "reason": "steering-question" } ], + "facade_ops": [ + { + "argumentSensitive": false, + "capabilities": [], + "class": "read", + "command": "health_check", + "pins": [], + "target": "crate::commands::health::health_check" + }, + { + "argumentSensitive": false, + "capabilities": [], + "class": "read", + "command": "list_tasks", + "pins": [], + "target": "crate::commands::task_commands::query::list_tasks" + }, + { + "argumentSensitive": false, + "capabilities": [], + "class": "read", + "command": "get_task", + "pins": [], + "target": "crate::commands::task_commands::query::get_task" + }, + { + "argumentSensitive": false, + "capabilities": [], + "class": "read", + "command": "search_tasks", + "pins": [], + "target": "crate::commands::task_commands::query::search_tasks" + }, + { + "argumentSensitive": false, + "capabilities": [], + "class": "read", + "command": "get_valid_transitions", + "pins": [], + "target": "crate::commands::task_commands::query::get_valid_transitions" + }, + { + "argumentSensitive": true, + "capabilities": [], + "class": "operate", + "command": "update_task", + "pins": [], + "target": "crate::commands::task_commands::mutation::update_task" + }, + { + "argumentSensitive": false, + "capabilities": [], + "class": "operate", + "command": "create_task", + "pins": [], + "target": "crate::commands::task_commands::mutation::create_task" + }, + { + "argumentSensitive": false, + "capabilities": [], + "class": "operate", + "command": "pause_task", + "pins": [], + "target": "crate::commands::task_commands::mutation::pause_task" + }, + { + "argumentSensitive": false, + "capabilities": [], + "class": "operate", + "command": "block_task", + "pins": [], + "target": "crate::commands::task_commands::mutation::block_task" + }, + { + "argumentSensitive": false, + "capabilities": [], + "class": "operate", + "command": "stop_task", + "pins": [], + "target": "crate::commands::task_commands::mutation::stop_task" + }, + { + "argumentSensitive": false, + "capabilities": [], + "class": "operate", + "command": "pause_tasks_in_group", + "pins": [], + "target": "crate::commands::task_commands::mutation::pause_tasks_in_group" + }, + { + "argumentSensitive": false, + "capabilities": [], + "class": "operate", + "command": "deny_permission_request", + "pins": [ + { + "field": "decision", + "param": "args", + "value": "deny" + } + ], + "target": "crate::commands::permission_commands::resolve_permission_request" + }, + { + "argumentSensitive": false, + "capabilities": [ + "agentControl", + "mutatesAgentConsumedContent" + ], + "class": "agentControl", + "command": "move_task", + "pins": [], + "target": "crate::commands::task_commands::mutation::move_task" + }, + { + "argumentSensitive": false, + "capabilities": [ + "agentControl" + ], + "class": "agentControl", + "command": "unblock_task", + "pins": [], + "target": "crate::commands::task_commands::mutation::unblock_task" + }, + { + "argumentSensitive": false, + "capabilities": [ + "agentControl" + ], + "class": "agentControl", + "command": "answer_user_question", + "pins": [], + "target": "crate::commands::task_commands::mutation::answer_user_question" + }, + { + "argumentSensitive": false, + "capabilities": [ + "mutatesAgentConsumedContent" + ], + "class": "agentControl", + "command": "approve_task_for_review", + "pins": [], + "target": "crate::commands::review_commands::approve_task_for_review" + }, + { + "argumentSensitive": false, + "capabilities": [ + "agentControl" + ], + "class": "agentControl", + "command": "reanalyze_project", + "pins": [], + "target": "crate::commands::project_commands::reanalyze_project" + }, + { + "argumentSensitive": false, + "capabilities": [ + "seedsSpawnTriggeringState" + ], + "class": "agentControl", + "command": "inject_task", + "pins": [], + "target": "crate::commands::task_commands::mutation::inject_task" + }, + { + "argumentSensitive": false, + "capabilities": [ + "seedsSpawnTriggeringState" + ], + "class": "agentControl", + "command": "resume_automation", + "pins": [], + "target": "crate::commands::automation_commands::resume_automation" + }, + { + "argumentSensitive": false, + "capabilities": [ + "seedsSpawnTriggeringState" + ], + "class": "agentControl", + "command": "finalize_automation", + "pins": [], + "target": "crate::commands::automation_commands::finalize_automation" + }, + { + "argumentSensitive": false, + "capabilities": [ + "mutatesAgentConsumedContent" + ], + "class": "agentControl", + "command": "create_task_step", + "pins": [], + "target": "crate::commands::task_step_commands::create_task_step" + }, + { + "argumentSensitive": false, + "capabilities": [ + "mutatesAgentConsumedContent" + ], + "class": "agentControl", + "command": "update_task_step", + "pins": [], + "target": "crate::commands::task_step_commands::update_task_step" + }, + { + "argumentSensitive": false, + "capabilities": [ + "mutatesAgentConsumedContent" + ], + "class": "agentControl", + "command": "create_artifact", + "pins": [], + "target": "crate::commands::artifact_commands::create_artifact" + }, + { + "argumentSensitive": false, + "capabilities": [ + "mutatesAgentConsumedContent" + ], + "class": "agentControl", + "command": "update_artifact", + "pins": [], + "target": "crate::commands::artifact_commands::update_artifact" + }, + { + "argumentSensitive": false, + "capabilities": [ + "mutatesAgentConsumedContent" + ], + "class": "agentControl", + "command": "add_artifact_relation", + "pins": [], + "target": "crate::commands::artifact_commands::add_artifact_relation" + }, + { + "argumentSensitive": false, + "capabilities": [ + "mutatesAgentConsumedContent" + ], + "class": "agentControl", + "command": "update_task_proposal", + "pins": [], + "target": "crate::commands::ideation_commands::update_task_proposal" + }, + { + "argumentSensitive": false, + "capabilities": [ + "agentControl" + ], + "class": "agentControl", + "command": "approve_permission_request", + "pins": [ + { + "field": "decision", + "param": "args", + "value": "allow" + } + ], + "target": "crate::commands::permission_commands::resolve_permission_request" + } + ], "ledger": [ { "capabilities": [ @@ -2139,7 +2407,7 @@ "command": "resume_automation", "module": "automation_commands", "reason": "detector-b: restores Active automation consumed by the automation scheduler", - "registered": false + "registered": true }, { "capabilities": [ @@ -2149,7 +2417,7 @@ "command": "finalize_automation", "module": "automation_commands", "reason": "detector-b: completes automation arming state consumed by the automation scheduler", - "registered": false + "registered": true }, { "capabilities": [ @@ -2438,24 +2706,20 @@ "registered": true }, { - "capabilities": [ - "agentControl" - ], - "class": "agentControl", + "capabilities": [], + "class": "operate", "command": "create_task", "module": "task_commands", - "reason": "conservative-module-default: may steer or arm autonomous work", - "registered": false + "reason": "Backlog-only by construction: CreateTaskInput carries no status field and Task::new_with_category sets InternalStatus::Backlog, so a created task cannot be born in a spawn-triggering state", + "registered": true }, { - "capabilities": [ - "agentControl" - ], - "class": "agentControl", + "capabilities": [], + "class": "operate", "command": "update_task", "module": "task_commands", - "reason": "conservative-module-default: may steer or arm autonomous work", - "registered": false + "reason": "inert fields only at this class: category is a closed enum and priority an i32; title/description carry a conditional MutatesAgentConsumedContent discharged by update_task_authz, and internal_status is rejected by validate_update_task_input", + "registered": true }, { "capabilities": [ @@ -2465,7 +2729,7 @@ "command": "answer_user_question", "module": "task_commands", "reason": "conservative-module-default: may steer or arm autonomous work", - "registered": false + "registered": true }, { "capabilities": [ @@ -2475,7 +2739,7 @@ "command": "inject_task", "module": "task_commands", "reason": "detector-b: seeds internal_status=Ready consumed by the ready-task scheduler", - "registered": false + "registered": true }, { "capabilities": [ @@ -2486,7 +2750,7 @@ "command": "move_task", "module": "task_commands", "reason": "detector-a plus content-surface: restart note is worker-consumed", - "registered": false + "registered": true }, { "capabilities": [ @@ -2514,7 +2778,7 @@ "command": "block_task", "module": "task_commands", "reason": "authority-reducing: transitions only to Blocked", - "registered": false + "registered": true }, { "capabilities": [ @@ -2524,7 +2788,7 @@ "command": "unblock_task", "module": "task_commands", "reason": "authority-restoring transition", - "registered": false + "registered": true }, { "capabilities": [ @@ -2562,7 +2826,7 @@ "command": "pause_tasks_in_group", "module": "task_commands", "reason": "authority-reducing: transitions only to Paused", - "registered": false + "registered": true }, { "capabilities": [ @@ -2620,7 +2884,7 @@ "command": "pause_task", "module": "task_commands", "reason": "authority-reducing: transitions only to Paused", - "registered": false + "registered": true }, { "capabilities": [ @@ -2648,7 +2912,7 @@ "command": "stop_task", "module": "task_commands", "reason": "authority-reducing: transitions only to Stopped", - "registered": false + "registered": true }, { "capabilities": [ @@ -2734,7 +2998,7 @@ "command": "create_task_step", "module": "task_step_commands", "reason": "content-surface: creates worker-consumed task step", - "registered": false + "registered": true }, { "capabilities": [ @@ -2754,7 +3018,7 @@ "command": "update_task_step", "module": "task_step_commands", "reason": "content-surface: updates worker-consumed task step", - "registered": false + "registered": true }, { "capabilities": [ @@ -2934,7 +3198,7 @@ "command": "reanalyze_project", "module": "project_commands", "reason": "spawns the project-analyzer agent", - "registered": false + "registered": true }, { "capabilities": [ @@ -3364,7 +3628,7 @@ "command": "approve_task_for_review", "module": "review_commands", "reason": "content-surface: writes worker-consumed review note", - "registered": false + "registered": true }, { "capabilities": [ @@ -4034,7 +4298,7 @@ "command": "update_task_proposal", "module": "ideation_commands", "reason": "content-surface: updates worker-consumed task proposal", - "registered": false + "registered": true }, { "capabilities": [ @@ -4944,7 +5208,7 @@ "command": "create_artifact", "module": "artifact_commands", "reason": "content-surface: creates worker-consumed artifact of any kind", - "registered": false + "registered": true }, { "capabilities": [ @@ -4954,7 +5218,7 @@ "command": "update_artifact", "module": "artifact_commands", "reason": "content-surface: updates worker-consumed artifact of any kind", - "registered": false + "registered": true }, { "capabilities": [ @@ -5044,7 +5308,7 @@ "command": "add_artifact_relation", "module": "artifact_commands", "reason": "content-surface: changes worker-consumed artifact relations", - "registered": false + "registered": true }, { "capabilities": [ @@ -6827,7 +7091,7 @@ "registered": false } ], - "schemaVersion": 1, + "schemaVersion": 2, "spawn_triggering_state_surface": [ { "armedValue": "Ready", diff --git a/src-tauri/src/remote_server/capability_ledger.rs b/src-tauri/src/remote_server/capability_ledger.rs index c48559b720..87e3f64c55 100644 --- a/src-tauri/src/remote_server/capability_ledger.rs +++ b/src-tauri/src/remote_server/capability_ledger.rs @@ -591,6 +591,28 @@ pub const COMMAND_OVERRIDES: &[CommandOverride] = &[ "spawns the project-analyzer agent", ), }, + // PR 1.5 `ui:operate` mutating surface. Both sit BELOW their `task_commands` module default + // (AgentControl) and each needs a structural reason, not a judgement call. + CommandOverride { + command: "update_task", + policy: policy( + RiskClass::Operate, + NONE, + "inert fields only at this class: category is a closed enum and priority an i32; \ + title/description carry a conditional MutatesAgentConsumedContent discharged by \ + update_task_authz, and internal_status is rejected by validate_update_task_input", + ), + }, + CommandOverride { + command: "create_task", + policy: policy( + RiskClass::Operate, + NONE, + "Backlog-only by construction: CreateTaskInput carries no status field and \ + Task::new_with_category sets InternalStatus::Backlog, so a created task cannot be \ + born in a spawn-triggering state", + ), + }, // Declared memberships not inferable from transition/process sinks. CommandOverride { command: "resolve_permission_request", @@ -604,6 +626,17 @@ pub const COMMAND_OVERRIDES: &[CommandOverride] = &[ command: "resolve_user_question", policy: policy(RiskClass::AgentControl, AGENT, "steering-question"), }, + // The approve half of the pinned permission split. Its sibling `deny_permission_request` + // carries an authority-reducing exemption down to Operate; this half gets none, because + // authorizing a live tool call is the declared membership itself. + CommandOverride { + command: "approve_permission_request", + policy: policy( + RiskClass::AgentControl, + AGENT, + "declared membership: authorizes-live-tool-call (server-pinned allow decision)", + ), + }, ]; pub const AUTHORITY_REDUCING_EXEMPTIONS: &[AuthorityReducingExemption] = &[ @@ -658,6 +691,28 @@ pub const AUTHORITY_REDUCING_EXEMPTIONS: &[AuthorityReducingExemption] = &[ }, ]; +/// A capability a command carries only for SOME arguments. +/// +/// `class_permits(Operate, [MutatesAgentConsumedContent])` is a compile error — `Operate` permits +/// no capability at all — so §3.3's "conditional capability" cannot be a macro `caps:` entry. It +/// is recorded here instead, and `conditional_capabilities_are_discharged_by_a_live_predicate` +/// makes the annotation and the argument-sensitive predicate inseparable: dropping the predicate +/// while the annotation stands (or the reverse) fails CI. Without that tie, `update_task` would +/// silently become a `ui:operate` write of worker-consumed prompt text. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ConditionalCapability { + pub command: &'static str, + pub capability: Capability, + /// The argument condition under which the capability applies, and what discharges it. + pub condition: &'static str, +} + +pub const CONDITIONAL_CAPABILITIES: &[ConditionalCapability] = &[ConditionalCapability { + command: "update_task", + capability: Capability::MutatesAgentConsumedContent, + condition: "conditional: title,description — discharged by update_task_authz", +}]; + pub const DECLARED_MEMBERSHIPS: &[(&str, &str)] = &[ ("approve_permission_request", "authorizes-live-tool-call"), ("resolve_user_question", "steering-question"), diff --git a/src-tauri/src/remote_server/capability_ledger_tests.rs b/src-tauri/src/remote_server/capability_ledger_tests.rs index 34151180a6..6184b65ce7 100644 --- a/src-tauri/src/remote_server/capability_ledger_tests.rs +++ b/src-tauri/src/remote_server/capability_ledger_tests.rs @@ -8,8 +8,8 @@ use super::authority_audit::{ SPAWN_TRIGGERING_STATE_SURFACE, }; use super::capability_ledger::{ - policy_for, AUTHORITY_REDUCING_EXEMPTIONS, COMMAND_OVERRIDES, DECLARED_MEMBERSHIPS, - MODULE_DEFAULTS, + policy_for, AUTHORITY_REDUCING_EXEMPTIONS, COMMAND_OVERRIDES, CONDITIONAL_CAPABILITIES, + DECLARED_MEMBERSHIPS, MODULE_DEFAULTS, }; use super::registry::{find_spec, REMOTE_COMMANDS}; @@ -391,9 +391,45 @@ fn generated_manifest() -> serde_json::Value { serde_json::json!({"id": entry.id, "surface": entry.surface, "armedValue": entry.armed_value, "readByLoops": entry.read_by_loops, "writers": writers}) }).collect::>(); + // The REGISTERED facade surface, including the two pinned permission ops. Those two are not + // census commands (no such Tauri command exists — the live surface has only the + // dual-decision `resolve_permission_request`), so the `ledger` table cannot carry them and + // the manifest would otherwise publish no record of the mutating surface's shape. `pins` is + // read straight off the specs, which is the same data the dispatch path binds — a pin cannot + // be documented here and absent from the wire. + let facade_ops = REMOTE_COMMANDS + .iter() + .map(|spec| { + serde_json::json!({ + "command": spec.name, + "target": spec.target, + "class": spec.class, + "capabilities": spec.capabilities, + "argumentSensitive": spec.authz.is_some(), + "pins": spec.pins.iter().map(|pin| serde_json::json!({ + "param": pin.param, + "field": pin.field, + "value": pin.value, + })).collect::>(), + }) + }) + .collect::>(); + let conditional_capabilities = CONDITIONAL_CAPABILITIES + .iter() + .map(|entry| { + serde_json::json!({ + "command": entry.command, + "capability": entry.capability, + "condition": entry.condition, + }) + }) + .collect::>(); + serde_json::json!({ - "schemaVersion": 1, + "schemaVersion": 2, "background_loop_inventory": background_loop_inventory, + "facade_ops": facade_ops, + "conditional_capabilities": conditional_capabilities, "spawn_triggering_state_surface": spawn_triggering_state_surface, "agent_consumed_content_surface": {"reads": agent_content_reads(), "writers": agent_content_writers()}, "worker_task_view_allowlist": worker_task_view_allowlist(), @@ -440,6 +476,112 @@ fn remote_command_manifest_is_current() { assert_eq!(actual, manifest_text(), "remote command manifest is stale"); } +/// The annotation ⇔ predicate tie (§3.3 conditional capability). +/// +/// A conditional capability is a promise that SOME arguments need a higher scope than the +/// command's class. The only thing that can keep that promise is an argument-sensitive `authz:` +/// predicate on the registered spec. This asserts the tie in BOTH directions, so neither half can +/// be removed alone: +/// +/// * annotation → predicate: every `CONDITIONAL_CAPABILITIES` row is registered, sits at a class +/// that does NOT already permit the capability (otherwise the annotation is noise), and carries +/// a predicate; +/// * content-writer → annotation-or-capability: every registered command the 1.3 content-surface +/// enumeration names as a writer either declares `MutatesAgentConsumedContent` outright or is +/// annotated conditional. A registered `Operate` content writer with neither fails here. +#[test] +fn conditional_capabilities_are_discharged_by_a_live_predicate() { + for entry in CONDITIONAL_CAPABILITIES { + let spec = find_spec(entry.command).unwrap_or_else(|| { + panic!( + "`{}` carries a conditional capability but is not registered; \ + an annotation on an unreachable command proves nothing", + entry.command + ) + }); + assert!( + !class_permits(spec.class, &[entry.capability]), + "`{}` is registered at {:?}, which already permits {:?} — declare it in `caps:` \ + instead of annotating it as conditional", + entry.command, + spec.class, + entry.capability + ); + assert!( + spec.authz.is_some(), + "`{}` is annotated with a conditional {:?} but has no `authz:` predicate to \ + discharge it — the annotation would be the only thing standing between a \ + ui:operate device and the content surface", + entry.command, + entry.capability + ); + } + + let annotated = CONDITIONAL_CAPABILITIES + .iter() + .map(|entry| entry.command) + .collect::>(); + for row in agent_content_writers() { + let writer = row["writer"].as_str().expect("writer is a string"); + let Some(spec) = find_spec(writer) else { + // Unregistered writers are unreachable remotely; nothing to discharge. + continue; + }; + let declares = spec + .capabilities + .contains(&Capability::MutatesAgentConsumedContent); + assert!( + declares || annotated.contains(writer), + "`{writer}` writes the agent-consumed content surface and is registered at {:?}, \ + but declares neither MutatesAgentConsumedContent nor a conditional annotation", + spec.class + ); + // A conditional annotation is only honest when the manifest row is also marked + // conditional; otherwise the audit output and the ledger disagree about the same write. + if !declares { + assert!( + row.get("conditional").is_some(), + "`{writer}` is annotated conditional in the ledger but the content-surface \ + row publishes it as an unconditional write" + ); + } + } +} + +/// P-1 feeder: a dual-decision sink is only safe when the decision is SERVER-controlled. +#[test] +fn no_facade_op_accepts_a_client_supplied_decision() { + assert!( + find_spec("resolve_permission_request").is_none(), + "the raw dual-decision command must never be registered; the facade exposes only the \ + two single-purpose pinned ops" + ); + + let pinned = REMOTE_COMMANDS + .iter() + .filter(|spec| spec.pins.iter().any(|pin| pin.field == "decision")) + .map(|spec| (spec.name, spec.pins[0].value)) + .collect::>(); + assert_eq!( + pinned, + [ + ("approve_permission_request", "allow"), + ("deny_permission_request", "deny"), + ] + .into_iter() + .collect::>(), + "the pinned permission ops drifted from their server-controlled decisions" + ); + + // Both ops target the SAME existing fn (A-7: no forked command fns) and are separated only + // by the pin and the class. + let approve = find_spec("approve_permission_request").expect("approve op is registered"); + let deny = find_spec("deny_permission_request").expect("deny op is registered"); + assert_eq!(approve.target, deny.target); + assert_eq!(approve.class, RiskClass::AgentControl); + assert_eq!(deny.class, RiskClass::Operate); +} + #[test] fn capability_ledger_is_exhaustive_and_internally_consistent() { let rows = census(); @@ -1054,12 +1196,63 @@ fn every_registered_spec_matches_its_ledger_row() { "the registered surface must not be empty or this gate is vacuous" ); for spec in REMOTE_COMMANDS { - let module = modules.get(spec.name).unwrap_or_else(|| { - panic!( - "registered command `{}` is not in the live census", - spec.name - ) - }); + // A pinned facade op is a SYNTHESISED name: no such Tauri command exists, so it has no + // census row of its own. It still may not float free of the ledger — it inherits the + // scrutiny of the fn it targets, plus two extra obligations, because splitting one + // command into two ops is precisely how an authority boundary gets quietly widened. + let module = match modules.get(spec.name) { + Some(module) => module, + None => { + assert!( + !spec.pins.is_empty(), + "registered command `{}` is not in the live census and is not a pinned \ + facade op; the facade may only expose existing commands", + spec.name + ); + let target_command = spec + .target + .rsplit("::") + .next() + .expect("target path has a final segment"); + let target_module = modules.get(target_command).unwrap_or_else(|| { + panic!( + "pinned op `{}` targets `{target_command}`, which is not a live command", + spec.name + ) + }); + let target_row = policy_for(target_command, target_module) + .unwrap_or_else(|| panic!("`{target_command}` is not ledgered")); + let own_row = policy_for(spec.name, target_module).unwrap_or_else(|| { + panic!( + "pinned op `{}` needs its own COMMAND_OVERRIDES row; inheriting the \ + module default would silently reclassify it", + spec.name + ) + }); + assert_eq!( + spec.class, own_row.class, + "pinned op `{}` is registered {:?} but ledgered {:?}", + spec.name, spec.class, own_row.class + ); + assert_eq!(spec.capabilities, own_row.capabilities); + // Weakening below the target's class is only legitimate when the pin makes the + // op authority-REDUCING, and that claim must be recorded, not asserted in a + // comment. + if own_row.class != target_row.class { + assert!( + AUTHORITY_REDUCING_EXEMPTIONS.iter().any(|exemption| { + exemption.subject == spec.name && exemption.kind == "command" + }), + "pinned op `{}` is ledgered {:?} while its target `{target_command}` is \ + {:?}, with no authority-reducing exemption to justify the gap", + spec.name, + own_row.class, + target_row.class + ); + } + continue; + } + }; let row = policy_for(spec.name, module) .unwrap_or_else(|| panic!("registered command `{}` is not ledgered", spec.name)); assert_eq!( @@ -1157,6 +1350,7 @@ fn proof_class_writers_are_flagged_by_write_site_markers() { fn detector_c_floors_process_spawn_authority() { let graph = CallGraph::build(&load_production_sources()); let mut spawners = BTreeSet::new(); + let mut registered_spawners = Vec::new(); for (command, module) in census() { let tokens = graph.closure([command.clone()]).tokens; @@ -1171,12 +1365,18 @@ fn detector_c_floors_process_spawn_authority() { SpawnsProcess is only expressible under Elevated", row.class ); - assert!( - find_spec(&command).is_none(), - "detector (c): `{command}` carries process authority and must not be registered \ - on the remote facade in this PR" - ); + if find_spec(&command).is_some() { + registered_spawners.push(command.clone()); + } } + // Reported as a set rather than on first hit: a registration sweep that trips this gate + // typically trips it many times, and failing one-at-a-time turns a single audit finding into + // a sequence of misleading ones. + assert!( + registered_spawners.is_empty(), + "detector (c): {registered_spawners:?} carry process authority and must not be \ + registered on the remote facade in this PR" + ); // Calibration — the detector must actually fire, or the floor above is vacuous. for command in [ diff --git a/src-tauri/src/remote_server/invoke_tests.rs b/src-tauri/src/remote_server/invoke_tests.rs index b9acf58e97..cafb6cefbc 100644 --- a/src-tauri/src/remote_server/invoke_tests.rs +++ b/src-tauri/src/remote_server/invoke_tests.rs @@ -242,6 +242,7 @@ fn spec_with(class: RiskClass, authz: Option) -> Remot capabilities: &[], authz, validate: None, + pins: &[], } } diff --git a/src-tauri/src/remote_server/mod.rs b/src-tauri/src/remote_server/mod.rs index ed2c004ce6..4bd8d085be 100644 --- a/src-tauri/src/remote_server/mod.rs +++ b/src-tauri/src/remote_server/mod.rs @@ -48,6 +48,11 @@ pub mod registry; mod registry_tests; // --- end PR 1.3 block --- +// --- PR 1.5: mutating surface — generated scope suite (one contiguous block) --- +#[cfg(test)] +mod scope_suite_tests; +// --- end PR 1.5 block --- + use std::net::SocketAddr; use std::sync::Arc; diff --git a/src-tauri/src/remote_server/registry.rs b/src-tauri/src/remote_server/registry.rs index 24b69049cd..9ba0a60533 100644 --- a/src-tauri/src/remote_server/registry.rs +++ b/src-tauri/src/remote_server/registry.rs @@ -13,14 +13,28 @@ //! on the hand-audited [`super::capability_ledger`] plus the independent //! [`super::authority_audit`] detectors, whose output is a CI-enforced floor (P-17, N3-H1). //! -//! # Runtime parameterisation (deviation, recorded) +//! # Runtime parameterisation (PR 1.5 resolution of the 1.3 deviation) //! -//! [`dispatch`] is generic over `R: tauri::Runtime` so the P-4 parity suite can drive the -//! *production* dispatch path under `tauri::test::MockRuntime` rather than a test-only fork -//! of it (A-7: no forked command fns). The consequence is that the `app_handle` injection form -//! yields `AppHandle`, so a command whose signature demands the Wry-monomorphic -//! `tauri::AppHandle` is not registrable in this PR. No `Read`-class command needs one; PR 3.1 -//! must either monomorphise the facade on `Wry` or widen those signatures. +//! [`dispatch`] stays generic over `R: tauri::Runtime` so the P-4 parity suite and the whole +//! P-17b scope suite can drive the *production* dispatch path under `tauri::test::MockRuntime` +//! rather than a test-only fork of it (A-7: no forked command fns). +//! +//! PR 1.3 recorded the consequence as a blocker: the `(app_handle)` injection form yields +//! `AppHandle`, so the ~115 commands whose signature demands the Wry-monomorphic +//! `tauri::AppHandle` — including the `block_task`/`pause_tasks_in_group` brakes — were +//! unregistrable. PR 1.5 needs them, and **monomorphising `dispatch` on `Wry` is not a usable +//! resolution on this platform**: building a Wry `AppHandle` in a test panics with +//! `On macOS, EventLoop must be created on the main thread!` (this is the standing +//! `remote_server::listener_tests` failure — it reproduces under `cargo nextest` too, because +//! libtest runs the test body on a spawned thread). Monomorphising would therefore make every +//! dispatch-path test unrunnable and silently delete the facade's only authorization coverage. +//! +//! The resolution is the [`(host_app_handle)`](remote_commands) injection arm: the facade keeps +//! its generic dispatch and resolves the concrete Wry handle from `AppState::app_handle`, which +//! the host populates at startup. This mirrors the `:3847` HTTP surface, which already resolves +//! the same handle the same way (`http_server/handlers/git.rs` `build_transition_service`), and +//! it fails CLOSED: when no handle is managed the request is refused with +//! `REMOTE_INTERNAL_ERROR` instead of taking a degraded path. use ralphx_remote_protocol::{Capability, ErrorCode, RiskClass, Scope}; use serde_json::Value; @@ -34,6 +48,23 @@ pub type AuthzPredicate = fn(&Value) -> Scope; /// A validation predicate, evaluated after authorization and before dispatch. pub type ValidatePredicate = fn(&Value) -> Result<(), String>; +/// A server-minted constant bound into a target fn's input, replacing whatever the client sent. +/// +/// This is what makes one dual-decision command safe to expose as two single-purpose facade ops +/// (P-1): `deny_permission_request` pins `decision = "deny"` into `ResolvePermissionArgs`, so a +/// request carrying `"decision": "allow"` still denies. The declaration below is the ONLY source +/// of the pinned value — [`extract_pinned_arg`] reads `spec.pins` at dispatch time, so a pin +/// cannot be declared in the manifest yet absent from the wire path, or vice versa. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PinnedField { + /// The target fn's parameter whose (struct) input carries the field. + pub param: &'static str, + /// The field inside that input which is server-controlled. + pub field: &'static str, + /// The constant written into it. + pub value: &'static str, +} + /// One row of the remote allowlist. #[derive(Clone)] pub struct RemoteCommandSpec { @@ -46,6 +77,8 @@ pub struct RemoteCommandSpec { pub capabilities: &'static [Capability], pub authz: Option, pub validate: Option, + /// Server-pinned input fields (see [`PinnedField`]). Empty for ordinary registrations. + pub pins: &'static [PinnedField], } /// The scope a risk class requires before any dispatch happens. @@ -90,6 +123,16 @@ impl RemoteInvokeError { message: message.into(), } } + + /// A host-side fault: the request was well-formed and authorized, but the host could not + /// assemble what the target fn needs. Never a statement about the client's request, and + /// never a path that lets the command run with a substitute. + pub fn internal(message: impl Into) -> Self { + Self { + code: ErrorCode::RemoteInternalError, + message: message.into(), + } + } } /// Outcome of a dispatch, mirroring the wire envelope. @@ -144,6 +187,44 @@ pub fn extract_arg( .map_err(|error| RemoteInvokeError::bad_args(format!("Invalid argument `{name}`: {error}"))) } +/// Extracts a struct argument, then OVERWRITES its server-pinned fields. +/// +/// The overwrite is unconditional and happens after the client's object is taken, so a +/// client-supplied value for a pinned field is discarded rather than merged. `pins` is filtered +/// to the requested parameter, so one registration can pin fields in several inputs. +/// +/// A missing input deserializes from an empty object rather than `null`, which is what lets a +/// fully-pinned struct be invoked with no client args at all. +pub fn extract_pinned_arg( + args: &Value, + name: &str, + pins: &[PinnedField], +) -> Result { + let mut raw = args + .get(name) + .or_else(|| args.get(camel_case(name))) + .cloned() + .unwrap_or_else(|| Value::Object(serde_json::Map::new())); + let object = raw.as_object_mut().ok_or_else(|| { + RemoteInvokeError::bad_args(format!("Argument `{name}` must be an object")) + })?; + let mut applied = 0usize; + for pin in pins.iter().filter(|pin| pin.param == name) { + object.insert(pin.field.to_string(), Value::String(pin.value.to_string())); + applied += 1; + } + if applied == 0 { + // A `pinned_arg` param whose spec declares no pin for it would silently degrade into an + // ordinary client-controlled argument — exactly the dual-decision shape this mechanism + // exists to remove. Refuse instead. + return Err(RemoteInvokeError::internal(format!( + "Argument `{name}` is registered as pinned but the specification declares no pin for it" + ))); + } + serde_json::from_value(raw) + .map_err(|error| RemoteInvokeError::bad_args(format!("Invalid argument `{name}`: {error}"))) +} + /// Serialises a command's success value exactly as the Tauri IPC layer does. pub fn serialize_ok(value: T) -> Result { // A host-side serialization fault, not a statement about the command's availability. @@ -228,6 +309,7 @@ macro_rules! remote_commands { params: [ $( $param:tt ),* $(,)? ], call: $call:ident, result: $result:ident + $(, pins: [ $( ($pin_param:literal, $pin_field:literal, $pin_value:literal) ),* $(,)? ] )? $(, authz: $authz:expr )? $(, validate: $validate:expr )? $(,)? @@ -265,6 +347,13 @@ macro_rules! remote_commands { capabilities: &[ $( ::ralphx_remote_protocol::Capability::$cap ),* ], authz: $crate::remote_commands!(@authz $( $authz )?), validate: $crate::remote_commands!(@validate $( $validate )?), + pins: &[ $($( + $crate::remote_server::registry::PinnedField { + param: $pin_param, + field: $pin_field, + value: $pin_value, + } + ),*)? ], } } ),* @@ -300,7 +389,7 @@ macro_rules! remote_commands { $( $name => { let outcome = $crate::remote_commands!( - @invoke app, args, $target, $call, $result, [ $( $param ),* ] + @invoke app, args, spec, $target, $call, $result, [ $( $param ),* ] ); outcome } @@ -336,36 +425,65 @@ macro_rules! remote_commands { compile_error!("remote_commands!: raw response bodies are not dispatchable over the facade"); }; (@reject_forbidden_param (arg $n:ident : $t:ty)) => {}; + (@reject_forbidden_param (pinned_arg $n:ident : $t:ty)) => {}; (@reject_forbidden_param (app_state)) => {}; (@reject_forbidden_param (execution_state)) => {}; (@reject_forbidden_param (active_project_state)) => {}; (@reject_forbidden_param (app_handle)) => {}; + (@reject_forbidden_param (host_app_handle)) => {}; // --- (b) the FIXED injection table ------------------------------------------------- - // These four arms ARE the table. An extractor form absent here has no matching arm, so + // These five arms ARE the table. An extractor form absent here has no matching arm, so // registering a command that needs it is a compile error (C-12, X-11). - (@bind $app:ident, $args:ident, (app_state)) => { + (@bind $app:ident, $args:ident, $spec:ident, (app_state)) => { $app.state::<$crate::application::AppState>() }; - (@bind $app:ident, $args:ident, (execution_state)) => { + (@bind $app:ident, $args:ident, $spec:ident, (execution_state)) => { $app.state::<::std::sync::Arc<$crate::commands::execution_commands::ExecutionState>>() }; - (@bind $app:ident, $args:ident, (active_project_state)) => { + (@bind $app:ident, $args:ident, $spec:ident, (active_project_state)) => { $app.state::<::std::sync::Arc<$crate::commands::execution_commands::ActiveProjectState>>() }; - (@bind $app:ident, $args:ident, (app_handle)) => { + (@bind $app:ident, $args:ident, $spec:ident, (app_handle)) => { $app.clone() }; - (@bind $app:ident, $args:ident, (arg $n:ident : $t:ty)) => { + // The Wry-monomorphic handle, resolved from the managed `AppState` rather than from the + // generic dispatch handle (see the module docs). Fails closed when the host has not + // populated it — a command demanding an `AppHandle` never runs without one. + (@bind $app:ident, $args:ident, $spec:ident, (host_app_handle)) => { + match $app + .state::<$crate::application::AppState>() + .app_handle + .clone() + { + Some(handle) => handle, + None => { + return Err($crate::remote_server::registry::RemoteInvokeError::internal( + "The host application handle is unavailable; the command was not executed.", + )) + } + } + }; + (@bind $app:ident, $args:ident, $spec:ident, (arg $n:ident : $t:ty)) => { match $crate::remote_server::registry::extract_arg::<$t>($args, stringify!($n)) { Ok(value) => value, Err(error) => return Err(error), } }; + (@bind $app:ident, $args:ident, $spec:ident, (pinned_arg $n:ident : $t:ty)) => { + match $crate::remote_server::registry::extract_pinned_arg::<$t>( + $args, + stringify!($n), + $spec.pins, + ) { + Ok(value) => value, + Err(error) => return Err(error), + } + }; // --- call + result shaping --------------------------------------------------------- - (@invoke $app:ident, $args:ident, $target:path, async, fallible, [ $( $param:tt ),* ]) => {{ - match $target( $( $crate::remote_commands!(@bind $app, $args, $param) ),* ).await { + (@invoke $app:ident, $args:ident, $spec:ident, $target:path, async, fallible, [ $( $param:tt ),* ]) => {{ + match $target( $( $crate::remote_commands!(@bind $app, $args, $spec, $param) ),* ).await { Ok(value) => $crate::remote_server::registry::serialize_ok(value) .map($crate::remote_server::registry::DispatchOutcome::Ok), Err(error) => Ok($crate::remote_server::registry::DispatchOutcome::Err( @@ -373,13 +491,13 @@ macro_rules! remote_commands { )), } }}; - (@invoke $app:ident, $args:ident, $target:path, async, infallible, [ $( $param:tt ),* ]) => {{ - let value = $target( $( $crate::remote_commands!(@bind $app, $args, $param) ),* ).await; + (@invoke $app:ident, $args:ident, $spec:ident, $target:path, async, infallible, [ $( $param:tt ),* ]) => {{ + let value = $target( $( $crate::remote_commands!(@bind $app, $args, $spec, $param) ),* ).await; $crate::remote_server::registry::serialize_ok(value) .map($crate::remote_server::registry::DispatchOutcome::Ok) }}; - (@invoke $app:ident, $args:ident, $target:path, sync, fallible, [ $( $param:tt ),* ]) => {{ - match $target( $( $crate::remote_commands!(@bind $app, $args, $param) ),* ) { + (@invoke $app:ident, $args:ident, $spec:ident, $target:path, sync, fallible, [ $( $param:tt ),* ]) => {{ + match $target( $( $crate::remote_commands!(@bind $app, $args, $spec, $param) ),* ) { Ok(value) => $crate::remote_server::registry::serialize_ok(value) .map($crate::remote_server::registry::DispatchOutcome::Ok), Err(error) => Ok($crate::remote_server::registry::DispatchOutcome::Err( @@ -387,8 +505,8 @@ macro_rules! remote_commands { )), } }}; - (@invoke $app:ident, $args:ident, $target:path, sync, infallible, [ $( $param:tt ),* ]) => {{ - let value = $target( $( $crate::remote_commands!(@bind $app, $args, $param) ),* ); + (@invoke $app:ident, $args:ident, $spec:ident, $target:path, sync, infallible, [ $( $param:tt ),* ]) => {{ + let value = $target( $( $crate::remote_commands!(@bind $app, $args, $spec, $param) ),* ); $crate::remote_server::registry::serialize_ok(value) .map($crate::remote_server::registry::DispatchOutcome::Ok) }}; @@ -537,4 +655,287 @@ crate::remote_commands! { call: async, result: fallible, }, + + // ----------------------------------------------------------------------------------- + // PR 1.5-A — `ui:operate`: watch + brakes + inert edits, and NOTHING that can start, + // resume, restart, or steer an agent. This is the default pairing's entire mutating + // surface (the "viewer with brakes" boundary, §3.3/§4.3). + // ----------------------------------------------------------------------------------- + + // Argument-sensitive: `category`/`priority` are inert (closed enum + i32, so neither can + // carry attacker-chosen text into a prompt); `title`/`description` are worker-consumed + // content and `update_task_authz` escalates those requests to `ui:agent`. The conditional + // `MutatesAgentConsumedContent` capability cannot live in `caps:` — `class_permits` gives + // `Operate` no capabilities at all — so it is carried as a ledger annotation whose CI guard + // requires this predicate to exist (`capability_ledger::CONDITIONAL_CAPABILITIES`). + "update_task" => crate::commands::task_commands::mutation::update_task { + class: Operate, + caps: [], + params: [ + (arg task_id: String), + (arg input: crate::commands::task_commands::types::UpdateTaskInput), + (app_state), + ], + call: async, + result: fallible, + authz: crate::remote_server::registry::update_task_authz, + }, + // Backlog-only by construction: `CreateTaskInput` carries no status field and every + // construction path runs `Task::new_with_category`, which sets `InternalStatus::Backlog`. + // A created task therefore cannot be born in a spawn-triggering state. + "create_task" => crate::commands::task_commands::mutation::create_task { + class: Operate, + caps: [], + params: [ + (arg input: crate::commands::task_commands::types::CreateTaskInput), + (app_state), + ], + call: async, + result: fallible, + }, + "pause_task" => crate::commands::task_commands::mutation::pause_task { + class: Operate, + caps: [], + params: [(arg task_id: String), (app_state), (execution_state)], + call: async, + result: fallible, + }, + "block_task" => crate::commands::task_commands::mutation::block_task { + class: Operate, + caps: [], + params: [ + (arg task_id: String), + (arg reason: Option), + (app_state), + (execution_state), + (host_app_handle), + ], + call: async, + result: fallible, + }, + "stop_task" => crate::commands::task_commands::mutation::stop_task { + class: Operate, + caps: [], + params: [ + (arg task_id: String), + (arg reason: Option), + (app_state), + (execution_state), + ], + call: async, + result: fallible, + }, + "pause_tasks_in_group" => crate::commands::task_commands::mutation::pause_tasks_in_group { + class: Operate, + caps: [], + params: [ + (arg group_kind: String), + (arg group_id: String), + (arg project_id: String), + (app_state), + (execution_state), + (host_app_handle), + ], + call: async, + result: fallible, + }, + // The deny half of the dual-decision `resolve_permission_request`. The raw command is + // NEVER registered; `decision` is server-pinned, so a client sending `"allow"` still denies. + "deny_permission_request" => crate::commands::permission_commands::resolve_permission_request { + class: Operate, + caps: [], + params: [ + (app_state), + (pinned_arg args: crate::commands::permission_commands::ResolvePermissionArgs), + ], + call: async, + result: fallible, + pins: [("args", "decision", "deny")], + }, + + // ----------------------------------------------------------------------------------- + // PR 1.5-A — `ui:agent`: everything that can start, resume, restart or steer an agent, + // whether directly (detector a), by seeding state a background loop consumes + // (detector b), by mutating agent-consumed content, or as a declared membership. + // Off by default; granted per device. + // ----------------------------------------------------------------------------------- + + // Detector (a). + "move_task" => crate::commands::task_commands::mutation::move_task { + class: AgentControl, + caps: [AgentControl, MutatesAgentConsumedContent], + params: [ + (arg task_id: String), + (arg to_status: String), + (arg note: Option), + (app_state), + (execution_state), + (host_app_handle), + ], + call: async, + result: fallible, + }, + // NOT registered — detector (c) proves `resume_task`, `apply_proposals_to_kanban` and + // `set_agent_conversation_workspace_auto_publish` reach a process-launch sink, and a + // command carrying `SpawnsProcess` authority is not exposable on the v1 facade at any + // scope. They stay AgentControl-floor members and answer `REMOTE_COMMAND_UNAVAILABLE`; + // the generated P-17b suite asserts exactly that. + "unblock_task" => crate::commands::task_commands::mutation::unblock_task { + class: AgentControl, + caps: [AgentControl], + params: [ + (arg task_id: String), + (app_state), + (execution_state), + (host_app_handle), + ], + call: async, + result: fallible, + }, + "answer_user_question" => crate::commands::task_commands::mutation::answer_user_question { + class: AgentControl, + caps: [AgentControl], + params: [ + (arg input: crate::commands::task_commands::types::AnswerUserQuestionInput), + (app_state), + (execution_state), + (host_app_handle), + ], + call: async, + result: fallible, + }, + "approve_task_for_review" => crate::commands::review_commands::approve_task_for_review { + class: AgentControl, + caps: [MutatesAgentConsumedContent], + params: [ + (arg input: crate::commands::review_commands_types::ApproveTaskInput), + (app_state), + (execution_state), + (host_app_handle), + ], + call: async, + result: fallible, + }, + "reanalyze_project" => crate::commands::project_commands::reanalyze_project { + class: AgentControl, + caps: [AgentControl], + params: [(arg id: String), (app_state)], + call: async, + result: fallible, + }, + + // Detector (b) — seeds state a registered background loop consumes. + "inject_task" => crate::commands::task_commands::mutation::inject_task { + class: AgentControl, + caps: [SeedsSpawnTriggeringState], + params: [ + (arg input: crate::commands::task_commands::types::InjectTaskInput), + (app_state), + (host_app_handle), + ], + call: async, + result: fallible, + }, + "resume_automation" => crate::commands::automation_commands::resume_automation { + class: AgentControl, + caps: [SeedsSpawnTriggeringState], + params: [ + (arg input: crate::commands::automation_commands::AutomationIdInput), + (app_state), + ], + call: async, + result: fallible, + }, + "finalize_automation" => crate::commands::automation_commands::finalize_automation { + class: AgentControl, + caps: [SeedsSpawnTriggeringState], + params: [ + (arg input: crate::commands::automation_commands::AutomationIdInput), + (app_state), + ], + call: async, + result: fallible, + }, + + // Agent-consumed content surface. + "create_task_step" => crate::commands::task_step_commands::create_task_step { + class: AgentControl, + caps: [MutatesAgentConsumedContent], + params: [ + (arg task_id: String), + (arg input: crate::commands::task_step_commands_types::CreateTaskStepInput), + (app_state), + ], + call: async, + result: fallible, + }, + "update_task_step" => crate::commands::task_step_commands::update_task_step { + class: AgentControl, + caps: [MutatesAgentConsumedContent], + params: [ + (arg step_id: String), + (arg input: crate::commands::task_step_commands_types::UpdateTaskStepInput), + (app_state), + ], + call: async, + result: fallible, + }, + "create_artifact" => crate::commands::artifact_commands::create_artifact { + class: AgentControl, + caps: [MutatesAgentConsumedContent], + params: [ + (arg input: crate::commands::artifact_commands::CreateArtifactInput), + (app_state), + ], + call: async, + result: fallible, + }, + "update_artifact" => crate::commands::artifact_commands::update_artifact { + class: AgentControl, + caps: [MutatesAgentConsumedContent], + params: [ + (arg id: String), + (arg input: crate::commands::artifact_commands::UpdateArtifactInput), + (app_state), + ], + call: async, + result: fallible, + }, + "add_artifact_relation" => crate::commands::artifact_commands::add_artifact_relation { + class: AgentControl, + caps: [MutatesAgentConsumedContent], + params: [ + (arg input: crate::commands::artifact_commands::AddRelationInput), + (app_state), + ], + call: async, + result: fallible, + }, + "update_task_proposal" => crate::commands::ideation_commands::update_task_proposal { + class: AgentControl, + caps: [MutatesAgentConsumedContent], + params: [ + (arg id: String), + (arg input: crate::commands::ideation_commands::UpdateProposalInput), + (app_state), + ], + call: async, + result: fallible, + }, + + // Declared membership: authorising a live tool call is not inferable from a transition or + // process sink, so it is declared. Same target fn as `deny_permission_request`, opposite + // server-pinned decision — and a whole class higher. + "approve_permission_request" + => crate::commands::permission_commands::resolve_permission_request { + class: AgentControl, + caps: [AgentControl], + params: [ + (app_state), + (pinned_arg args: crate::commands::permission_commands::ResolvePermissionArgs), + ], + call: async, + result: fallible, + pins: [("args", "decision", "allow")], + }, } diff --git a/src-tauri/src/remote_server/scope_suite_tests.rs b/src-tauri/src/remote_server/scope_suite_tests.rs new file mode 100644 index 0000000000..e07517a37a --- /dev/null +++ b/src-tauri/src/remote_server/scope_suite_tests.rs @@ -0,0 +1,506 @@ +//! P-17b — the `ui:agent` negative suite, GENERATED from the checked-in audit manifest. +//! +//! The membership of this suite is never written by hand. It is the union of the manifest's +//! `agent_control_floor` (detector (a) ∪ detector (b) output) and its `declared_memberships`, +//! which is exactly the set of commands the audit says can start, resume, restart or steer an +//! agent. A hand-maintained list would drift the moment a new arming path landed, and would +//! drift SILENTLY — the failure mode this suite exists to remove. +//! +//! The generation source is legitimate because the manifest itself is gated: the staleness test +//! (`remote_command_manifest_is_current`) re-derives it from the live census plus the call graph +//! and fails if the checked-in copy differs. So "generated from the manifest" is transitively +//! "generated from the audit", with a CI gate on the link. +//! +//! What each member proves: +//! * registered → dispatching it with a default pairing's grant (`ui:read` + `ui:operate`) +//! is refused with `REMOTE_FORBIDDEN`, and nothing was written; +//! * unregistered → dispatching it returns the `REMOTE_COMMAND_UNAVAILABLE` envelope, i.e. the +//! floor member is unreachable rather than reachable-but-unguarded. + +use std::collections::BTreeSet; +use std::sync::Arc; + +use ralphx_remote_protocol::{ErrorCode, RiskClass, Scope}; +use serde_json::{json, Value}; +use tauri::Manager as _; + +use super::capability_ledger::CONDITIONAL_CAPABILITIES; +use super::registry::{self, find_spec}; +use crate::application::AppState; +use crate::domain::entities::{ProjectId, Task}; + +/// The grant a default-paired device holds under the viewer-with-brakes model. +const DEFAULT_PAIRING: &[Scope] = &[Scope::UiRead, Scope::UiOperate]; + +/// Every scope, used to prove an unregistered floor member is unreachable for ANY device — not +/// merely unauthorized for this one. +const EVERY_SCOPE: &[Scope] = &[ + Scope::UiRead, + Scope::UiOperate, + Scope::UiAgent, + Scope::UiElevated, +]; + +/// The commands whose presence in the generated set is itself part of the contract. If the +/// generator silently produced an empty or truncated set, every per-member assertion below would +/// vacuously pass; these anchors are what make that impossible. +const ANCHORS: &[&str] = &[ + "move_task", + "inject_task", + "resume_automation", + "approve_permission_request", + "resolve_user_question", + "answer_user_question", + "unblock_task", +]; + +fn manifest_path() -> std::path::PathBuf { + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../docs/generated/remote-commands.json") +} + +/// Reads the checked-in manifest, failing CLOSED. +/// +/// A missing or unparseable manifest must abort the suite, never skip it: "the audit output was +/// not available" is the one condition under which a silently-empty negative suite is most +/// likely, and an empty suite is indistinguishable from a passing one. +fn manifest() -> Value { + let raw = std::fs::read_to_string(manifest_path()).unwrap_or_else(|error| { + panic!( + "the generated scope suite requires the checked-in audit manifest at {}: {error}", + manifest_path().display() + ) + }); + serde_json::from_str(&raw).expect("the checked-in audit manifest is valid JSON") +} + +/// floor ∪ declared memberships, read out of a manifest VALUE rather than off disk, so P-17f can +/// feed a mutated copy and observe the suite shrink. +fn suite_membership(manifest: &Value) -> BTreeSet { + let floor = manifest["agent_control_floor"] + .as_array() + .expect("manifest publishes an agent_control_floor array") + .iter() + .map(|value| { + value + .as_str() + .expect("floor members are strings") + .to_string() + }); + let declared = manifest["declared_memberships"] + .as_array() + .expect("manifest publishes a declared_memberships array") + .iter() + .map(|row| { + row["command"] + .as_str() + .expect("declared membership rows carry a command") + .to_string() + }); + let members = floor.chain(declared).collect::>(); + assert!( + !members.is_empty(), + "the generated suite is empty; every per-member assertion would pass vacuously" + ); + members +} + +/// The anchor guard, as a fallible function so P-17f can assert it FAILS on a stripped manifest. +fn check_anchors(members: &BTreeSet) -> Result<(), String> { + for anchor in ANCHORS { + if !members.contains(*anchor) { + return Err(format!("generated suite is missing anchor `{anchor}`")); + } + } + Ok(()) +} + +fn conditionally_annotated(command: &str) -> bool { + CONDITIONAL_CAPABILITIES + .iter() + .any(|entry| entry.command == command) +} + +#[test] +fn the_generated_suite_contains_every_anchor() { + let members = suite_membership(&manifest()); + check_anchors(&members).expect("anchor missing from the generated suite"); +} + +/// P-17f — a declared membership cannot silently evaporate. +/// +/// Mirrors the representative strip tests in `capability_ledger_tests`: feed the generator a +/// manifest copy with one `declared_memberships` row removed and assert BOTH that the suite +/// visibly shrinks by exactly that member and that the guard which would have covered it now +/// fails. Without the second half, a strip would merely make the suite smaller and quieter. +#[test] +fn stripping_a_declared_membership_shrinks_the_suite_and_fails_the_guard() { + let manifest = manifest(); + let full = suite_membership(&manifest); + check_anchors(&full).expect("baseline manifest satisfies the anchors"); + + for stripped_command in ["approve_permission_request", "resolve_user_question"] { + // `resolve_user_question` is a declared membership AND may independently be a floor + // member; strip it from both tables so the test measures the declared row's own + // contribution rather than an overlap. + let mut mutated = manifest.clone(); + mutated["declared_memberships"] = Value::Array( + manifest["declared_memberships"] + .as_array() + .expect("declared memberships is an array") + .iter() + .filter(|row| row["command"].as_str() != Some(stripped_command)) + .cloned() + .collect(), + ); + mutated["agent_control_floor"] = Value::Array( + manifest["agent_control_floor"] + .as_array() + .expect("floor is an array") + .iter() + .filter(|value| value.as_str() != Some(stripped_command)) + .cloned() + .collect(), + ); + + let shrunk = suite_membership(&mutated); + assert!( + !shrunk.contains(stripped_command), + "stripping `{stripped_command}` left it in the generated suite" + ); + assert_eq!( + full.difference(&shrunk).cloned().collect::>(), + vec![stripped_command.to_string()], + "stripping `{stripped_command}` changed the suite by more than that one member" + ); + assert!( + check_anchors(&shrunk).is_err(), + "the anchor guard accepted a suite that had lost `{stripped_command}`; a declared \ + membership could then be deleted without any test failing" + ); + } +} + +/// Every registered member of the generated suite sits at a class a default pairing cannot reach. +#[test] +fn every_registered_suite_member_is_classified_above_the_default_pairing() { + let members = suite_membership(&manifest()); + let mut registered = 0usize; + for member in &members { + let Some(spec) = find_spec(member) else { + continue; + }; + registered += 1; + let acceptable = spec.class == RiskClass::AgentControl + || spec.class == RiskClass::Elevated + || (spec.class == RiskClass::Operate + && conditionally_annotated(member) + && spec.authz.is_some()); + assert!( + acceptable, + "`{member}` is in the AgentControl floor but is registered at {:?} with no \ + argument-sensitive discharge", + spec.class + ); + } + assert!( + registered > 0, + "no suite member is registered; the forbidden-dispatch assertions below prove nothing" + ); +} + +// --------------------------------------------------------------------------------------- +// Positive branch — the product contract the negatives would otherwise let us break by +// simply forbidding everything. +// --------------------------------------------------------------------------------------- + +/// `title`/`description` escalate to `ui:agent`; `category`/`priority` do not. +/// +/// Driven through `dispatch`, not through `enforce_scope`, so the escalation is proven on the +/// path a request actually takes. +#[tokio::test] +async fn update_task_splits_on_the_field_the_request_touches() { + let app = crate::testing::create_mock_app(); + let app_state = AppState::new_test(); + let task = app_state + .task_repo + .create(Task::new(ProjectId::new(), "Editable".to_string())) + .await + .expect("task is created"); + let original_title = task.title.clone(); + let task_id = task.id.as_str().to_string(); + app.manage(app_state); + + for field in ["title", "description"] { + let args = json!({"taskId": &task_id, "input": {field: "POISON"}}); + let refused = registry::dispatch(app.handle(), DEFAULT_PAIRING, "update_task", &args) + .await + .expect_err("a content write must not be reachable from the default pairing"); + assert_eq!(refused.code, ErrorCode::RemoteForbidden, "field {field}"); + } + + // Absence assertion for both refusals. + let state = app.state::(); + let untouched = state + .task_repo + .get_by_id(&task.id) + .await + .expect("task is readable") + .expect("task exists"); + assert_eq!(untouched.title, original_title); + assert_eq!(untouched.description, None); + + // The inert edit is admitted at the same class. + let admitted = registry::dispatch( + app.handle(), + DEFAULT_PAIRING, + "update_task", + &json!({"taskId": &task_id, "input": {"priority": 7}}), + ) + .await + .expect("an inert edit must be reachable from the default pairing"); + assert!( + matches!(admitted, registry::DispatchOutcome::Ok(_)), + "inert update was admitted but failed: {admitted:?}" + ); + let edited = state + .task_repo + .get_by_id(&task.id) + .await + .expect("task is readable") + .expect("task exists"); + assert_eq!(edited.priority, 7); + assert_eq!( + edited.title, original_title, + "an inert edit rewrote content" + ); +} + +/// A remotely created task is Backlog whatever the client sends. +#[tokio::test] +async fn create_task_is_backlog_only_even_when_a_status_is_smuggled() { + use crate::domain::entities::InternalStatus; + + let app = crate::testing::create_mock_app(); + let app_state = AppState::new_test(); + let project_id = ProjectId::new(); + app.manage(app_state); + + let outcome = registry::dispatch( + app.handle(), + DEFAULT_PAIRING, + "create_task", + // Extra keys are the smuggling attempt: `CreateTaskInput` has no status field, so they + // must be inert rather than merely ignored-by-luck. + &json!({"input": { + "projectId": project_id.as_str(), + "title": "Remote created", + "internal_status": "Ready", + "internalStatus": "Ready", + "status": "Ready", + }}), + ) + .await + .expect("create_task is reachable from the default pairing"); + assert!( + matches!(outcome, registry::DispatchOutcome::Ok(_)), + "create_task failed: {outcome:?}" + ); + + let created = app + .state::() + .task_repo + .get_by_project(&project_id) + .await + .expect("project tasks are readable"); + assert_eq!(created.len(), 1); + assert_eq!( + created[0].internal_status, + InternalStatus::Backlog, + "a remotely created task must be born in Backlog" + ); +} + +/// The brakes are reachable from the default pairing. +/// +/// The assertion is admission, not business success: a brake applied to a task in a state that +/// cannot be paused legitimately returns a command-level error, and that is still proof that the +/// scope gate let the request through. What must never appear is a FACADE error. +#[tokio::test] +async fn the_brakes_are_reachable_from_the_default_pairing() { + let app = crate::testing::create_mock_app(); + let app_state = AppState::new_test(); + let task = app_state + .task_repo + .create(Task::new(ProjectId::new(), "Brakeable".to_string())) + .await + .expect("task is created"); + let task_id = task.id.as_str().to_string(); + app.manage(app_state); + app.manage(Arc::new( + crate::commands::execution_commands::ExecutionState::default(), + )); + + for brake in ["pause_task", "stop_task"] { + let spec = find_spec(brake).unwrap_or_else(|| panic!("{brake} is registered")); + assert_eq!(spec.class, RiskClass::Operate, "{brake} drifted class"); + registry::dispatch( + app.handle(), + DEFAULT_PAIRING, + brake, + &json!({"taskId": &task_id}), + ) + .await + .unwrap_or_else(|error| panic!("{brake} was refused by the facade: {error:?}")); + } + + // The two AppHandle-bearing brakes must be registered at the same class. They cannot be + // dispatched under a mock runtime — `AppState::new_test()` carries no Wry handle — and the + // `(host_app_handle)` arm is required to fail CLOSED rather than substitute anything. + for brake in ["block_task", "pause_tasks_in_group"] { + let spec = find_spec(brake).unwrap_or_else(|| panic!("{brake} is registered")); + assert_eq!(spec.class, RiskClass::Operate, "{brake} drifted class"); + let error = registry::dispatch( + app.handle(), + DEFAULT_PAIRING, + brake, + &json!({"taskId": &task_id, "groupKind": "status", "groupId": "Ready", "projectId": ProjectId::new().as_str()}), + ) + .await + .expect_err("a missing host handle must not be substituted"); + assert_eq!( + error.code, + ErrorCode::RemoteInternalError, + "{brake} degraded instead of failing closed" + ); + } +} + +/// The pinned decision is server-controlled on the wire path itself. +/// +/// This reads `spec.pins` — the exact data `dispatch` binds — so it cannot pass while the +/// dispatch path uses something else. +#[test] +fn a_client_supplied_decision_cannot_flip_a_pinned_permission_op() { + use crate::commands::permission_commands::ResolvePermissionArgs; + + for (op, expected) in [ + ("deny_permission_request", "deny"), + ("approve_permission_request", "allow"), + ] { + let spec = find_spec(op).unwrap_or_else(|| panic!("{op} is registered")); + // `ResolvePermissionArgs` carries no `rename_all`, so the wire field is snake_case — + // the facade deserializes exactly what the local IPC path does (P-4 parity). + let args = json!({"args": { + "request_id": "req-1", + // The attack: the client asserts the opposite decision. + "decision": if expected == "deny" { "allow" } else { "deny" }, + "message": "client supplied", + }}); + let resolved: ResolvePermissionArgs = + registry::extract_pinned_arg(&args, "args", spec.pins).expect("pinned args bind"); + assert_eq!( + resolved.decision, expected, + "{op} took the client's decision instead of the pinned one" + ); + // The non-pinned fields still come from the client. + assert_eq!(resolved.request_id, "req-1"); + } +} + +/// The suite proper, driven through the PRODUCTION `registry::dispatch`. +#[tokio::test] +async fn the_generated_suite_is_unreachable_from_a_default_pairing() { + let members = suite_membership(&manifest()); + check_anchors(&members).expect("anchor missing from the generated suite"); + + let app = crate::testing::create_mock_app(); + let app_state = AppState::new_test(); + + // A row every refused dispatch could plausibly touch, so "nothing happened" is an assertion + // about state rather than about the response code alone. + let project_id = ProjectId::new(); + let seeded = app_state + .task_repo + .create(Task::new(project_id.clone(), "P17B canary".to_string())) + .await + .expect("canary task is created"); + let before = app_state + .task_repo + .get_by_id(&seeded.id) + .await + .expect("canary is readable") + .expect("canary exists"); + + let execution_state = Arc::new(crate::commands::execution_commands::ExecutionState::default()); + app.manage(app_state); + app.manage(Arc::clone(&execution_state)); + + let mut forbidden = 0usize; + let mut unavailable = 0usize; + for member in &members { + // Args are deliberately shaped like a real attempt: the point is that the refusal happens + // at the scope gate, BEFORE argument handling or any target fn is reached. + let args = json!({ + "taskId": seeded.id.as_str(), + "task_id": seeded.id.as_str(), + "projectId": project_id.as_str(), + "input": {"taskId": seeded.id.as_str(), "projectId": project_id.as_str()}, + }); + + let refusal = registry::dispatch(app.handle(), DEFAULT_PAIRING, member, &args) + .await + .expect_err(&format!( + "`{member}` is in the AgentControl floor but a default pairing dispatched it" + )); + + if find_spec(member).is_some() { + assert_eq!( + refusal.code, + ErrorCode::RemoteForbidden, + "registered floor member `{member}` must be refused as forbidden" + ); + forbidden += 1; + } else { + assert_eq!( + refusal.code, + ErrorCode::RemoteCommandUnavailable, + "unregistered floor member `{member}` must answer the unavailable envelope" + ); + // Unreachable for EVERY device, not merely for this pairing. + let with_everything = registry::dispatch(app.handle(), EVERY_SCOPE, member, &args) + .await + .expect_err(&format!( + "`{member}` is unregistered and must stay unreachable" + )); + assert_eq!(with_everything.code, ErrorCode::RemoteCommandUnavailable); + unavailable += 1; + } + } + + assert!( + forbidden > 0 && unavailable > 0, + "suite covered only one branch" + ); + + // Absence assertion: the whole suite ran and wrote nothing. + let state = app.state::(); + let after = state + .task_repo + .get_by_id(&seeded.id) + .await + .expect("canary is readable") + .expect("canary still exists"); + assert_eq!(after.internal_status, before.internal_status); + assert_eq!(after.title, before.title); + assert_eq!(after.description, before.description); + assert_eq!( + state + .task_repo + .get_by_project(&project_id) + .await + .expect("project tasks are readable") + .len(), + 1, + "a refused dispatch created a task" + ); +} From 1c0e0d2ab31bfd527f95c4a05e68146127886156 Mon Sep 17 00:00:00 2001 From: Adrian Demian <8708742+adriandemian@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:27:24 +0300 Subject: [PATCH 160/416] feat(remote): manifest-driven ui:agent gate with live scope consumption (2.6-b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A remote device paired at the default boundary can watch and can stop things; everything that steers an agent forward now requires the `ui:agent` scope the host grants explicitly. Derivation (never hand-maintained): `scripts/lib/agent-control-derivation.mjs` mirrors the gated set out of docs/generated/remote-commands.json into a checked-in TS module, checked by scripts/check-agent-control-command-mirror.mjs and wired into pretypecheck. The contract specified `agent_control_floor union declared_memberships` — 111 commands. The ledger classifies 327 as `class: agentControl`, 261 of which are NOT in that union, so the contract's set under-gates steering badly. The derivation therefore unions all three sources (372). Tests assert it is a strict superset of the contract's set, covers every agentControl row, and swallows zero `operate`/`read` commands, so the viewer-with-brakes floor is provably intact. Scopes come from the LIVE confirmed set (P-28), never the pair-time snapshot. The carrier moved from a module-local Map in environment-runtime onto environmentStore.effectiveScopes — one copy, one writer, and now reachable from React. A negative test proves a stale snapshot granting ui:agent does not open the gate. The inert exemption list is argument-constrained, not command-level, because two A6 surfaces share a command with a steering action: `resolve_permission_request` carries allow and deny, `update_task` carries both agent-consumed content and inert metadata. Each exemption declares the restriction that makes it safe, and a test rejects any inert row that exempts a gated command without one. Task editing locks title/description while leaving category/priority live. Also fixes a real coupling found on the way: `typedInvoke` moved out of the `@/lib/tauri` barrel, so environmentStore no longer drags the entire API graph into every component that reads it. The barrel re-exports it unchanged. Flags dual-authority (review-4): host-behaviour flags are owned by the env-scoped useFeatureFlags query; client-owned flags (today: remoteEnvironments) are owned by uiStore's boot-time local fetch and are stripped from host payloads, so a host cannot switch this client's remote runtime on. --- frontend/package.json | 2 +- frontend/src/api/remote-environments.ts | 4 +- frontend/src/components/Chat/ChatInput.tsx | 18 +- .../src/components/Ideation/PlanEditor.tsx | 5 +- .../src/components/Ideation/ProposalCard.tsx | 7 +- .../Ideation/ProposalDetailSheet.tsx | 7 +- frontend/src/components/PermissionDialog.tsx | 24 +- .../agents/AgentComposerSurface.tsx | 33 +- .../agents/AgentsAutomationPanel.tsx | 21 +- .../agents/task-details/StepList.tsx | 5 +- .../agents/task-details/TaskEditForm.tsx | 36 +- .../agents/task-details/TaskFormFields.tsx | 15 +- .../components/remote/AgentGateTooltip.tsx | 53 +++ .../remote/agent-gate-surfaces.test.tsx | 223 ++++++++++ .../tasks/GroupContextMenuItems.tsx | 11 +- .../src/components/tasks/TaskBoard/Column.tsx | 7 +- .../components/tasks/TaskBoard/TaskBoard.tsx | 14 +- .../components/tasks/TaskContextMenuItems.tsx | 44 +- .../src/components/tasks/TaskEditForm.tsx | 36 +- .../src/components/tasks/TaskFormFields.tsx | 15 +- .../tasks/detail-views/BasicTaskDetail.tsx | 12 +- .../detail-views/EscalatedTaskDetail.tsx | 12 +- .../detail-views/HumanReviewTaskDetail.tsx | 12 +- frontend/src/hooks/useAgentGate.ts | 45 ++ frontend/src/hooks/useFeatureFlags.ts | 18 +- frontend/src/hooks/useIdeation.ts | 9 + frontend/src/hooks/useQuestionInput.ts | 19 +- .../agent-control-commands.generated.ts | 388 ++++++++++++++++++ .../lib/remote/agent-gate.consumption.test.ts | 159 +++++++ frontend/src/lib/remote/agent-gate.test.ts | 384 +++++++++++++++++ frontend/src/lib/remote/agent-gate.ts | 234 +++++++++++ .../src/lib/remote/environment-runtime.ts | 38 +- .../lib/remote/feature-flag-authority.test.ts | 110 +++++ .../src/lib/remote/feature-flag-authority.ts | 91 ++++ .../src/lib/remote/transport-alias.test.ts | 15 +- frontend/src/lib/tauri.ts | 44 +- frontend/src/lib/typed-invoke.ts | 46 +++ frontend/src/stores/environmentStore.ts | 48 +++ .../check-agent-control-command-mirror.mjs | 95 +++++ scripts/lib/agent-control-derivation.mjs | 94 +++++ 40 files changed, 2343 insertions(+), 110 deletions(-) create mode 100644 frontend/src/components/remote/AgentGateTooltip.tsx create mode 100644 frontend/src/components/remote/agent-gate-surfaces.test.tsx create mode 100644 frontend/src/hooks/useAgentGate.ts create mode 100644 frontend/src/lib/remote/agent-control-commands.generated.ts create mode 100644 frontend/src/lib/remote/agent-gate.consumption.test.ts create mode 100644 frontend/src/lib/remote/agent-gate.test.ts create mode 100644 frontend/src/lib/remote/agent-gate.ts create mode 100644 frontend/src/lib/remote/feature-flag-authority.test.ts create mode 100644 frontend/src/lib/remote/feature-flag-authority.ts create mode 100644 frontend/src/lib/typed-invoke.ts create mode 100644 scripts/check-agent-control-command-mirror.mjs create mode 100644 scripts/lib/agent-control-derivation.mjs diff --git a/frontend/package.json b/frontend/package.json index e29e81e6d9..28bedba65e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,7 +16,7 @@ "test:check-warnings": "./scripts/check-vitest-warnings.sh", "test:visual": "playwright test tests/visual --workers=1", "test:coverage": "vitest run --coverage --testTimeout=15000 --retry=1", - "pretypecheck": "node ../scripts/check-raw-tauri-event-listen.mjs .. && node ../scripts/check-remote-transport-drift.mjs --self-test && node ../scripts/check-remote-transport-drift.mjs .. && node ../scripts/check-local-only-event-mirror.mjs ..", + "pretypecheck": "node ../scripts/check-raw-tauri-event-listen.mjs .. && node ../scripts/check-remote-transport-drift.mjs --self-test && node ../scripts/check-remote-transport-drift.mjs .. && node ../scripts/check-local-only-event-mirror.mjs .. && node ../scripts/check-agent-control-command-mirror.mjs ..", "typecheck": "tsc --noEmit", "lint": "eslint src", "lint:fix": "eslint src --fix", diff --git a/frontend/src/api/remote-environments.ts b/frontend/src/api/remote-environments.ts index dbe3a0a8f2..b8cde9dafe 100644 --- a/frontend/src/api/remote-environments.ts +++ b/frontend/src/api/remote-environments.ts @@ -5,7 +5,9 @@ // copy is the authority the proxy commands enforce (P-26). import { z } from "zod"; -import { typedInvoke } from "@/lib/tauri"; +// Narrow import, not the `@/lib/tauri` barrel: the environment store sits under +// every gated component, and the barrel would pull the whole API graph with it. +import { typedInvoke } from "@/lib/typed-invoke"; export const remoteEnvironmentStatusSchema = z.enum([ "active", diff --git a/frontend/src/components/Chat/ChatInput.tsx b/frontend/src/components/Chat/ChatInput.tsx index 38b38a258f..7fffa994a7 100644 --- a/frontend/src/components/Chat/ChatInput.tsx +++ b/frontend/src/components/Chat/ChatInput.tsx @@ -15,6 +15,8 @@ import { useEffect, type ReactNode, } from "react"; +import { AgentGateTooltip } from "@/components/remote/AgentGateTooltip"; +import { useAgentGate } from "@/hooks/useAgentGate"; import { useChatAttachmentDrop } from "@/hooks/useChatAttachmentDrop"; import { ChatAttachmentPicker } from "./ChatAttachmentPicker"; import { ChatAttachmentDropOverlay } from "./ChatAttachmentDropOverlay"; @@ -240,8 +242,8 @@ export function ChatInput({ // Handle sending or queueing message const handleSend = useCallback(async () => { const trimmedValue = value.trim(); - // Block if no content, or if sending and agent not alive (can't queue or interact) - if (!trimmedValue || (isSending && !isAgentAlive)) return; + // Block if no content, gated, or if sending and agent not alive + if (!trimmedValue || agentGate.gated || (isSending && !isAgentAlive)) return; // Clear input immediately (optimistic UI) const clearInput = () => { @@ -299,8 +301,14 @@ export function ChatInput({ const [isFocused, setIsFocused] = useState(false); // Allow typing and queueing/sending when agent is alive (generating or waiting), but not in read-only mode - const isDisabled = isReadOnly || (isSending && !isAgentAlive); - const canSend = value.trim().length > 0 && !isReadOnly && (!isSending || isAgentAlive); + // Sending a chat message steers the agent — `ui:agent` required (2.6-b). + const agentGate = useAgentGate(); + const isDisabled = isReadOnly || agentGate.gated || (isSending && !isAgentAlive); + const canSend = + value.trim().length > 0 && + !isReadOnly && + !agentGate.gated && + (!isSending || isAgentAlive); const attachmentDropEnabled = enableAttachments && !isReadOnly && onFilesSelected !== undefined; const { isDragging: isAttachmentDragging, dropProps: attachmentDropProps } = useChatAttachmentDrop({ enabled: attachmentDropEnabled, @@ -397,6 +405,7 @@ export function ChatInput({ {/* Send button */}

+ +
diff --git a/frontend/src/components/Ideation/PlanEditor.tsx b/frontend/src/components/Ideation/PlanEditor.tsx index 0d72e5c3e2..a2f92421d1 100644 --- a/frontend/src/components/Ideation/PlanEditor.tsx +++ b/frontend/src/components/Ideation/PlanEditor.tsx @@ -7,6 +7,7 @@ * - Calls update_plan_artifact HTTP endpoint on save */ +import { useAgentGate } from "@/hooks/useAgentGate"; import { useState, useCallback } from "react"; import { Save, X, Eye, Edit2 } from "lucide-react"; import ReactMarkdown from "react-markdown"; @@ -147,6 +148,7 @@ const markdownComponents = { // ============================================================================ export function PlanEditor({ plan, onSave, onCancel, isNewPlan = false }: PlanEditorProps) { + const agentGate = useAgentGate(); // Get initial content const initialContent = plan.content.type === "inline" ? plan.content.text : ""; @@ -263,7 +265,8 @@ export function PlanEditor({ plan, onSave, onCancel, isNewPlan = false }: PlanEd variant="default" size="sm" onClick={handleSave} - disabled={isSaving || !hasChanges} + disabled={isSaving || !hasChanges || agentGate.gated} + title={agentGate.reason ?? undefined} className="bg-[var(--accent-primary)] hover:bg-[var(--accent-hover)] text-white" > diff --git a/frontend/src/components/Ideation/ProposalCard.tsx b/frontend/src/components/Ideation/ProposalCard.tsx index 92de64ac65..f020b48b79 100644 --- a/frontend/src/components/Ideation/ProposalCard.tsx +++ b/frontend/src/components/Ideation/ProposalCard.tsx @@ -5,6 +5,7 @@ * and warm orange accent for selection states. */ +import { useAgentGate } from "@/hooks/useAgentGate"; import React, { useState } from "react"; import { Button } from "@/components/ui/button"; import { @@ -74,6 +75,7 @@ export const ProposalCard = React.memo(function ProposalCard({ isSelected = false, onDelete, }: ProposalCardProps) { + const agentGate = useAgentGate(); const [isDepsExpanded, setIsDepsExpanded] = useState(false); const [isHovered, setIsHovered] = useState(false); const effectivePriority = proposal.userPriority ?? proposal.suggestedPriority; @@ -172,12 +174,13 @@ export const ProposalCard = React.memo(function ProposalCard({ style={{ background: "transparent" }} onMouseEnter={(e) => { e.currentTarget.style.background = "var(--overlay-weak)"; }} onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; }} - onClick={(e) => { e.stopPropagation(); onEdit(proposal.id); }} + disabled={agentGate.gated} + onClick={(e) => { e.stopPropagation(); if (agentGate.gated) return; onEdit(proposal.id); }} > - Edit + {agentGate.reason ?? "Edit"}
{onDelete !== undefined && ( diff --git a/frontend/src/components/Ideation/ProposalDetailSheet.tsx b/frontend/src/components/Ideation/ProposalDetailSheet.tsx index 8f18a0afc9..a20ca45f5d 100644 --- a/frontend/src/components/Ideation/ProposalDetailSheet.tsx +++ b/frontend/src/components/Ideation/ProposalDetailSheet.tsx @@ -4,6 +4,7 @@ * Design: Dark glass aesthetic with backdrop blur, warm orange accent */ +import { useAgentGate } from "@/hooks/useAgentGate"; import React, { useEffect, useCallback, useMemo, useState } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; @@ -101,6 +102,7 @@ export const ProposalDetailSheet = React.memo(function ProposalDetailSheet({ onDelete, onNavigateToTask, }: ProposalDetailSheetProps) { + const agentGate = useAgentGate(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const handleKeyDown = useCallback( @@ -191,8 +193,9 @@ export const ProposalDetailSheet = React.memo(function ProposalDetailSheet({ style={{ color: "var(--text-muted)" }} onMouseEnter={(e) => { e.currentTarget.style.background = "var(--overlay-weak)"; e.currentTarget.style.color = "var(--text-secondary)"; }} onMouseLeave={(e) => { e.currentTarget.style.background = "transparent"; e.currentTarget.style.color = "var(--text-muted)"; }} - onClick={() => onEdit(proposal.id)} - title="Edit proposal" + disabled={agentGate.gated} + onClick={() => { if (agentGate.gated) return; onEdit(proposal.id); }} + title={agentGate.reason ?? "Edit proposal"} > diff --git a/frontend/src/components/PermissionDialog.tsx b/frontend/src/components/PermissionDialog.tsx index 0ebe8c4406..d9495561e3 100644 --- a/frontend/src/components/PermissionDialog.tsx +++ b/frontend/src/components/PermissionDialog.tsx @@ -10,6 +10,8 @@ import { DialogDescription, DialogFooter, } from "@/components/ui/dialog"; +import { AgentGateTooltip } from "@/components/remote/AgentGateTooltip"; +import { useAgentGate } from "@/hooks/useAgentGate"; import { Button } from "@/components/ui/button"; import { AlertTriangle, Shield, Terminal } from "lucide-react"; import { useTaskStore } from "@/stores/taskStore"; @@ -75,6 +77,7 @@ export function PermissionDialog() { const [requests, setRequests] = useState([]); // D8: track WHICH request is being resolved, not just a boolean const [resolvingId, setResolvingId] = useState(null); + const agentGate = useAgentGate(); const eventBus = useEventBus(); const currentRequest = requests[0]; @@ -197,6 +200,10 @@ export function PermissionDialog() { const handleDecision = async (decision: "allow" | "deny") => { if (!currentRequest) return; + // Approving authorizes a live tool call, so it needs `ui:agent`. Denying is + // authority-REDUCING and stays available to every paired device — including the + // dismiss-as-deny path below, which is a user's fastest way to stop something. + if (decision === "allow" && agentGate.gated) return; // D8: set resolvingId to current request's ID setResolvingId(currentRequest.request_id); @@ -400,10 +407,19 @@ export function PermissionDialog() { > Deny - + + + diff --git a/frontend/src/components/agents/AgentComposerSurface.tsx b/frontend/src/components/agents/AgentComposerSurface.tsx index 3c9bdaec8a..58d50ca1ea 100644 --- a/frontend/src/components/agents/AgentComposerSurface.tsx +++ b/frontend/src/components/agents/AgentComposerSurface.tsx @@ -46,6 +46,8 @@ import { import type { ChatComposerFolder } from "@/stores/chatStore"; import type { CapabilityIntent, TeamIntent } from "@/api/chat"; import type { AgentStatus } from "@/stores/chatStore"; +import { useAgentGate } from "@/hooks/useAgentGate"; +import { AgentGateTooltip } from "@/components/remote/AgentGateTooltip"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { ChatAttachmentDropOverlay } from "@/components/Chat/ChatAttachmentDropOverlay"; @@ -462,10 +464,18 @@ export function AgentComposerSurface({ const emptySubmitValue = emptySubmitMessage?.trim() ?? ""; const hasSubmittableValue = value.trim().length > 0 || emptySubmitValue.length > 0; + /** + * Sending a message steers the agent, so it needs `ui:agent` (2.6-b). Folded into + * the EXISTING `sendDisabledReason` seam rather than added beside it: one reason + * string means one disabled-explanation path, and the keyboard submit at the top + * of `handleSend` is gated by the same value as the button. + */ + const agentGate = useAgentGate(); + const effectiveSendDisabledReason = agentGate.reason ?? sendDisabledReason; const canSubmit = hasSubmittableValue && !isReadOnly && - !sendDisabledReason && + !effectiveSendDisabledReason && (!isSubmitting || canSendWhileAgentActive); const attachmentDisabled = isReadOnly || (isSubmitting && !canSendWhileAgentActive); @@ -1593,7 +1603,11 @@ export function AgentComposerSurface({ return; } - if ((isSubmitting && !canSendWhileAgentActive) || isReadOnly || sendDisabledReason) { + if ( + (isSubmitting && !canSendWhileAgentActive) || + isReadOnly || + effectiveSendDisabledReason + ) { return; } @@ -2129,6 +2143,12 @@ export function AgentComposerSurface({ )} + + diff --git a/frontend/src/components/agents/AgentsAutomationPanel.tsx b/frontend/src/components/agents/AgentsAutomationPanel.tsx index ceecfad991..c3e987b263 100644 --- a/frontend/src/components/agents/AgentsAutomationPanel.tsx +++ b/frontend/src/components/agents/AgentsAutomationPanel.tsx @@ -1,4 +1,5 @@ import { useCallback, useMemo, useState, type ReactNode } from "react"; +import { useAgentGate } from "@/hooks/useAgentGate"; import { CheckCircle2, ExternalLink, @@ -642,6 +643,15 @@ export function AgentsAutomationPanel({ onOpenAutomation, onFocusAutomationRun, }: AgentsAutomationPanelProps) { + /** + * Running, restarting, and resuming an automation all ARM autonomous work — the + * clearest case of agent steering (2.6-b). Pause and stop are authority-reducing + * and stay available to a paired device under the brakes boundary. + * + * Declared before the loading/error early returns: hooks must run in the same + * order on every render. + */ + const agentGate = useAgentGate(); const afterPaint = useAfterPaintMounted(Boolean(automationId)); const detail = useAutomationDetail(automationId, { enabled: afterPaint }); const queryClient = useQueryClient(); @@ -1100,6 +1110,8 @@ export function AgentsAutomationPanel({ const judgeRecovery = getAutomationJudgeRecovery(automation, run); const showPausedReason = !failureReason && automation.status === "paused" && Boolean(automation.pausedReasonCode); + const armingDisabled = agentGate.gated; + const actionPending = pauseMutation.isPending || resumeMutation.isPending || @@ -1407,7 +1419,8 @@ export function AgentsAutomationPanel({ type="button" variant="secondary" size="sm" - disabled={actionPending} + disabled={actionPending || armingDisabled} + title={agentGate.reason ?? undefined} onClick={() => runNowMutation.mutate()} > Run now @@ -1448,7 +1461,8 @@ export function AgentsAutomationPanel({ variant="secondary" size="sm" className="gap-2" - disabled={actionPending} + disabled={actionPending || armingDisabled} + title={agentGate.reason ?? undefined} onClick={() => resumeMutation.mutate()} data-testid="agents-automation-resume" > @@ -1476,7 +1490,8 @@ export function AgentsAutomationPanel({ variant="secondary" size="sm" className="gap-2" - disabled={actionPending} + disabled={actionPending || armingDisabled} + title={agentGate.reason ?? undefined} onClick={() => restartMutation.mutate()} data-testid="agents-automation-restart" > diff --git a/frontend/src/components/agents/task-details/StepList.tsx b/frontend/src/components/agents/task-details/StepList.tsx index 83cd2e1d6d..62710de295 100644 --- a/frontend/src/components/agents/task-details/StepList.tsx +++ b/frontend/src/components/agents/task-details/StepList.tsx @@ -5,6 +5,7 @@ * Supports editing and deletion when editable=true. */ +import { useAgentGate } from "@/hooks/useAgentGate"; import { ListChecks } from 'lucide-react'; import { StepItem } from './StepItem'; import { Skeleton } from '@/components/ui/skeleton'; @@ -32,6 +33,8 @@ interface StepListProps { export function StepList({ taskId, editable = false, hideCompletionNotes = false }: StepListProps) { const { data: steps, isLoading, isError } = useTaskSteps(taskId); const { skip: skipStep } = useStepMutations(taskId); + // Skipping a step re-plans work in flight — `ui:agent` (2.6-b). + const agentGate = useAgentGate(); // Loading state if (isLoading) { @@ -77,7 +80,7 @@ export function StepList({ taskId, editable = false, hideCompletionNotes = false index, editable, hideCompletionNote: hideCompletionNotes, - ...(editable && { onSkip: (stepId: string) => skipStep.mutate({ stepId, reason: "Skipped by user" }) }), + ...(editable && !agentGate.gated && { onSkip: (stepId: string) => skipStep.mutate({ stepId, reason: "Skipped by user" }) }), }; return (
diff --git a/frontend/src/components/agents/task-details/TaskEditForm.tsx b/frontend/src/components/agents/task-details/TaskEditForm.tsx index 0da3c85bd6..135a7d27ee 100644 --- a/frontend/src/components/agents/task-details/TaskEditForm.tsx +++ b/frontend/src/components/agents/task-details/TaskEditForm.tsx @@ -13,6 +13,7 @@ * Design spec: specs/design/refined-studio-patterns.md */ +import { useAgentGate } from "@/hooks/useAgentGate"; import { useState, useCallback, type FormEvent } from "react"; import { UpdateTaskSchema, type Task, type UpdateTask } from "@/types/task"; import { ACTIVE_STATUSES } from "@/types/status"; @@ -65,6 +66,18 @@ export function TaskEditForm({ // Check if task is executing (steps are editable only when not executing) const isExecuting = ACTIVE_STATUSES.includes(task.internalStatus); + /** + * Title and description are AGENT-CONSUMED content — editing them re-aims work in + * flight — so they need `ui:agent` (2.6-b). Category and priority are inert under + * the viewer-with-brakes boundary (A6). + * + * Both halves share one `update_task` call, so the gate has to be argument-level, + * not command-level: the gated fields are stripped from the diff here AND their + * inputs are disabled, so neither the form nor a stale state value can smuggle a + * title change into an otherwise-inert priority edit. + */ + const agentGate = useAgentGate(); + const handleSubmit = useCallback( (e: FormEvent) => { e.preventDefault(); @@ -73,7 +86,7 @@ export function TaskEditForm({ // Build update data (only include changed fields) const updateData: UpdateTask = {}; - if (title.trim() !== task.title) { + if (!agentGate.gated && title.trim() !== task.title) { updateData.title = title.trim(); } @@ -82,7 +95,7 @@ export function TaskEditForm({ } const descValue = description.trim() || null; - if (descValue !== task.description) { + if (!agentGate.gated && descValue !== task.description) { updateData.description = descValue; } @@ -105,14 +118,15 @@ export function TaskEditForm({ onSave(updateData); }, - [title, category, description, priority, task, onSave, onCancel] + [agentGate.gated, title, category, description, priority, task, onSave, onCancel] ); - const hasChanges = - title.trim() !== task.title || - category !== task.category || - (description.trim() || null) !== task.description || - priority !== task.priority; + const hasChanges = agentGate.gated + ? category !== task.category || priority !== task.priority + : title.trim() !== task.title || + category !== task.category || + (description.trim() || null) !== task.description || + priority !== task.priority; const handleAddStep = useCallback(async () => { if (!newStepTitle.trim()) return; @@ -141,6 +155,8 @@ export function TaskEditForm({ priority={priority} setPriority={setPriority} disabled={isSaving} + contentDisabled={agentGate.gated} + contentDisabledReason={agentGate.reason} validationError={validationError} /> @@ -188,7 +204,9 @@ export function TaskEditForm({
{unblockMutation.error && (

diff --git a/frontend/src/components/tasks/detail-views/EscalatedTaskDetail.tsx b/frontend/src/components/tasks/detail-views/EscalatedTaskDetail.tsx index 2d1824ea9b..7a34de20a5 100644 --- a/frontend/src/components/tasks/detail-views/EscalatedTaskDetail.tsx +++ b/frontend/src/components/tasks/detail-views/EscalatedTaskDetail.tsx @@ -9,6 +9,8 @@ import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; import { markdownComponents } from "@/components/Chat/MessageItem.markdown"; import { useMutation, useQueryClient, useQuery } from "@tanstack/react-query"; +import { useAgentGate } from "@/hooks/useAgentGate"; +import { AgentGateTooltip } from "@/components/remote/AgentGateTooltip"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { @@ -257,6 +259,8 @@ function DecisionButtonsCard({ } }; + const agentGate = useAgentGate(); + const handleApprove = useCallback(async () => { const confirmed = await confirm({ title: "Approve despite concerns?", @@ -294,10 +298,15 @@ function DecisionButtonsCard({ ); const approveButton = ( +