From 4b4d08dd3d3305c97b85f6f95c4c86480d16ac7a Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:44:51 -0700 Subject: [PATCH 1/2] feat: add server-owned machine identities --- crates/freshell-server/src/machines.rs | 734 ++++++++++++++++++ crates/freshell-server/src/machines_tests.rs | 382 +++++++++ crates/freshell-server/src/main.rs | 50 +- .../freshell-server/src/recovery_inventory.rs | 34 +- .../src/recovery_inventory_tests.rs | 98 +++ crates/freshell-ws/src/tabs.rs | 188 ++++- crates/freshell-ws/src/tabs_tests.rs | 94 +++ crates/freshell-ws/src/terminal.rs | 98 ++- 8 files changed, 1645 insertions(+), 33 deletions(-) create mode 100644 crates/freshell-server/src/machines.rs create mode 100644 crates/freshell-server/src/machines_tests.rs diff --git a/crates/freshell-server/src/machines.rs b/crates/freshell-server/src/machines.rs new file mode 100644 index 000000000..b26f4bcdb --- /dev/null +++ b/crates/freshell-server/src/machines.rs @@ -0,0 +1,734 @@ +//! Server-owned machine identity directory. +//! +//! The tab-sync protocol still calls this identity `deviceId` for wire +//! compatibility. This module is the authoritative, durable directory that +//! turns those legacy device IDs into server-owned machine records without +//! changing an existing tab key or snapshot path. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex}; + +use axum::extract::{Path as AxumPath, State}; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::{get, patch}; +use axum::{Json, Router}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use uuid::Uuid; + +use crate::boot::{is_authed, unauthorized}; + +const MACHINE_DIRECTORY_VERSION: u32 = 1; +const FALLBACK_LEGACY_LABEL: &str = "Recovered device"; + +/// The public machine record returned by `/api/machines`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Machine { + pub id: String, + pub label: String, + pub created_at: i64, + pub last_seen_at: i64, +} + +/// An exact historical device identifier discovered before the machine +/// directory begins owning new IDs. The caller supplies candidates from both +/// the compact tabs registry and the immutable snapshot directories. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LegacyMachineCandidate { + pub id: String, + pub label: String, + pub last_seen_at: i64, +} + +impl LegacyMachineCandidate { + pub fn new(id: impl Into, label: impl Into, last_seen_at: i64) -> Self { + Self { + id: id.into(), + label: label.into(), + last_seen_at, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct MachineDirectoryFile { + version: u32, + machines: Vec, +} + +impl Default for MachineDirectoryFile { + fn default() -> Self { + Self { + version: MACHINE_DIRECTORY_VERSION, + machines: Vec::new(), + } + } +} + +#[derive(Clone)] +struct MachineStorePaths { + state: PathBuf, + backup: PathBuf, + state_tmp: PathBuf, + backup_tmp: PathBuf, +} + +impl MachineStorePaths { + fn new(root: PathBuf) -> Self { + let v1 = root.join("v1"); + Self { + state: v1.join("state.json"), + backup: v1.join("state.json.bak"), + state_tmp: v1.join("state.json.tmp"), + backup_tmp: v1.join("state.json.bak.tmp"), + } + } + + fn ensure_parent(&self) -> std::io::Result<()> { + let parent = self + .state + .parent() + .expect("machine state path always has a parent"); + std::fs::create_dir_all(parent) + } +} + +/// Load/persistence failures for the machine directory. The caller receives a +/// clear boot failure instead of silently changing historical identities. +#[derive(Debug)] +pub enum MachineStoreError { + Io(std::io::Error), + Invalid(String), +} + +impl std::fmt::Display for MachineStoreError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Io(error) => write!(f, "machine directory I/O error: {error}"), + Self::Invalid(message) => write!(f, "machine directory is invalid: {message}"), + } + } +} + +impl std::error::Error for MachineStoreError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Io(error) => Some(error), + Self::Invalid(_) => None, + } + } +} + +impl From for MachineStoreError { + fn from(error: std::io::Error) -> Self { + Self::Io(error) + } +} + +#[derive(Debug)] +pub enum MachineMutationError { + NotFound, + InvalidLabel, + Storage(MachineStoreError), +} + +impl std::fmt::Display for MachineMutationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotFound => write!(f, "machine not found"), + Self::InvalidLabel => write!(f, "machine label must not be empty"), + Self::Storage(error) => error.fmt(f), + } + } +} + +/// Cloneable process-local handle over one machine directory. Disk mutations +/// hold the mutex until their atomic publish completes, so an acknowledged +/// create/rename/touch is always the state a later request observes. +#[derive(Clone)] +pub struct MachineStore { + state: Arc>, + paths: Option>, +} + +impl MachineStore { + /// Open the durable directory and import exact historical IDs before the + /// server exposes either REST or WebSocket service. `None` is the + /// deliberately ephemeral no-home mode used by isolated tests. + pub fn open( + root: Option, + legacy_candidates: Vec, + ) -> Result { + let paths = root.map(MachineStorePaths::new).map(Arc::new); + let (mut state, mut needs_publish) = match paths.as_deref() { + Some(paths) => load_directory(paths)?, + None => (MachineDirectoryFile::default(), false), + }; + + needs_publish |= normalize_directory(&mut state)?; + let (imported, legacy_state_changed) = + import_legacy_candidates(&mut state, legacy_candidates); + needs_publish |= legacy_state_changed; + + if needs_publish { + if let Some(paths) = paths.as_deref() { + // A recovered backup must remain the fallback until a later, + // healthy mutation succeeds; never replace it with the corrupt + // primary bytes we just recovered from. + persist_directory(paths, &state, false)?; + } + } + + if imported > 0 { + tracing::info!( + target: "freshell_server::machines", + event = "machine_directory_legacy_imported", + imported, + total = state.machines.len(), + "machine directory imported legacy identities" + ); + } + + Ok(Self { + state: Arc::new(Mutex::new(state)), + paths, + }) + } + + /// All known machines, newest activity first. The stable tie-breaks keep + /// an empty chooser deterministic across refreshes. + pub fn list(&self) -> Vec { + let mut machines = lock_unpoisoned(&self.state).machines.clone(); + machines.sort_by(|left, right| { + right + .last_seen_at + .cmp(&left.last_seen_at) + .then_with(|| right.created_at.cmp(&left.created_at)) + .then_with(|| left.id.cmp(&right.id)) + }); + machines + } + + /// Server-mint a new identity. Client-supplied labels are suggestions; + /// labels are normalized and made unique on the server. + pub fn create(&self, requested_label: &str) -> Result { + let base = + normalized_nonempty_label(requested_label).ok_or(MachineMutationError::InvalidLabel)?; + let now = now_ms(); + let mut guard = lock_unpoisoned(&self.state); + let mut next = guard.clone(); + let occupied = occupied_labels(&next.machines, None); + let machine = Machine { + id: Uuid::new_v4().to_string(), + label: unique_label(&base, &occupied), + created_at: now, + last_seen_at: now, + }; + next.machines.push(machine.clone()); + self.persist(&next).map_err(MachineMutationError::Storage)?; + *guard = next; + tracing::info!( + target: "freshell_server::machines", + event = "machine_created", + machine_id = %machine.id, + label = %machine.label, + "machine created" + ); + Ok(machine) + } + + pub fn rename( + &self, + machine_id: &str, + requested_label: &str, + ) -> Result { + let base = + normalized_nonempty_label(requested_label).ok_or(MachineMutationError::InvalidLabel)?; + let now = now_ms(); + let mut guard = lock_unpoisoned(&self.state); + let mut next = guard.clone(); + let Some(index) = next + .machines + .iter() + .position(|machine| machine.id == machine_id) + else { + return Err(MachineMutationError::NotFound); + }; + let occupied = occupied_labels(&next.machines, Some(machine_id)); + let machine = &mut next.machines[index]; + machine.label = unique_label(&base, &occupied); + machine.last_seen_at = now.max(machine.created_at); + let updated = machine.clone(); + self.persist(&next).map_err(MachineMutationError::Storage)?; + *guard = next; + tracing::info!( + target: "freshell_server::machines", + event = "machine_renamed", + machine_id = %updated.id, + label = %updated.label, + "machine renamed" + ); + Ok(updated) + } + + /// Resolve an existing machine for tab sync, stamp its canonical label, + /// and record activity. Returning `None` is intentional: callers reject + /// an unknown legacy `deviceId` rather than silently creating it. + pub fn resolve_for_tab_sync( + &self, + machine_id: &str, + ) -> Result, MachineStoreError> { + let now = now_ms(); + let mut guard = lock_unpoisoned(&self.state); + let Some(index) = guard + .machines + .iter() + .position(|machine| machine.id == machine_id) + else { + return Ok(None); + }; + let mut next = guard.clone(); + let machine = &mut next.machines[index]; + machine.last_seen_at = now.max(machine.last_seen_at); + let label = machine.label.clone(); + if next != *guard { + self.persist(&next)?; + *guard = next; + } + Ok(Some(label)) + } + + fn persist(&self, state: &MachineDirectoryFile) -> Result<(), MachineStoreError> { + if let Some(paths) = self.paths.as_deref() { + persist_directory(paths, state, true)?; + } + Ok(()) + } +} + +impl freshell_ws::tabs::MachineIdentityStore for MachineStore { + fn resolve_for_tab_sync(&self, machine_id: &str) -> Result, String> { + self.resolve_for_tab_sync(machine_id) + .map_err(|error| error.to_string()) + } + + fn machine_exists(&self, machine_id: &str) -> bool { + lock_unpoisoned(&self.state) + .machines + .iter() + .any(|machine| machine.id == machine_id) + } +} + +/// Collect exact historical IDs from both durable tabs sources. Snapshot files +/// are read-only here: importing a machine never rewrites, renames, or moves a +/// snapshot directory, which keeps `deviceId:tabId` provenance intact. +pub fn legacy_machine_candidates( + tabs: &freshell_ws::tabs::TabsRegistry, + snapshots_dir: Option<&Path>, +) -> Result, MachineStoreError> { + let mut candidates: Vec = tabs + .legacy_device_metadata() + .into_iter() + .map(|device| { + LegacyMachineCandidate::new(device.device_id, device.device_label, device.last_seen_at) + }) + .collect(); + + let Some(snapshots_dir) = snapshots_dir else { + return Ok(candidates); + }; + for device_id in freshell_ws::tabs_persist::list_snapshot_devices(snapshots_dir)? { + let Some((union, _generations)) = + freshell_ws::tabs_persist::read_device_overview(snapshots_dir, &device_id)? + else { + continue; + }; + let label = union + .get("deviceLabel") + .and_then(Value::as_str) + .unwrap_or(FALLBACK_LEGACY_LABEL); + let last_seen_at = union.get("capturedAt").and_then(Value::as_i64).unwrap_or(0); + candidates.push(LegacyMachineCandidate::new(device_id, label, last_seen_at)); + } + Ok(candidates) +} + +#[derive(Clone)] +pub struct MachinesState { + pub auth_token: Arc, + pub store: MachineStore, +} + +/// Public REST contract used by the startup chooser and machine settings. +pub fn router(state: MachinesState) -> Router { + Router::new() + .route("/api/machines", get(list_machines).post(create_machine)) + .route("/api/machines/{machine_id}", patch(rename_machine)) + .with_state(state) +} + +async fn list_machines(State(state): State, headers: HeaderMap) -> Response { + if !is_authed(&headers, &state.auth_token) { + return unauthorized(); + } + Json(json!({ "machines": state.store.list() })).into_response() +} + +async fn create_machine( + State(state): State, + headers: HeaderMap, + Json(body): Json, +) -> Response { + if !is_authed(&headers, &state.auth_token) { + return unauthorized(); + } + let label = match body.get("label").and_then(Value::as_str) { + Some(label) => label.to_string(), + None => return invalid_machine_label(), + }; + let store = state.store.clone(); + match tokio::task::spawn_blocking(move || store.create(&label)).await { + Ok(Ok(machine)) => Json(json!({ "machine": machine })).into_response(), + Ok(Err(error)) => mutation_error_response(error), + Err(error) => { + tracing::error!(target: "freshell_server::machines", error = %error, + "machine_create_task_failed"); + machine_store_unavailable() + } + } +} + +async fn rename_machine( + State(state): State, + AxumPath(machine_id): AxumPath, + headers: HeaderMap, + Json(body): Json, +) -> Response { + if !is_authed(&headers, &state.auth_token) { + return unauthorized(); + } + let label = match body.get("label").and_then(Value::as_str) { + Some(label) => label.to_string(), + None => return invalid_machine_label(), + }; + let store = state.store.clone(); + match tokio::task::spawn_blocking(move || store.rename(&machine_id, &label)).await { + Ok(Ok(machine)) => Json(json!({ "machine": machine })).into_response(), + Ok(Err(error)) => mutation_error_response(error), + Err(error) => { + tracing::error!(target: "freshell_server::machines", error = %error, + "machine_rename_task_failed"); + machine_store_unavailable() + } + } +} + +fn invalid_machine_label() -> Response { + ( + StatusCode::BAD_REQUEST, + Json(json!({ "error": "machine label must be a non-empty string" })), + ) + .into_response() +} + +fn machine_store_unavailable() -> Response { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({ "error": "machine directory unavailable" })), + ) + .into_response() +} + +fn mutation_error_response(error: MachineMutationError) -> Response { + match error { + MachineMutationError::NotFound => ( + StatusCode::NOT_FOUND, + Json(json!({ "error": "machine not found" })), + ) + .into_response(), + MachineMutationError::InvalidLabel => invalid_machine_label(), + MachineMutationError::Storage(error) => { + tracing::error!(target: "freshell_server::machines", error = %error, + "machine_directory_write_failed"); + machine_store_unavailable() + } + } +} + +fn load_directory( + paths: &MachineStorePaths, +) -> Result<(MachineDirectoryFile, bool), MachineStoreError> { + paths.ensure_parent()?; + match read_directory_file(&paths.state) { + Ok(Some(directory)) => Ok((directory, false)), + Ok(None) => match read_directory_file(&paths.backup) { + Ok(Some(directory)) => { + tracing::warn!( + target: "freshell_server::machines", + event = "machine_directory_recovered_from_backup", + state_path = %paths.state.display(), + "machine directory primary file missing; recovered backup" + ); + Ok((directory, true)) + } + Ok(None) => Ok((MachineDirectoryFile::default(), false)), + Err(error) => reset_from_unreadable_state(paths, error), + }, + Err(primary_error) => match read_directory_file(&paths.backup) { + Ok(Some(directory)) => { + archive_unreadable_primary(paths, &primary_error)?; + tracing::warn!( + target: "freshell_server::machines", + event = "machine_directory_recovered_from_backup", + state_path = %paths.state.display(), + error = %primary_error, + "machine directory primary file unreadable; recovered backup" + ); + Ok((directory, true)) + } + Ok(None) | Err(_) => reset_from_unreadable_state(paths, primary_error), + }, + } +} + +fn reset_from_unreadable_state( + paths: &MachineStorePaths, + error: MachineStoreError, +) -> Result<(MachineDirectoryFile, bool), MachineStoreError> { + archive_unreadable_primary(paths, &error)?; + Ok((MachineDirectoryFile::default(), true)) +} + +fn archive_unreadable_primary( + paths: &MachineStorePaths, + error: &MachineStoreError, +) -> Result<(), MachineStoreError> { + if paths.state.exists() { + let archived = paths.state.with_file_name(format!( + "state.json.corrupt-{}-{}", + now_ms(), + std::process::id() + )); + std::fs::rename(&paths.state, &archived)?; + tracing::error!( + target: "freshell_server::machines", + event = "machine_directory_corrupt_state_archived", + state_path = %paths.state.display(), + archived_path = %archived.display(), + error = %error, + "machine directory unreadable state archived before recovery" + ); + } + Ok(()) +} + +fn read_directory_file(path: &Path) -> Result, MachineStoreError> { + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => return Err(MachineStoreError::Io(error)), + }; + serde_json::from_slice(&bytes) + .map(Some) + .map_err(|error| MachineStoreError::Invalid(format!("{}: {error}", path.display()))) +} + +fn persist_directory( + paths: &MachineStorePaths, + directory: &MachineDirectoryFile, + preserve_previous: bool, +) -> Result<(), MachineStoreError> { + paths.ensure_parent()?; + if preserve_previous { + if let Ok(Some(_)) = read_directory_file(&paths.state) { + let bytes = std::fs::read(&paths.state)?; + freshell_ws::tabs_persist::atomic_write_durable( + &paths.backup, + &paths.backup_tmp, + &bytes, + )?; + } + } + let bytes = serde_json::to_vec(directory) + .map_err(|error| MachineStoreError::Invalid(error.to_string()))?; + freshell_ws::tabs_persist::atomic_write_durable(&paths.state, &paths.state_tmp, &bytes)?; + Ok(()) +} + +fn normalize_directory(directory: &mut MachineDirectoryFile) -> Result { + if directory.version != MACHINE_DIRECTORY_VERSION { + return Err(MachineStoreError::Invalid(format!( + "unsupported version {}", + directory.version + ))); + } + let mut changed = false; + let mut ids = HashSet::new(); + let mut labels = HashSet::new(); + for machine in &mut directory.machines { + if machine.id.is_empty() { + return Err(MachineStoreError::Invalid( + "machine ID must not be empty".to_string(), + )); + } + if !ids.insert(machine.id.clone()) { + return Err(MachineStoreError::Invalid(format!( + "duplicate machine ID {}", + machine.id + ))); + } + if machine.created_at < 0 || machine.last_seen_at < 0 { + return Err(MachineStoreError::Invalid(format!( + "machine {} has a negative timestamp", + machine.id + ))); + } + if machine.last_seen_at < machine.created_at { + machine.last_seen_at = machine.created_at; + changed = true; + } + let normalized = normalized_nonempty_label(&machine.label) + .unwrap_or_else(|| FALLBACK_LEGACY_LABEL.to_string()); + let unique = unique_label(&normalized, &labels); + if unique != machine.label { + machine.label = unique.clone(); + changed = true; + } + labels.insert(unique.to_lowercase()); + } + Ok(changed) +} + +fn import_legacy_candidates( + directory: &mut MachineDirectoryFile, + candidates: Vec, +) -> (usize, bool) { + let mut imported = 0; + let mut changed = false; + let mut labels = occupied_labels(&directory.machines, None); + // Once an administrator has named a persisted machine, its label is + // authoritative. Only duplicate historical candidates discovered during + // THIS import can refine an initially inferred label. + let persisted_ids: HashSet = directory + .machines + .iter() + .map(|machine| machine.id.clone()) + .collect(); + for candidate in candidates { + if candidate.id.is_empty() { + tracing::warn!( + target: "freshell_server::machines", + event = "machine_directory_legacy_candidate_ignored", + "ignored legacy machine candidate with an empty ID" + ); + continue; + } + if let Some(index) = directory + .machines + .iter() + .position(|machine| machine.id == candidate.id) + { + let existing_seen_at = directory.machines[index].last_seen_at; + let existing_label = directory.machines[index].label.clone(); + let candidate_seen_at = candidate.last_seen_at.max(0); + let candidate_label = normalized_nonempty_label(&candidate.label); + let candidate_label_quality = candidate_label + .as_deref() + .map(legacy_label_quality) + .unwrap_or(0); + let existing_label_quality = legacy_label_quality(&existing_label); + let should_replace_label = !persisted_ids.contains(&candidate.id) + && candidate_label.is_some() + && (candidate_label_quality > existing_label_quality + || (candidate_seen_at > existing_seen_at + && candidate_label_quality >= existing_label_quality)); + if should_replace_label { + let occupied = occupied_labels(&directory.machines, Some(&candidate.id)); + let next_label = unique_label( + candidate_label.as_deref().expect("checked above"), + &occupied, + ); + labels.remove(&existing_label.to_lowercase()); + labels.insert(next_label.to_lowercase()); + directory.machines[index].label = next_label; + changed = true; + } + let next_seen_at = existing_seen_at.max(candidate_seen_at); + if next_seen_at != existing_seen_at { + directory.machines[index].last_seen_at = next_seen_at; + changed = true; + } + continue; + } + let label = normalized_nonempty_label(&candidate.label) + .unwrap_or_else(|| FALLBACK_LEGACY_LABEL.to_string()); + let label = unique_label(&label, &labels); + labels.insert(label.to_lowercase()); + let seen = candidate.last_seen_at.max(0); + directory.machines.push(Machine { + id: candidate.id, + label, + created_at: seen, + last_seen_at: seen, + }); + imported += 1; + changed = true; + } + (imported, changed) +} + +fn normalized_nonempty_label(label: &str) -> Option { + let normalized = label.split_whitespace().collect::>().join(" "); + (!normalized.is_empty()).then_some(normalized) +} + +fn legacy_label_quality(label: &str) -> u8 { + match normalized_nonempty_label(label).as_deref() { + None => 0, + Some(FALLBACK_LEGACY_LABEL) => 1, + Some(_) => 2, + } +} + +fn occupied_labels(machines: &[Machine], excluded_id: Option<&str>) -> HashSet { + machines + .iter() + .filter(|machine| Some(machine.id.as_str()) != excluded_id) + .map(|machine| machine.label.to_lowercase()) + .collect() +} + +fn unique_label(base: &str, occupied: &HashSet) -> String { + if !occupied.contains(&base.to_lowercase()) { + return base.to_string(); + } + for suffix in 2_u64.. { + let candidate = format!("{base} {suffix}"); + if !occupied.contains(&candidate.to_lowercase()) { + return candidate; + } + } + unreachable!("u64 label suffixes cannot be exhausted") +} + +fn lock_unpoisoned(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + mutex + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +#[path = "machines_tests.rs"] +mod tests; diff --git a/crates/freshell-server/src/machines_tests.rs b/crates/freshell-server/src/machines_tests.rs new file mode 100644 index 000000000..c4afb9f9d --- /dev/null +++ b/crates/freshell-server/src/machines_tests.rs @@ -0,0 +1,382 @@ +use super::*; +use axum::body::Body; +use axum::http::{Method, Request, StatusCode}; +use serde_json::json; +use tower::ServiceExt; + +#[test] +fn legacy_machine_ids_are_imported_exactly_and_survive_a_restart() { + let home = tempfile::tempdir().unwrap(); + let candidates = vec![ + LegacyMachineCandidate::new("legacy-registry-id", "Desktop", 10), + LegacyMachineCandidate::new("legacy-snapshot-id", "Desktop", 20), + ]; + + let store = MachineStore::open( + Some(home.path().join(".freshell").join("machines")), + candidates, + ) + .expect("legacy identities import"); + let initial = store.list(); + assert_eq!( + initial + .iter() + .map(|machine| machine.id.as_str()) + .collect::>(), + vec!["legacy-snapshot-id", "legacy-registry-id"], + "legacy IDs are machine IDs, never rewritten UUIDs" + ); + assert_eq!(initial[0].label, "Desktop 2"); + assert_eq!(initial[1].label, "Desktop"); + + drop(store); + let reopened = MachineStore::open( + Some(home.path().join(".freshell").join("machines")), + Vec::new(), + ) + .expect("durable machine directory reloads"); + assert_eq!( + reopened + .list() + .iter() + .map(|machine| machine.id.as_str()) + .collect::>(), + vec!["legacy-snapshot-id", "legacy-registry-id"] + ); +} + +#[test] +fn creates_server_minted_ids_and_deduplicates_requested_labels() { + let store = MachineStore::open(None, Vec::new()).expect("in-memory store"); + let first = store.create("Windows device").expect("first machine"); + let second = store.create(" Windows device ").expect("second machine"); + + assert!(uuid::Uuid::parse_str(&first.id).is_ok(), "{}", first.id); + assert!(uuid::Uuid::parse_str(&second.id).is_ok(), "{}", second.id); + assert_ne!(first.id, second.id); + assert_eq!(first.label, "Windows device"); + assert_eq!(second.label, "Windows device 2"); + + let renamed = store.rename(&second.id, "Desk").expect("rename machine"); + assert_eq!(renamed.label, "Desk"); + assert!(renamed.last_seen_at >= renamed.created_at); +} + +#[test] +fn tab_sync_resolution_returns_the_canonical_label_and_updates_last_seen_at() { + let store = MachineStore::open( + None, + vec![LegacyMachineCandidate::new("legacy-machine", "Desktop", 1)], + ) + .expect("in-memory store"); + let before = store.list().pop().expect("imported machine"); + + let label = store + .resolve_for_tab_sync("legacy-machine") + .expect("directory read") + .expect("legacy machine remains known"); + let after = store.list().pop().expect("machine remains present"); + + assert_eq!(label, "Desktop"); + assert!( + after.last_seen_at > before.last_seen_at, + "an accepted tab-sync identity lookup records fresh activity" + ); +} + +#[test] +fn duplicate_legacy_sources_keep_the_exact_id_and_newest_real_label() { + let store = MachineStore::open( + None, + vec![ + LegacyMachineCandidate::new("legacy-machine", "Old compact label", 10), + LegacyMachineCandidate::new("legacy-machine", "Most recent snapshot label", 20), + ], + ) + .expect("in-memory store"); + + assert_eq!( + store.list(), + vec![Machine { + id: "legacy-machine".to_string(), + label: "Most recent snapshot label".to_string(), + created_at: 10, + last_seen_at: 20, + }] + ); +} + +#[test] +fn later_legacy_imports_never_override_a_persisted_machine_rename() { + let home = tempfile::tempdir().unwrap(); + let root = home.path().join(".freshell").join("machines"); + let initial = MachineStore::open( + Some(root.clone()), + vec![LegacyMachineCandidate::new( + "legacy-machine", + "Old label", + 10, + )], + ) + .unwrap(); + let renamed = initial.rename("legacy-machine", "Chosen label").unwrap(); + drop(initial); + + let reopened = MachineStore::open( + Some(root), + vec![LegacyMachineCandidate::new( + "legacy-machine", + "Newer snapshot label", + 20, + )], + ) + .unwrap(); + let machine = reopened.list().pop().unwrap(); + assert_eq!(machine.id, "legacy-machine"); + assert_eq!(machine.label, "Chosen label"); + assert_eq!(machine.created_at, 10); + assert!(machine.last_seen_at >= renamed.last_seen_at); +} + +#[test] +fn recovers_a_valid_backup_without_losing_the_machine_directory_to_a_corrupt_primary() { + let home = tempfile::tempdir().unwrap(); + let root = home.path().join(".freshell").join("machines"); + let store = MachineStore::open(Some(root.clone()), Vec::new()).unwrap(); + let first = store.create("Primary desktop").unwrap(); + let _second = store.create("Garage server").unwrap(); + drop(store); + + let state_path = root.join("v1").join("state.json"); + let backup_path = root.join("v1").join("state.json.bak"); + assert!(state_path.is_file()); + assert!( + backup_path.is_file(), + "the previous durable state is retained" + ); + std::fs::write(&state_path, b"{not valid json").unwrap(); + + let recovered = MachineStore::open(Some(root.clone()), Vec::new()).unwrap(); + let machines = recovered.list(); + assert_eq!(machines.len(), 1, "recovery uses the last valid backup"); + assert_eq!(machines[0].id, first.id); + assert!( + std::fs::read_to_string(&state_path) + .unwrap() + .contains(&first.id), + "the recovered state is atomically restored as the new primary" + ); + assert!( + std::fs::read_dir(root.join("v1")) + .unwrap() + .flatten() + .any(|entry| entry + .file_name() + .to_string_lossy() + .starts_with("state.json.corrupt-")), + "the unreadable primary is preserved for diagnosis" + ); +} + +#[test] +fn imports_ids_from_compact_registry_and_snapshot_directories_without_rekeying() { + let home = tempfile::tempdir().unwrap(); + let compact_root = home.path().join("tabs-registry"); + let snapshots = home.path().join("tabs-snapshots"); + let durable = freshell_ws::tabs_store::DurableTabsStore::open( + &compact_root, + freshell_ws::tabs_store_model::default_caps(), + 1, + ) + .unwrap(); + let compact_tabs = freshell_ws::tabs::TabsRegistry::with_durable_store(durable, None); + compact_tabs + .replace_client_snapshot( + "server", + "legacy-compact-id", + "Desktop", + "client-compact", + 1, + vec![test_record("legacy-compact-id:tab", 1)], + ) + .unwrap(); + + let snapshot_tabs = freshell_ws::tabs::TabsRegistry::with_persist_dir(snapshots.clone()); + snapshot_tabs + .replace_client_snapshot( + "server", + "legacy-snapshot-id", + "Garage server", + "client-snapshot", + 1, + vec![test_record("legacy-snapshot-id:tab", 2)], + ) + .unwrap(); + let snapshot_dir = + snapshots.join(freshell_ws::tabs_persist::encode_device_id("legacy-snapshot-id").unwrap()); + assert!( + snapshot_dir.is_dir(), + "fixture must use the real snapshot layout" + ); + let snapshot_files_before = snapshot_file_bytes(&snapshot_dir); + + let candidates = legacy_machine_candidates(&compact_tabs, Some(&snapshots)).unwrap(); + let ids: Vec<_> = candidates + .iter() + .map(|candidate| candidate.id.as_str()) + .collect(); + assert!(ids.contains(&"legacy-compact-id"), "{ids:?}"); + assert!(ids.contains(&"legacy-snapshot-id"), "{ids:?}"); + + let store = MachineStore::open(Some(home.path().join("machines")), candidates).unwrap(); + let machine_ids: Vec<_> = store.list().into_iter().map(|machine| machine.id).collect(); + assert!(machine_ids.contains(&"legacy-compact-id".to_string())); + assert!(machine_ids.contains(&"legacy-snapshot-id".to_string())); + assert!( + snapshot_dir.is_dir(), + "migration must not move snapshot directories" + ); + assert_eq!( + snapshot_file_bytes(&snapshot_dir), + snapshot_files_before, + "machine migration must not rewrite immutable snapshot generations" + ); +} + +#[tokio::test] +async fn machine_routes_expose_the_create_list_and_rename_contract() { + let store = MachineStore::open(None, Vec::new()).unwrap(); + let app = router(MachinesState { + auth_token: std::sync::Arc::new("token".to_string()), + store, + }); + + let (status, _) = machine_request(app.clone(), Method::GET, "/api/machines", None, None).await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + + let (status, first) = machine_request( + app.clone(), + Method::POST, + "/api/machines", + Some(json!({ "label": "Windows device" })), + Some("token"), + ) + .await; + assert_eq!(status, StatusCode::OK); + let first_machine = &first["machine"]; + assert!(uuid::Uuid::parse_str(first_machine["id"].as_str().unwrap()).is_ok()); + assert_eq!(first_machine["label"], "Windows device"); + assert!(first_machine["createdAt"].is_i64() || first_machine["createdAt"].is_u64()); + assert!(first_machine["lastSeenAt"].is_i64() || first_machine["lastSeenAt"].is_u64()); + + let (status, second) = machine_request( + app.clone(), + Method::POST, + "/api/machines", + Some(json!({ "label": "Windows device" })), + Some("token"), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(second["machine"]["label"], "Windows device 2"); + + let (status, list) = machine_request( + app.clone(), + Method::GET, + "/api/machines", + None, + Some("token"), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(list["machines"].as_array().unwrap().len(), 2); + + let id = second["machine"]["id"].as_str().unwrap(); + let (status, deduplicated) = machine_request( + app.clone(), + Method::PATCH, + &format!("/api/machines/{id}"), + Some(json!({ "label": "Windows device" })), + Some("token"), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(deduplicated["machine"]["id"], id); + assert_eq!(deduplicated["machine"]["label"], "Windows device 2"); + + let (status, renamed) = machine_request( + app, + Method::PATCH, + &format!("/api/machines/{id}"), + Some(json!({ "label": "Garage server" })), + Some("token"), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(renamed["machine"]["id"], id); + assert_eq!(renamed["machine"]["label"], "Garage server"); +} + +fn snapshot_file_bytes(dir: &std::path::Path) -> std::collections::BTreeMap> { + std::fs::read_dir(dir) + .unwrap() + .flatten() + .filter_map(|entry| { + entry + .file_type() + .ok() + .filter(|kind| kind.is_file()) + .map(|_| { + let path = entry.path(); + ( + entry.file_name().to_string_lossy().into_owned(), + std::fs::read(path).unwrap(), + ) + }) + }) + .collect() +} + +fn test_record(tab_key: &str, updated_at: i64) -> serde_json::Value { + json!({ + "tabKey": tab_key, + "tabId": tab_key, + "tabName": "work", + "status": "open", + "revision": 1, + "updatedAt": updated_at, + "createdAt": updated_at, + "paneCount": 1, + "titleSetByUser": false, + "panes": [], + }) +} + +async fn machine_request( + app: axum::Router, + method: Method, + uri: &str, + body: Option, + token: Option<&str>, +) -> (StatusCode, serde_json::Value) { + let mut request = Request::builder().method(method).uri(uri); + if let Some(token) = token { + request = request.header("x-auth-token", token); + } + let body = match body { + Some(body) => { + request = request.header("content-type", "application/json"); + Body::from(body.to_string()) + } + None => Body::empty(), + }; + let response = app.oneshot(request.body(body).unwrap()).await.unwrap(); + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + ( + status, + serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null), + ) +} diff --git a/crates/freshell-server/src/main.rs b/crates/freshell-server/src/main.rs index 5273ce6c7..16bba1e87 100644 --- a/crates/freshell-server/src/main.rs +++ b/crates/freshell-server/src/main.rs @@ -33,6 +33,7 @@ mod identity_sink; mod instance_id; mod legacy_local_seed; mod logging; +mod machines; mod managed_ports; mod migrations; mod net_bind; @@ -553,6 +554,9 @@ async fn main() -> ExitCode { // `/.freshell/tabs-snapshots//` (last 5 per device) so a // device's tabs can be rebuilt after client-state loss (continuity trio, // docs/plans/2026-07-22-continuity-safety-trio.md). + let snapshots_dir = home + .as_ref() + .map(|home| home.join(".freshell").join("tabs-snapshots")); let tabs = match &home { Some(home) => { let store_root = home.join(".freshell").join("tabs-registry"); @@ -574,13 +578,39 @@ async fn main() -> ExitCode { std::process::exit(1); } }; - freshell_ws::tabs::TabsRegistry::with_durable_store( - store, - Some(home.join(".freshell").join("tabs-snapshots")), - ) + freshell_ws::tabs::TabsRegistry::with_durable_store(store, snapshots_dir.clone()) } None => freshell_ws::tabs::TabsRegistry::new(), }; + // Machine identities are server-owned and imported BEFORE any route or + // WebSocket handler receives traffic. The import preserves every legacy + // `deviceId` exactly, including IDs that now survive only in immutable + // snapshot directories; no tab key, ledger provenance, or snapshot path + // is rewritten during this migration. + let legacy_machine_candidates = + match machines::legacy_machine_candidates(&tabs, snapshots_dir.as_deref()) { + Ok(candidates) => candidates, + Err(error) => { + tracing::error!(target: "freshell_server::machines", error = %error, + "machine_directory_legacy_discovery_failed"); + eprintln!("Failed to discover legacy machine identities: {error}"); + std::process::exit(1); + } + }; + let machine_store = match machines::MachineStore::open( + home.as_ref() + .map(|home| home.join(".freshell").join("machines")), + legacy_machine_candidates, + ) { + Ok(store) => store, + Err(error) => { + tracing::error!(target: "freshell_server::machines", error = %error, + "machine_directory_open_failed"); + eprintln!("Failed to open machine directory: {error}"); + std::process::exit(1); + } + }; + tabs.install_machine_identity(Arc::new(machine_store.clone())); // Follow-up 3.19: discover the CLI extensions (bundled `extensions/` + user/local // dirs) once. Feeds THREE consumers: the WS terminal spawner's coding-CLI command @@ -1624,14 +1654,16 @@ async fn main() -> ExitCode { }, )) .merge(boot::router(boot_state)) + .merge(machines::router(machines::MachinesState { + auth_token: Arc::clone(&auth_token), + store: machine_store.clone(), + })) // Continuity trio Task 2: the tabs-sync snapshot read surface. The // `snapshots_dir` MUST match the `tabs-snapshots` dir wired into the // `TabsRegistry` above so the reads serve exactly what pushes persist. .merge(tabs_snapshots::router(tabs_snapshots::TabsSnapshotsState { auth_token: Arc::clone(&auth_token), - snapshots_dir: home - .as_ref() - .map(|h| h.join(".freshell").join("tabs-snapshots")), + snapshots_dir: snapshots_dir.clone(), })) // B3/P1.9 Task 2: the recovery-inventory read surface. Joins the SAME // tabs-snapshots store as `tabs_snapshots` above (read-only), the @@ -1640,9 +1672,7 @@ async fn main() -> ExitCode { .merge(recovery_inventory::router( recovery_inventory::RecoveryInventoryState { auth_token: auth_token.as_ref().clone(), - snapshots_dir: home - .as_ref() - .map(|h| h.join(".freshell").join("tabs-snapshots")), + snapshots_dir: snapshots_dir.clone(), ledger: std::sync::Arc::clone(&pane_ledger), registry: registry.clone(), identity: terminal_identity.clone(), diff --git a/crates/freshell-server/src/recovery_inventory.rs b/crates/freshell-server/src/recovery_inventory.rs index 15a48f42e..85139c11c 100644 --- a/crates/freshell-server/src/recovery_inventory.rs +++ b/crates/freshell-server/src/recovery_inventory.rs @@ -1414,6 +1414,11 @@ pub struct RecoveryInventoryState { struct InventoryQuery { client_instance_id: Option, boot_ago_ms: Option, + /// The server-owned machine selected by the client. `deviceId` remains + /// accepted below as a wire-compatible alias while the retained client + /// still speaks in device terms. + machine_id: Option, + device_id: Option, } pub fn router(state: RecoveryInventoryState) -> Router { @@ -1450,6 +1455,12 @@ async fn inventory_handler( if !is_authed(&headers, &state.auth_token) { return unauthorized(); } + let machine_scope = match requested_machine_scope(&q) { + Ok(scope) => scope, + Err(message) => { + return (StatusCode::BAD_REQUEST, Json(json!({ "error": message }))).into_response(); + } + }; let exclude = q.client_instance_id.unwrap_or_default(); // D2/A16: anchor the concurrent-client filter to the requester's boot. // Missing param => 0 => boot_cutoff = now, so nothing that predates the @@ -1459,7 +1470,7 @@ async fn inventory_handler( None => (vec![], vec![]), Some(dir) => { let job = tokio::task::spawn_blocking(move || { - read_foreign_unions(&dir, &exclude, boot_cutoff) + read_foreign_unions(&dir, &exclude, boot_cutoff, machine_scope.as_deref()) }); match job.await { Ok(Ok(u)) => u, @@ -1509,6 +1520,23 @@ async fn inventory_handler( .into_response() } +/// Resolve the additive machine-scoping query without changing the existing +/// device-shaped recovery wire contract. A selected scope is exact: it is +/// never a hint that permits falling back to an unrelated surviving device. +fn requested_machine_scope(query: &InventoryQuery) -> Result, &'static str> { + let machine_id = query.machine_id.as_deref(); + let device_id = query.device_id.as_deref(); + match (machine_id, device_id) { + (Some(machine), Some(device)) if machine != device => { + Err("machineId and deviceId must name the same machine") + } + (Some(""), _) | (_, Some("")) => Err("machineId must be a non-empty string"), + (Some(machine), _) => Ok(Some(machine.to_string())), + (_, Some(device)) => Ok(Some(device.to_string())), + (None, None) => Ok(None), + } +} + /// Read-only liveness join (D7): `(provider = mode, sessionId)` for every /// currently-Running terminal row — the same row fields the ladder's A13 guard /// reads (`terminal.rs:1690-1745`: mode + resume session id, status == @@ -1614,6 +1642,7 @@ fn read_foreign_unions( dir: &std::path::Path, exclude_client: &str, boot_cutoff: u64, + machine_scope: Option<&str>, ) -> std::io::Result<(Vec, DeviceEvidence)> { use freshell_ws::tabs_persist::{ list_snapshot_devices, read_device_overview, read_generations_union_by_ids, ComponentsUnion, @@ -1624,6 +1653,9 @@ fn read_foreign_unions( return Ok((out, evidence)); } 'devices: for device in list_snapshot_devices(dir)? { + if machine_scope.is_some_and(|scope| scope != device) { + continue; + } let mut last_missing: Vec = Vec::new(); for _attempt in 0..UNION_READ_ATTEMPTS { let Some((_, generations)) = read_device_overview(dir, &device)? else { diff --git a/crates/freshell-server/src/recovery_inventory_tests.rs b/crates/freshell-server/src/recovery_inventory_tests.rs index b6060bac1..ea5f38fd4 100644 --- a/crates/freshell-server/src/recovery_inventory_tests.rs +++ b/crates/freshell-server/src/recovery_inventory_tests.rs @@ -4398,6 +4398,104 @@ async fn route_excludes_requesting_clients_own_generations() { assert!(tabs.iter().any(|t| t["tabKey"] == "k1")); } +#[tokio::test] +async fn route_scopes_recovery_to_requested_machine_instead_of_selecting_first_survivor() { + let tmp = tempfile::tempdir().unwrap(); + write_snapshot( + tmp.path(), + "machinea", + "client-a", + 1_000, + 1, + json!([ + {"tabKey":"machinea:tab-a","tabId":"tab-a","tabName":"wrong machine","status":"open","revision":1,"updatedAt":1000, + "paneCount":1,"panes":[{"paneId":"pane-a","kind":"terminal","payload":{"mode":"shell"}}]} + ]), + ); + write_snapshot( + tmp.path(), + "machineb", + "client-b", + 2_000, + 1, + json!([ + {"tabKey":"machineb:tab-b","tabId":"tab-b","tabName":"selected machine","status":"open","revision":1,"updatedAt":2000, + "paneCount":1,"panes":[{"paneId":"pane-b","kind":"terminal","payload":{"mode":"shell"}}]} + ]), + ); + + let (code, body) = get( + router(test_state(Some(tmp.path().to_path_buf()), None)), + "/api/recovery/inventory?clientInstanceId=me&bootAgoMs=1000000000000&machineId=machineb", + Some("tok"), + ) + .await; + + assert_eq!(code, StatusCode::OK); + assert_eq!(body["device"]["deviceId"], "machineb", "{body}"); + assert_eq!(body["device"]["tabs"][0]["tabName"], "selected machine"); + assert!( + body["otherDevices"].as_array().unwrap().is_empty(), + "a scoped recovery must never offer another machine: {body}" + ); +} + +#[test] +fn requested_machine_scope_accepts_the_legacy_device_id_alias_and_rejects_conflicts() { + let legacy_alias = InventoryQuery { + client_instance_id: None, + boot_ago_ms: None, + machine_id: None, + device_id: Some("selected-machine".to_string()), + }; + assert_eq!( + requested_machine_scope(&legacy_alias).unwrap(), + Some("selected-machine".to_string()) + ); + + let conflict = InventoryQuery { + client_instance_id: None, + boot_ago_ms: None, + machine_id: Some("machine-a".to_string()), + device_id: Some("machine-b".to_string()), + }; + assert_eq!( + requested_machine_scope(&conflict).unwrap_err(), + "machineId and deviceId must name the same machine" + ); +} + +#[tokio::test] +async fn route_never_falls_back_to_another_machine_when_the_requested_scope_has_no_snapshot() { + let tmp = tempfile::tempdir().unwrap(); + write_snapshot( + tmp.path(), + "machinewithsnapshot", + "client-a", + 1_000, + 1, + json!([ + {"tabKey":"machinewithsnapshot:tab-a","tabId":"tab-a","tabName":"must not leak","status":"open","revision":1,"updatedAt":1000, + "paneCount":1,"panes":[{"paneId":"pane-a","kind":"terminal","payload":{"mode":"shell"}}]} + ]), + ); + + let (code, body) = get( + router(test_state(Some(tmp.path().to_path_buf()), None)), + "/api/recovery/inventory?clientInstanceId=me&bootAgoMs=1000000000000&machineId=machinewithoutsnapshot", + Some("tok"), + ) + .await; + + assert_eq!(code, StatusCode::OK); + assert_eq!(body["recoverable"], false, "{body}"); + assert!(body["device"].is_null(), "{body}"); + assert!( + body["otherDevices"].as_array().unwrap().is_empty(), + "{body}" + ); +} + #[tokio::test] async fn route_never_offers_ledger_only_rows_without_parent_evidence() { // D8 route-level contract (deliberate rewrite of the old blanket-contract diff --git a/crates/freshell-ws/src/tabs.rs b/crates/freshell-ws/src/tabs.rs index b270de067..8280c77c5 100644 --- a/crates/freshell-ws/src/tabs.rs +++ b/crates/freshell-ws/src/tabs.rs @@ -32,6 +32,7 @@ //! caller sees the IO error and the registry keeps serving the last //! durably-committed state (Node throws out of the mutation, store.ts:1189). +use std::collections::HashMap; use std::path::PathBuf; use std::sync::{Arc, Mutex}; @@ -55,6 +56,31 @@ use crate::tabs_store_model::{ const STALE_REVISION_ERROR: &str = "Stale snapshot revision rejected for tabs registry client snapshot"; +/// Server-owned machine-directory seam. The WebSocket protocol deliberately +/// continues calling this a `deviceId`, but a configured directory is the +/// authority that decides which IDs may write or query tab-sync data and what +/// label is canonical for that ID. +pub trait MachineIdentityStore: Send + Sync { + /// `Ok(None)` means the ID is unknown and must be rejected. An accepted + /// resolution records activity before returning the canonical label. + fn resolve_for_tab_sync(&self, machine_id: &str) -> Result, String>; + + /// A read-only known-ID check for query paths. Queries run on the async + /// WebSocket loop, so this must not turn a routine read into a filesystem + /// write; accepted pushes perform the durable activity touch above. + fn machine_exists(&self, machine_id: &str) -> bool; +} + +/// Historical device metadata extracted from the compact tabs registry during +/// server boot. `freshell-server` imports these exact IDs into its separate +/// machine directory without touching a tab key or snapshot path. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LegacyDeviceMetadata { + pub device_id: String, + pub device_label: String, + pub last_seen_at: i64, +} + /// The result of a `tabs.sync.push` (`tabs.sync.ack` payload). #[derive(Debug)] pub struct PushAck { @@ -89,6 +115,10 @@ pub struct TabsRegistry { /// Push/state caps — the store's own caps in durable mode, `DEFAULT_CAPS` /// otherwise. caps: Arc, + /// Optional server-owned machine directory. It is installed after the + /// server imports every historical ID at boot; `None` preserves the + /// memory-only unit-test/no-home mode. + machine_identity: Arc>>>, } impl Default for TabsRegistry { @@ -101,6 +131,7 @@ impl Default for TabsRegistry { persist_dir: None, store: None, caps: Arc::new(default_caps()), + machine_identity: Arc::new(Mutex::new(None)), } } } @@ -130,7 +161,86 @@ impl TabsRegistry { persist_dir: persist_dir.map(Arc::new), store: Some(Arc::new(Mutex::new(store))), caps: Arc::new(caps), + machine_identity: Arc::new(Mutex::new(None)), + } + } + + /// Install the authoritative machine directory only after the server has + /// imported legacy IDs from this compact state and from snapshots. The + /// shared interior slot lets all cloned REST/WS registry handles observe + /// the same policy without adding a new field to every `WsState` literal. + pub fn install_machine_identity(&self, machine_identity: Arc) { + *self + .machine_identity + .lock() + .expect("machine identity registry lock") = Some(machine_identity); + } + + /// Extract every exact device ID still represented by the compact durable + /// registry. This deliberately reads all durable components, not merely + /// `devices_by_id`, whose display TTL means it is not a canonical history. + pub fn legacy_device_metadata(&self) -> Vec { + let state = self.inner.lock().expect("tabs registry lock"); + let mut candidates: HashMap = HashMap::new(); + let mut consider = |device_id: &str, device_label: Option<&str>, last_seen_at: i64| { + if device_id.is_empty() { + return; + } + let label = device_label.filter(|label| !label.trim().is_empty()); + match candidates.get_mut(device_id) { + Some(existing) if existing.last_seen_at > last_seen_at => {} + Some(existing) => { + // Revision watermarks name an ID but carry no label. They + // must never erase an otherwise useful historical label + // just because their timestamp ties a richer component. + if let Some(label) = label { + existing.device_label = label.to_string(); + } + existing.last_seen_at = last_seen_at; + } + None => { + candidates.insert( + device_id.to_string(), + LegacyDeviceMetadata { + device_id: device_id.to_string(), + device_label: label.unwrap_or("Recovered device").to_string(), + last_seen_at, + }, + ); + } + } + }; + + for device in state.devices_by_id.values() { + consider( + &device.device_id, + Some(&device.device_label), + device.last_seen_at, + ); + } + for snapshot in state.open_snapshots_by_client.values() { + consider( + &snapshot.device_id, + Some(&snapshot.device_label), + snapshot.snapshot_received_at, + ); } + for watermark in state.client_revisions_by_client.values() { + consider(&watermark.device_id, None, watermark.last_seen_at); + } + for record in state.closed_by_tab_key.values() { + let device_id = record_str(record, "deviceId").unwrap_or_default(); + let device_label = record_str(record, "deviceLabel"); + consider( + &device_id, + device_label.as_deref(), + closed_at_or_updated(record), + ); + } + + let mut out: Vec = candidates.into_values().collect(); + out.sort_by(|left, right| left.device_id.cmp(&right.device_id)); + out } /// `replaceClientSnapshot` (store.ts:1091-1192): validate + canonicalize @@ -150,8 +260,16 @@ impl TabsRegistry { device_label: &str, client_instance_id: &str, snapshot_revision: i64, - records: Vec, + mut records: Vec, ) -> Result { + let canonical_device_label = self.canonical_machine_label(device_id, device_label)?; + canonicalize_envelope_machine_metadata( + &mut records, + device_id, + device_label, + &canonical_device_label, + )?; + let device_label = canonical_device_label.as_str(); let now = now_ms(); // Pre-checks run OUTSIDE every lock (store.ts:1091-1107 run before // the mutation is enqueued). @@ -278,6 +396,11 @@ impl TabsRegistry { client_instance_id: &str, snapshot_revision: i64, ) -> bool { + if let Err(error) = self.require_known_machine(device_id) { + tracing::warn!(target: "freshell_ws::tabs", device_id = %device_id, error = %error, + "tabs_sync_retire_rejected_unknown_machine"); + return false; + } let now = now_ms(); let Ok(key) = client_snapshot_key(device_id, client_instance_id) else { return false; @@ -351,6 +474,7 @@ impl TabsRegistry { closed_tab_retention_days: i64, now_ms: i64, ) -> Result { + self.require_known_machine(device_id)?; if !(1..=30).contains(&closed_tab_retention_days) { return Err("Closed tab retention must be an integer from 1 to 30 days".to_string()); } @@ -487,6 +611,34 @@ impl TabsRegistry { (record_count, device_count) } + + fn canonical_machine_label( + &self, + device_id: &str, + supplied_label: &str, + ) -> Result { + match self.machine_identity() { + Some(directory) => directory + .resolve_for_tab_sync(device_id)? + .ok_or_else(|| format!("Unknown machine ID for tabs sync: {device_id}")), + None => Ok(supplied_label.to_string()), + } + } + + fn require_known_machine(&self, device_id: &str) -> Result<(), String> { + match self.machine_identity() { + Some(directory) if directory.machine_exists(device_id) => Ok(()), + Some(_) => Err(format!("Unknown machine ID for tabs sync: {device_id}")), + None => Ok(()), + } + } + + fn machine_identity(&self) -> Option> { + self.machine_identity + .lock() + .expect("machine identity registry lock") + .clone() + } } // ── Push pre-checks (store.ts:1091-1107 + ws-handler.ts:3122-3132) ────────── @@ -501,6 +653,40 @@ struct PreparedPush { open_snapshot_hash: String, } +/// Verify a record's client-supplied ownership against the legacy envelope, +/// then rewrite its label to the server-owned canonical value before the +/// normal tabs registry validator receives it. This preserves the existing +/// anti-cross-machine ownership check while allowing an upgraded server to +/// correct a stale client label without rejecting its workspace. +fn canonicalize_envelope_machine_metadata( + records: &mut [Value], + device_id: &str, + supplied_label: &str, + canonical_label: &str, +) -> Result<(), String> { + for record in records { + let Some(map) = record.as_object_mut() else { + continue; + }; + for (field, expected) in [("deviceId", device_id), ("deviceLabel", supplied_label)] { + if map + .get(field) + .and_then(Value::as_str) + .is_some_and(|value| value != expected) + { + return Err( + "Tabs registry record device metadata must match the snapshot device \ + metadata" + .to_string(), + ); + } + } + map.insert("deviceId".to_string(), json!(device_id)); + map.insert("deviceLabel".to_string(), json!(canonical_label)); + } + Ok(()) +} + #[allow(clippy::too_many_arguments)] fn prepare_push( server_instance_id: &str, diff --git a/crates/freshell-ws/src/tabs_tests.rs b/crates/freshell-ws/src/tabs_tests.rs index 93c742ff9..2fa6222d7 100644 --- a/crates/freshell-ws/src/tabs_tests.rs +++ b/crates/freshell-ws/src/tabs_tests.rs @@ -9,6 +9,8 @@ use crate::tabs_store_model::{ DEFAULT_DEVICE_DISPLAY_TTL_DAYS, MINUTE_MS, }; use serde_json::{json, Value}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; fn open_record(tab_key: &str, tab_name: &str, updated_at: i64) -> Value { json!({ @@ -217,6 +219,98 @@ fn record_ownership_mismatch_is_rejected() { ); } +#[derive(Clone)] +struct FixtureMachineDirectory { + labels: Arc>>, +} + +impl FixtureMachineDirectory { + fn with_machine(id: &str, label: &str) -> Self { + Self { + labels: Arc::new(Mutex::new(HashMap::from([( + id.to_string(), + label.to_string(), + )]))), + } + } +} + +impl MachineIdentityStore for FixtureMachineDirectory { + fn resolve_for_tab_sync(&self, machine_id: &str) -> Result, String> { + Ok(self.labels.lock().unwrap().get(machine_id).cloned()) + } + + fn machine_exists(&self, machine_id: &str) -> bool { + self.labels.lock().unwrap().contains_key(machine_id) + } +} + +#[test] +fn server_owned_machine_directory_rejects_unknown_ids_and_canonicalizes_labels() { + let reg = TabsRegistry::new(); + reg.install_machine_identity(Arc::new(FixtureMachineDirectory::with_machine( + "machine-1", + "Canonical desktop", + ))); + + let error = reg + .replace_client_snapshot( + "srv", + "unknown-machine", + "Client supplied label", + "client-1", + 1, + vec![open_record("unknown:tab", "wrong", 1)], + ) + .unwrap_err(); + assert!(error.contains("Unknown machine ID"), "{error}"); + + reg.replace_client_snapshot( + "srv", + "machine-1", + "Stale client label", + "client-1", + 1, + vec![open_record("machine-1:tab", "work", 2)], + ) + .unwrap(); + let data = reg.query("machine-1", "client-1", 30, now_ms()).unwrap(); + assert_eq!( + data["localOpen"][0]["deviceLabel"], "Canonical desktop", + "the server canonical label, not the stale client label, is persisted" + ); + assert_eq!( + data["localOpen"][0]["tabKey"], "machine-1:tab", + "canonicalizing a label never rekeys existing tab provenance" + ); + assert!(reg + .query("unknown-machine", "client-1", 30, now_ms()) + .is_err()); + assert!( + !reg.retire_client_snapshot("unknown-machine", "client-1", 2), + "unknown IDs are rejected on every tab-sync mutation path" + ); +} + +#[test] +fn legacy_metadata_keeps_a_real_label_when_a_watermark_has_no_label() { + let reg = TabsRegistry::new(); + reg.replace_client_snapshot( + "srv", + "legacy-device", + "Original desktop", + "client-1", + 1, + vec![open_record("legacy-device:tab", "work", 2)], + ) + .unwrap(); + + let metadata = reg.legacy_device_metadata(); + assert_eq!(metadata.len(), 1); + assert_eq!(metadata[0].device_id, "legacy-device"); + assert_eq!(metadata[0].device_label, "Original desktop"); +} + #[test] fn query_validates_retention_and_filters_expired_open_snapshots() { let reg = TabsRegistry::new(); diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index 5d1fa1081..1b770bcd9 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -752,25 +752,7 @@ async fn handle_client_text( if let Some(msg_type) = value.get("type").and_then(|v| v.as_str()) { match msg_type { "tabs.sync.push" => { - // D8: refresh the connection identity from each push (same - // non-empty-string filter `validate_tabs_push` applies), so a - // mid-lifetime clientInstanceId rotation self-heals at the - // next push instead of waiting out a reconnect. - if let Some(device_id) = value - .get("deviceId") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - { - conn_identity.device_id = Some(device_id.to_string()); - } - if let Some(client_instance_id) = value - .get("clientInstanceId") - .and_then(|v| v.as_str()) - .filter(|s| !s.is_empty()) - { - conn_identity.client_instance_id = Some(client_instance_id.to_string()); - } - return handle_tabs_push(&value, ws_tx, state).await; + return handle_tabs_push(&value, ws_tx, state, conn_identity).await; } "tabs.sync.query" => return handle_tabs_query(&value, ws_tx, state).await, "tabs.sync.client.retire" => { @@ -6366,7 +6348,12 @@ fn is_opencode_provider(provider: freshell_protocol::AgentProvider) -> bool { /// registry, then reply `tabs.sync.ack`. On a stale/invalid revision the registry /// returns `Err`, which we surface as an `error{code:INVALID_MESSAGE}` frame (the /// original's `catch` arm; the SPA maps a `/tabs/i` error to its sync-error state). -async fn handle_tabs_push(value: &serde_json::Value, ws_tx: &mut WsSink, state: &WsState) -> bool { +async fn handle_tabs_push( + value: &serde_json::Value, + ws_tx: &mut WsSink, + state: &WsState, + conn_identity: &mut ConnectionIdentity, +) -> bool { match tabs_push_response( value, state.tabs.clone(), @@ -6374,7 +6361,27 @@ async fn handle_tabs_push(value: &serde_json::Value, ws_tx: &mut WsSink, state: ) .await { - TabsPushResponse::Ack(message) => send(ws_tx, &message).await, + TabsPushResponse::Ack(message) => { + // Refresh provenance only AFTER the machine directory accepted + // this exact push. An unknown `deviceId` must never become a + // connection identity merely because it appeared in a rejected + // envelope. + if let Some(device_id) = value + .get("deviceId") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + conn_identity.device_id = Some(device_id.to_string()); + } + if let Some(client_instance_id) = value + .get("clientInstanceId") + .and_then(|v| v.as_str()) + .filter(|s| !s.is_empty()) + { + conn_identity.client_instance_id = Some(client_instance_id.to_string()); + } + send(ws_tx, &message).await + } TabsPushResponse::Error(frame) => send_raw(ws_tx, &frame).await, } } @@ -8019,6 +8026,18 @@ mod connection_span_filter_tests { mod pane_reconcile_gate_tests { use super::*; + struct OnlyKnownMachine; + + impl crate::tabs::MachineIdentityStore for OnlyKnownMachine { + fn resolve_for_tab_sync(&self, machine_id: &str) -> Result, String> { + Ok((machine_id == "known-machine").then(|| "Canonical machine".to_string())) + } + + fn machine_exists(&self, machine_id: &str) -> bool { + machine_id == "known-machine" + } + } + /// A REAL loopback websocket pair: a scratch axum app upgrades the client /// connection and hands its write half (the production `WsSink` type) to /// the test, so `handle_client_text` runs its real serialization + send @@ -8143,6 +8162,43 @@ mod pane_reconcile_gate_tests { } } + #[tokio::test] + async fn rejected_unknown_tabs_push_does_not_replace_connection_identity() { + let (mut ws_tx, mut client) = loopback_sink_and_client().await; + let state = state(); + state + .tabs + .install_machine_identity(std::sync::Arc::new(OnlyKnownMachine)); + let mut conn_identity = ConnectionIdentity { + device_id: Some("known-machine".to_string()), + client_instance_id: Some("known-client".to_string()), + }; + let rejected_push = serde_json::json!({ + "type": "tabs.sync.push", + "deviceId": "unknown-machine", + "deviceLabel": "Unknown machine", + "clientInstanceId": "unknown-client", + "snapshotRevision": 1, + "records": [], + }); + + assert!( + handle_tabs_push(&rejected_push, &mut ws_tx, &state, &mut conn_identity).await, + "a rejected tabs push must answer without disconnecting the client" + ); + let frame = next_text_frame(&mut client).await; + assert_eq!(frame["type"], "error"); + assert_eq!(frame["code"], "INVALID_MESSAGE"); + assert!(frame["message"] + .as_str() + .is_some_and(|message| message.contains("Unknown machine ID"))); + assert_eq!(conn_identity.device_id.as_deref(), Some("known-machine")); + assert_eq!( + conn_identity.client_instance_id.as_deref(), + Some("known-client") + ); + } + #[tokio::test] async fn pane_reconcile_request_without_capability_gets_explicit_error() { let (mut ws_tx, mut client) = loopback_sink_and_client().await; From bb58dc0017d118f3047b03e509131a1f577fa618 Mon Sep 17 00:00:00 2001 From: Dan Shapiro <3732858+danshapiro@users.noreply.github.com> Date: Sat, 12 Sep 2026 00:50:41 -0700 Subject: [PATCH 2/2] feat: add server-owned machine identity --- docs/index.html | 18 +- electron/entry.ts | 42 +-- electron/preload.ts | 2 + src/App.tsx | 257 +++++++++++++++--- src/components/MachineChooser.tsx | 94 +++++++ src/components/settings/DevicesSettings.tsx | 201 ++++++-------- src/lib/api.ts | 52 +++- src/lib/machine-identity.ts | 223 +++++++++++++++ src/lib/machine-workspace.ts | 78 ++++++ src/store/machineIdentitySlice.ts | 66 +++++ src/store/panesSlice.ts | 23 ++ src/store/persistMiddleware.ts | 6 + src/store/storage-keys.ts | 6 + src/store/store.ts | 4 + src/store/tabRegistrySlice.ts | 39 +-- src/store/tabsSlice.ts | 13 + src/vite-env.d.ts | 7 + .../specs/machine-identity.spec.ts | 36 +++ test/e2e/settings-devices-flow.test.tsx | 119 +++----- .../components/App.machine-identity.test.tsx | 237 ++++++++++++++++ .../DevicesSettings.machine-identity.test.tsx | 91 +++++++ .../client/components/MachineChooser.test.tsx | 63 +++++ .../components/SettingsView.behavior.test.tsx | 46 +--- .../components/SettingsView.core.test.tsx | 2 +- .../components/settings-view-test-utils.tsx | 13 + test/unit/client/lib/api.test.ts | 48 ++++ test/unit/client/lib/machine-identity.test.ts | 173 ++++++++++++ .../unit/client/lib/machine-workspace.test.ts | 119 ++++++++ .../client/store/machineIdentitySlice.test.ts | 43 +++ .../client/store/tabRegistrySlice.test.ts | 17 ++ test/unit/electron/preload.test.ts | 7 + 31 files changed, 1812 insertions(+), 333 deletions(-) create mode 100644 src/components/MachineChooser.tsx create mode 100644 src/lib/machine-identity.ts create mode 100644 src/lib/machine-workspace.ts create mode 100644 src/store/machineIdentitySlice.ts create mode 100644 test/e2e-browser/specs/machine-identity.spec.ts create mode 100644 test/unit/client/components/App.machine-identity.test.tsx create mode 100644 test/unit/client/components/DevicesSettings.machine-identity.test.tsx create mode 100644 test/unit/client/components/MachineChooser.test.tsx create mode 100644 test/unit/client/lib/machine-identity.test.ts create mode 100644 test/unit/client/lib/machine-workspace.test.ts create mode 100644 test/unit/client/store/machineIdentitySlice.test.ts diff --git a/docs/index.html b/docs/index.html index 70d5313f1..8b87b3606 100644 --- a/docs/index.html +++ b/docs/index.html @@ -532,6 +532,12 @@ } .settings-select { padding-right: 32px; } .settings-textarea { min-height: 76px; padding: 9px 10px; resize: vertical; font-family: inherit; } +.settings-actions { display: flex; align-items: center; gap: 8px; } +.settings-action { + min-height: 32px; padding: 0 10px; border: 1px solid hsl(var(--border)); border-radius: 6px; + background: transparent; color: hsl(var(--foreground)); font: inherit; font-size: 12px; cursor: pointer; +} +.settings-action:hover { background: hsl(var(--muted)); } .settings-segmented { display: flex; width: 100%; gap: 2px; padding: 2px; border-radius: 7px; background: hsl(var(--muted)); } @@ -1259,12 +1265,16 @@

Extensions

-

Devices

-

Device names and local aliases

+

Machine

+

Choose the saved workspace this client opens on this server.

-
This machine
Renaming this updates what other machines see.
-
+
Current machine
Rename the machine shown to other Freshell clients.
+
+
+
+
Choose another machine
Open the chooser to use a saved workspace or add this computer.
+
diff --git a/electron/entry.ts b/electron/entry.ts index 1104b66d8..92a278d06 100644 --- a/electron/entry.ts +++ b/electron/entry.ts @@ -696,6 +696,7 @@ async function main(): Promise { ipcMain.removeHandler('complete-setup') ipcMain.removeHandler('get-server-mode') ipcMain.removeHandler('get-server-status') + ipcMain.removeHandler('get-hostname') ipcMain.removeHandler('set-global-hotkey') ipcMain.removeHandler('install-update') ipcMain.removeHandler('get-launch-options') @@ -723,29 +724,27 @@ async function main(): Promise { } } + function isMainRenderer(event: unknown): boolean { + const typed = event as { + sender?: { id?: number } + senderFrame?: { url?: string } + } + if (mainWebContentsId === undefined || typed.sender?.id !== mainWebContentsId) return false + const expectedOrigin = getExpectedOrigin() + const frameUrl = typed.senderFrame?.url + if (!expectedOrigin || !frameUrl) return false + try { + return new URL(frameUrl).origin === expectedOrigin + } catch { + return false + } + } + // Register system-browser link handler. registerOpenExternalHandler({ ipcMain, shell, - isAllowedSender: (event) => { - const typed = event as { - sender?: { id?: number } - senderFrame?: { url?: string } - } - const senderId = typed.sender?.id - if (mainWebContentsId === undefined || senderId !== mainWebContentsId) { - return false - } - const expectedOrigin = getExpectedOrigin() - if (!expectedOrigin) return false - const frameUrl = typed.senderFrame?.url - if (!frameUrl) return false - try { - return new URL(frameUrl).origin === expectedOrigin - } catch { - return false - } - }, + isAllowedSender: isMainRenderer, }) // Register the complete-setup handler before runStartup so it is available @@ -858,6 +857,11 @@ async function main(): Promise { mode: desktopConfig.serverMode, })) + // Deliberately narrow renderer surface: the selected Freshell renderer can + // ask only for this desktop process's hostname. It accepts no arguments and + // is origin- and webContents-bound like open-external-url. + ipcMain.handle('get-hostname', (event) => (isMainRenderer(event) ? os.hostname() : '')) + ipcMain.handle('set-global-hotkey', (_event, accelerator: string) => { return hotkeyManager.update(accelerator, () => { // Toggle the main window visibility when the hotkey is pressed diff --git a/electron/preload.ts b/electron/preload.ts index f1d5a2098..ddc852704 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -33,6 +33,7 @@ export interface FreshellDesktopApi { isElectron: boolean getServerMode: () => Promise getServerStatus: () => Promise<{ running: boolean; mode: string }> + getHostname: () => Promise setGlobalHotkey: (accelerator: string) => Promise onUpdateAvailable: (callback: () => void) => void onUpdateDownloaded: (callback: () => void) => void @@ -63,6 +64,7 @@ export function registerPreloadApi( isElectron: true, getServerMode: () => ipcRenderer.invoke('get-server-mode'), getServerStatus: () => ipcRenderer.invoke('get-server-status'), + getHostname: () => ipcRenderer.invoke('get-hostname'), setGlobalHotkey: (accelerator: string) => ipcRenderer.invoke('set-global-hotkey', accelerator), onUpdateAvailable: (callback: () => void) => ipcRenderer.on('update-available', callback), onUpdateDownloaded: (callback: () => void) => ipcRenderer.on('update-downloaded', callback), diff --git a/src/App.tsx b/src/App.tsx index 9be5e1972..d6c5c198c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,7 +9,15 @@ import { resetWsSnapshotReceived, } from '@/store/sessionsSlice' import { addTab, closeTab, reopenClosedTab, switchToNextTab, switchToPrevTab } from '@/store/tabsSlice' -import { api, isApiUnauthorizedError, isTransientRequestFailure, type VersionInfo } from '@/lib/api' +import { + ApiError, + api, + createMachine, + getMachines, + isApiUnauthorizedError, + isTransientRequestFailure, + type VersionInfo, +} from '@/lib/api' import { fetchSessionWindow, loadInitialSessionsWindow, @@ -50,6 +58,20 @@ import { installCrossTabSync } from '@/store/crossTabSync' import { startTabRegistrySync, getCurrentTabRegistryClientInstanceId } from '@/store/tabRegistrySync' import { startSessionGreyTouchWatcher } from '@/store/sessionGreyTouch' import { resolveAndPersistDeviceMeta, setTabRegistryDeviceMeta } from '@/store/tabRegistrySlice' +import { + setMachineChooser, + setMachineReady, + setMachineResolutionError, + setMachineRestoring, + type MachineIdentityState, +} from '@/store/machineIdentitySlice' +import type { Machine } from '@/lib/machine-identity' +import { + getSuggestedMachineLabel, + persistSelectedMachineId, + resolveMachineIdentity, +} from '@/lib/machine-identity' +import { restoreMachineWorkspace } from '@/lib/machine-workspace' import { buildLocalSettingsPatch } from '@/store/browserPreferencesPersistence' import Sidebar, { AppView } from '@/components/Sidebar' import TabBar from '@/components/TabBar' @@ -63,6 +85,7 @@ import { TerminalInterestReporter } from '@/components/TerminalInterestReporter' import { ReconcileWarmingBanner } from '@/components/ReconcileWarmingBanner' import { SetupWizard } from '@/components/SetupWizard' import { RecoveryOfferPanel } from '@/components/RecoveryOfferPanel' +import { MachineChooser } from '@/components/MachineChooser' import VirtualDeckPanel from '@/components/VirtualDeckPanel' import { ErrorBoundary } from '@/components/ui/error-boundary' import { fetchNetworkStatus } from '@/store/networkSlice' @@ -199,6 +222,12 @@ export default function App() { // the effect below re-runs when the status flips back to 'ready'. const connectionStatus = useAppSelector((s) => s.connection.status) const networkStatus = useAppSelector((s) => s.network.status) + // A number of focused App tests intentionally provide a partial store. The + // real store always has this slice; treating its absence as legacy behavior + // keeps those narrow tests independent of machine bootstrap concerns. + const machineIdentity = useAppSelector( + (s) => (s as unknown as { machineIdentity?: MachineIdentityState }).machineIdentity, + ) const perfAuditEnabled = typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('perfAudit') const perfAuditBridgeRef = useRef | null>(null) @@ -331,8 +360,12 @@ export default function App() { // Keep this tab's Redux state in sync with persisted writes from other browser tabs. useEffect(() => { + // A persisted layout is scoped only after the selected server machine has + // replaced the local cache. Until then, an older browser tab must not + // hydrate a foreign workspace into this boot. + if (machineIdentity?.status && machineIdentity.status !== 'ready') return return installCrossTabSync(appStore) - }, [appStore]) + }, [appStore, machineIdentity?.status]) useEffect(() => { return () => { @@ -514,6 +547,24 @@ export default function App() { } }, [updateAvailable]) + const restartAfterMachineSelection = useCallback(() => { + // A reload is an intentional bootstrap boundary: it drops any old + // websocket/client-instance lane before the newly selected workspace is + // hydrated, so it cannot write the previous layout under a new machine. + window.location.reload() + }, []) + + const selectMachineFromChooser = useCallback(async (machine: Machine) => { + persistSelectedMachineId(machine.id) + restartAfterMachineSelection() + }, [restartAfterMachineSelection]) + + const addMachineFromChooser = useCallback(async (label: string) => { + const machine = await createMachine(label) + persistSelectedMachineId(machine.id) + restartAfterMachineSelection() + }, [restartAfterMachineSelection]) + // Bootstrap: load settings, sessions, and connect websocket. useEffect(() => { let cancelled = false @@ -532,6 +583,10 @@ export default function App() { let lastReadyServerInstanceId: string | undefined let lastSessionsRevision = -1 const versionInfoLoadedRef = { current: false } + const machineIdentityEnabled = !!( + appStore.getState() as unknown as { machineIdentity?: MachineIdentityState } + ).machineIdentity + let machineTransportReady = !machineIdentityEnabled // Bounded wait for the current boot's pane.reconcile.result: the result // is unicast to THIS socket, so a result lost with a dying socket would @@ -641,10 +696,6 @@ export default function App() { if (hasLoadedPlatformCapabilities(bootstrapData.platform)) { platformCapabilitiesLoaded = true } - dispatch(setTabRegistryDeviceMeta(resolveAndPersistDeviceMeta({ - platform: bootstrapData.platform.platform, - hostName: bootstrapData.platform.hostName ?? bootstrapData.platform.host, - }))) } if (bootstrapData.configFallback) { setConfigFallback({ @@ -680,10 +731,6 @@ export default function App() { dispatch(setAvailableClis(platformData.availableClis ?? {})) dispatch(setFeatureFlags(platformData.featureFlags ?? {})) platformCapabilitiesLoaded = true - dispatch(setTabRegistryDeviceMeta(resolveAndPersistDeviceMeta({ - platform: platformData.platform, - hostName: platformData.hostName ?? platformData.host, - }))) } return true } catch (err: any) { @@ -725,34 +772,103 @@ export default function App() { dispatch(setError('Authentication failed')) } - // ── WebSocket setup (synchronous) ───────────────────────────── - // Register the message handler BEFORE any async work. App.tsx is the - // sole owner of the WebSocket connection. The socket may become ready - // while we await HTTP fetches below; registering early avoids losing - // early messages. + // ── WebSocket setup (transport remains gated) ────────────────── + // Register message handlers now, but do not configure hello or start + // tabs.sync until a selected machine has restored its scoped workspace. const ws = getWsClient() - stopTabRegistrySync = startTabRegistrySync(appStore, ws) // Grey-transition touch: sessions leaving non-grey status (any of the // four tiers) get an activity ratchet, so the default sort floats them // to the top of the grey agents. Store-only; no WS dependency. stopSessionGreyTouch = startSessionGreyTouchWatcher(appStore) - // Set up hello extension to include session IDs for prioritized repair - ws.setHelloExtensionProvider(() => ({ - sessions: getSessionsForHello(appStore.getState()), - sidebarOpenSessions: collectSessionLocatorsFromTabs( - appStore.getState().tabs.tabs, - appStore.getState().panes, - ), - client: { mobile: isMobileRef.current }, - // D8 (restore-open-sessions-only): the connection's provenance identity - // — the same deviceId/clientInstanceId `tabs.sync.push` frames carry — - // so the server can stamp connection-scoped ledger bind rows. The - // provider is re-invoked per (re)connect, so a lease-collision rotation - // re-stamps on the next hello. - deviceId: appStore.getState().tabRegistry.deviceId, - clientInstanceId: getCurrentTabRegistryClientInstanceId(), - })) + let machineTransportConfigured = false + const configureMachineBoundTransport = () => { + if (machineTransportConfigured) return + machineTransportConfigured = true + stopTabRegistrySync = startTabRegistrySync(appStore, ws) + // Set up hello extension only after the selected machine has been + // resolved. `deviceId`/`deviceLabel` remain wire-compatible names, + // now carrying the server-owned machine id and canonical label. + ws.setHelloExtensionProvider(() => ({ + sessions: getSessionsForHello(appStore.getState()), + sidebarOpenSessions: collectSessionLocatorsFromTabs( + appStore.getState().tabs.tabs, + appStore.getState().panes, + ), + client: { mobile: isMobileRef.current }, + deviceId: appStore.getState().tabRegistry.deviceId, + clientInstanceId: getCurrentTabRegistryClientInstanceId(), + })) + machineTransportReady = true + } + + const resolveMachineBeforeTransport = async (): Promise => { + if (!machineIdentityEnabled) return true + try { + const machines = await getMachines() + if (cancelled) return false + const suggestedLabel = await getSuggestedMachineLabel() + if (cancelled) return false + const resolution = await resolveMachineIdentity({ + machines, + createMachine, + suggestedLabel, + }) + if (cancelled) return false + if (resolution.kind === 'chooser') { + dispatch(setMachineChooser({ + machines: resolution.machines, + suggestedLabel: resolution.suggestedLabel, + })) + return false + } + + dispatch(setMachineRestoring(resolution.machine)) + dispatch(setTabRegistryDeviceMeta({ + deviceId: resolution.machine.id, + deviceLabel: resolution.machine.label, + })) + await restoreMachineWorkspace(appStore, resolution.machine.id) + if (cancelled) return false + dispatch(setMachineReady({ machine: resolution.machine, mode: 'server-managed' })) + return true + } catch (err) { + if (handleBootstrapAuthFailure(err)) return false + // A frozen client can still connect to a server that predates the + // machine API. Keep that compatibility lane stable (no fingerprint + // rotation) while all server-owned deployments use the branch above. + if (err instanceof ApiError && err.status === 404) { + const legacy = resolveAndPersistDeviceMeta() + if (cancelled) return false + dispatch(setTabRegistryDeviceMeta(legacy)) + dispatch(setMachineReady({ + machine: { + id: legacy.deviceId, + label: legacy.deviceLabel, + createdAt: 0, + lastSeenAt: 0, + }, + mode: 'legacy', + })) + return true + } + log.warn('Failed to resolve the selected machine', err) + if (!cancelled) { + dispatch(setMachineResolutionError( + err instanceof Error ? err.message : 'Could not resolve a machine', + )) + } + return false + } + } + + // Focused App tests and old embedded renderers can supply a deliberately + // partial Redux store without the new machine slice. They retain the + // pre-machine bootstrap path; production stores always include the + // slice and therefore take the gated branch above. + if (!machineIdentityEnabled) { + configureMachineBoundTransport() + } const requestCodexActivityList = () => { const requestId = `codex-activity-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` @@ -1066,6 +1182,12 @@ export default function App() { log.error('ready frame failed schema validation; skipping restart detection', ready.error.issues) } else { dispatch(setServerInstanceId(nextServerInstanceId)) + const selectedMachine = ( + appStore.getState() as unknown as { machineIdentity?: MachineIdentityState } + ).machineIdentity?.selectedMachine + if (selectedMachine) { + persistSelectedMachineId(selectedMachine.id, nextServerInstanceId) + } const newBootId = ready.data.bootId if (!newBootId) { log.warn('ready frame carried no bootId; falling back to serverInstanceId for restart detection') @@ -1551,6 +1673,13 @@ export default function App() { // ── HTTP bootstrap (async) ──────────────────────────────────── if (!(await loadBootstrapData())) return + // Select and hydrate one server-owned machine before the websocket can + // send hello or tab sync. This makes a storage reset a deliberate choice + // instead of an arbitrary restore followed by a blank-layout overwrite. + if (!(await resolveMachineBeforeTransport())) return + if (cancelled) return + configureMachineBoundTransport() + if (!(await ensureSidebarSessionsWindow())) return if (!(await loadVersionInfo())) return @@ -1580,6 +1709,12 @@ export default function App() { dispatch(setError(undefined)) dispatch(setStatus('ready')) dispatch(setServerInstanceId(ws.serverInstanceId)) + const selectedMachine = ( + appStore.getState() as unknown as { machineIdentity?: MachineIdentityState } + ).machineIdentity?.selectedMachine + if (selectedMachine) { + persistSelectedMachineId(selectedMachine.id, ws.serverInstanceId) + } dispatch(resetWsSnapshotReceived()) promoteRecentHttpSessionsBaseline() @@ -1621,8 +1756,12 @@ export default function App() { // page comes back to the front (visibilitychange/online/pageshow — the // iOS bfcache restore only fires pageshow). const ws = getWsClient() - const pokeWs = () => ws.poke() - const pokeWsWhenVisible = () => { if (document.visibilityState === 'visible') ws.poke() } + const pokeWs = () => { + if (machineTransportReady) ws.poke() + } + const pokeWsWhenVisible = () => { + if (machineTransportReady && document.visibilityState === 'visible') ws.poke() + } window.addEventListener('online', pokeWs) window.addEventListener('pageshow', pokeWs) document.addEventListener('visibilitychange', pokeWsWhenVisible) @@ -1633,6 +1772,7 @@ export default function App() { document.removeEventListener('visibilitychange', pokeWsWhenVisible) cancelled = true cleanedUp = true + machineTransportReady = false clearReconcileResultWait() cleanup?.() stopTabRegistrySync?.() @@ -1730,10 +1870,10 @@ export default function App() { // Ensure at least one tab exists for first-time users. useEffect(() => { - if (tabs.length === 0) { + if ((!machineIdentity || machineIdentity.status === 'ready') && tabs.length === 0) { dispatch(addTab({ mode: 'shell' })) } - }, [tabs.length, dispatch]) + }, [tabs.length, dispatch, machineIdentity]) const handleTerminalChromeRevealTouchStart = useCallback((event: ReactTouchEvent) => { if (!isMobile || view !== 'terminal') return @@ -1766,6 +1906,46 @@ export default function App() { } }, [exitFullscreen, isFullscreen, isLandscapeTerminalView, isMobile, view]) + if (machineIdentity && machineIdentity.status !== 'ready') { + if (machineIdentity.status === 'choosing') { + return ( + + ) + } + + return ( +
+
+

+ {machineIdentity.status === 'error' ? 'Could not choose a machine' : 'Preparing this machine'} +

+

+ {machineIdentity.status === 'error' + ? machineIdentity.error ?? 'Reload Freshell and try again.' + : 'Loading the saved workspace for this machine.'} +

+ {machineIdentity.status === 'error' ? ( + + ) : null} +
+
+ ) + } + const content = (() => { if (view === 'sessions') { return ( @@ -2079,8 +2259,9 @@ npm run serve`} }} /> )} - {/* LANE B3 (recover-my-panes): self-gating recovery offer — see docs/plans/2026-07-26-recover-my-panes.md */} - + {/* A server-owned machine hydrates its scoped durable workspace during + bootstrap. Legacy servers retain the older opt-in recovery panel. */} + {machineIdentity?.mode !== 'server-managed' ? : null} {/* In-app Stream Deck emulator — self-hides unless deck.virtualDeckOpen */} diff --git a/src/components/MachineChooser.tsx b/src/components/MachineChooser.tsx new file mode 100644 index 000000000..d5d0de8c1 --- /dev/null +++ b/src/components/MachineChooser.tsx @@ -0,0 +1,94 @@ +import { useState } from 'react' +import type { Machine } from '@/lib/machine-identity' +import { Button } from '@/components/ui/button' + +const HEADING_ID = 'machine-chooser-heading' + +export function MachineChooser({ + machines, + suggestedLabel, + onSelectMachine, + onAddMachine, +}: { + machines: Machine[] + suggestedLabel: string + onSelectMachine: (machine: Machine) => Promise | void + onAddMachine: (label: string) => Promise | void +}) { + const [newMachineName, setNewMachineName] = useState(suggestedLabel) + const [pending, setPending] = useState(false) + const [error, setError] = useState() + + const run = async (operation: () => Promise | void) => { + setPending(true) + setError(undefined) + try { + await operation() + } catch (cause) { + setError(cause instanceof Error ? cause.message : 'Could not choose a machine') + } finally { + setPending(false) + } + } + + return ( +
+
+

Choose a machine

+

+ This server already has saved workspaces. Choose the one you want to open, or add this computer as a new machine. +

+ +
+ {machines.map((machine) => ( + + ))} +
+ +
{ + event.preventDefault() + const label = newMachineName.trim() + if (!label) { + setError('Enter a name for this machine') + return + } + void run(() => onAddMachine(label)) + }} + > + + setNewMachineName(event.target.value)} + className="mt-2 h-10 w-full rounded-md border border-border bg-background px-3 text-sm focus:outline-none focus:ring-1 focus:ring-ring" + /> + {error ?

{error}

: null} +
+ +
+
+
+
+ ) +} diff --git a/src/components/settings/DevicesSettings.tsx b/src/components/settings/DevicesSettings.tsx index 8dde0b4b5..a13dd2cb9 100644 --- a/src/components/settings/DevicesSettings.tsx +++ b/src/components/settings/DevicesSettings.tsx @@ -1,137 +1,112 @@ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useAppDispatch, useAppSelector } from '@/store/hooks' -import { - dismissDeviceIds, - persistDeviceAliasesForDevices, - persistOwnDeviceLabel, - setTabRegistryDeviceAliases, - setTabRegistryDismissedDeviceIds, - setTabRegistryDeviceLabel, -} from '@/store/tabRegistrySlice' -import { buildKnownDevices, type KnownDevice } from '@/lib/known-devices' +import { renameMachine } from '@/lib/api' +import { clearSelectedMachineId } from '@/lib/machine-identity' +import { updateSelectedMachine, type MachineIdentityState } from '@/store/machineIdentitySlice' +import { setTabRegistryDeviceMeta } from '@/store/tabRegistrySlice' import type { SettingsSectionProps } from './settings-types' import { SettingsSection, SettingsRow, } from './settings-controls' +/** + * The wire protocol still calls this a device, but it is now the selected + * server-owned machine. There is intentionally no local alias or delete + * surface here: the server owns the canonical label and durable workspace. + */ export default function DevicesSettings(_props: SettingsSectionProps) { const dispatch = useAppDispatch() - const tabRegistryState = useAppSelector((s) => (s as any).tabRegistry) - const fallbackTabRegistry = useMemo(() => ({ - deviceId: 'local-device', - deviceLabel: 'local-device', - deviceAliases: {} as Record, - dismissedDeviceIds: [] as string[], - localOpen: [], - sameDeviceOpen: [], - remoteOpen: [], - closed: [], - devices: [], - }), []) - const tabRegistry = tabRegistryState ?? fallbackTabRegistry - const [deviceNameInputs, setDeviceNameInputs] = useState>({}) - - const knownDevices = useMemo(() => { - return buildKnownDevices({ - ownDeviceId: tabRegistry.deviceId, - ownDeviceLabel: tabRegistry.deviceLabel, - deviceAliases: tabRegistry.deviceAliases, - dismissedDeviceIds: tabRegistry.dismissedDeviceIds, - localOpen: tabRegistry.localOpen, - sameDeviceOpen: tabRegistry.sameDeviceOpen, - remoteOpen: tabRegistry.remoteOpen, - closed: tabRegistry.closed, - devices: tabRegistry.devices, - }) - }, [tabRegistry]) + const machineIdentity = useAppSelector( + (state) => (state as unknown as { machineIdentity?: MachineIdentityState }).machineIdentity, + ) + const machine = machineIdentity?.selectedMachine + const [label, setLabel] = useState(machine?.label ?? '') + const [saving, setSaving] = useState(false) + const [error, setError] = useState() useEffect(() => { - setDeviceNameInputs((current) => { - const next: Record = {} - for (const device of knownDevices) { - next[device.key] = current[device.key] ?? device.effectiveLabel - } - const changed = - Object.keys(current).length !== Object.keys(next).length || - Object.entries(next).some(([key, value]) => current[key] !== value) - return changed ? next : current - }) - }, [knownDevices]) + setLabel(machine?.label ?? '') + }, [machine?.id, machine?.label]) - const saveDeviceName = useCallback((device: KnownDevice) => { - const nextValue = (deviceNameInputs[device.key] || '').trim() - if (device.isOwn) { - const persisted = persistOwnDeviceLabel(nextValue || tabRegistry.deviceLabel) - dispatch(setTabRegistryDeviceLabel(persisted)) - setDeviceNameInputs((current) => ({ ...current, [device.key]: persisted })) + const renameSelectedMachine = useCallback(async () => { + const nextLabel = label.trim() + if (!machine) { + setError('No machine has been selected yet.') + return + } + if (!nextLabel) { + setError('Enter a machine name.') return } - const aliases = persistDeviceAliasesForDevices(device.deviceIds, nextValue || undefined) - dispatch(setTabRegistryDeviceAliases(aliases)) - setDeviceNameInputs((current) => ({ - ...current, - [device.key]: device.deviceIds.map((deviceId) => aliases[deviceId]).find(Boolean) || device.baseLabel, - })) - }, [deviceNameInputs, dispatch, tabRegistry.deviceLabel]) - const deleteDevice = useCallback((device: KnownDevice) => { - if (device.isOwn) return + setSaving(true) + setError(undefined) + try { + const renamed = await renameMachine(machine.id, nextLabel) + dispatch(updateSelectedMachine(renamed)) + // Keep the established wire fields canonical until the next hello/sync. + dispatch(setTabRegistryDeviceMeta({ + deviceId: renamed.id, + deviceLabel: renamed.label, + })) + setLabel(renamed.label) + } catch (cause) { + setError(cause instanceof Error ? cause.message : 'Could not rename this machine.') + } finally { + setSaving(false) + } + }, [dispatch, label, machine]) - const aliases = persistDeviceAliasesForDevices(device.deviceIds, undefined) - const dismissedIds = dismissDeviceIds(device.deviceIds) - dispatch(setTabRegistryDeviceAliases(aliases)) - dispatch(setTabRegistryDismissedDeviceIds(dismissedIds)) - setDeviceNameInputs((current) => { - const next = { ...current } - delete next[device.key] - return next - }) - }, [dispatch]) + const switchMachine = useCallback(() => { + // The reload is a transport boundary. It stops the current tab-sync lane + // before the chooser begins the next machine's scoped restoration. + clearSelectedMachineId() + window.location.reload() + }, []) return ( - {knownDevices.map((device) => ( - +
+ setLabel(event.target.value)} + className="h-10 w-full min-w-[14rem] rounded-md border border-border bg-muted px-3 text-sm focus:outline-none focus:ring-1 focus:ring-border md:h-8 md:w-[20rem]" + aria-label="Machine name" + placeholder="Machine name" + /> + +
+
+ + - {!device.isOwn ? ( - - ) : null} - - - ))} + Switch machine + + + {error ?

{error}

: null}
) } diff --git a/src/lib/api.ts b/src/lib/api.ts index cf1dae391..269cea2af 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -12,6 +12,7 @@ import { getAuthToken } from '@/lib/auth' import { sanitizeSessionLocators } from '@/lib/session-utils' import type { SessionLocator } from '@/store/paneTypes' import type { RecoveryInventory } from '@/lib/recovery/types' +import type { Machine } from '@/lib/machine-identity' import { type FreshAgentModelCapabilitiesResponse, } from '@shared/fresh-agent-model-capabilities' @@ -304,9 +305,56 @@ export async function getBootstrap(options: ApiRequestOptions = {}): Promise { +function parseMachine(value: unknown): Machine { + if (!value || typeof value !== 'object') throw new Error('Invalid machine response') + const machine = value as Record + if (typeof machine.id !== 'string' || !machine.id.trim()) throw new Error('Invalid machine id') + if (typeof machine.label !== 'string' || !machine.label.trim()) throw new Error('Invalid machine label') + const createdAt = machine.createdAt + const lastSeenAt = machine.lastSeenAt + if ( + typeof createdAt !== 'number' + || !Number.isFinite(createdAt) + || typeof lastSeenAt !== 'number' + || !Number.isFinite(lastSeenAt) + ) { + throw new Error('Invalid machine timestamps') + } + return { + id: machine.id, + label: machine.label, + createdAt, + lastSeenAt, + } +} + +export async function getMachines(): Promise { + const response = await api.get<{ machines?: unknown }>('/api/machines') + if (!Array.isArray(response.machines)) throw new Error('Invalid machines response') + return response.machines.map(parseMachine) +} + +export async function createMachine(label: string): Promise { + const response = await api.post<{ machine?: unknown }>('/api/machines', { label }) + return parseMachine(response.machine) +} + +export async function renameMachine(machineId: string, label: string): Promise { + const response = await api.patch<{ machine?: unknown }>(`/api/machines/${encodeURIComponent(machineId)}`, { label }) + return parseMachine(response.machine) +} + +export async function getRecoveryInventory( + clientInstanceId: string, + bootAgoMs: number, + options: { machineId?: string } = {}, +): Promise { return api.get( - `/api/recovery/inventory${buildQueryString([['clientInstanceId', clientInstanceId], ['bootAgoMs', Math.max(0, Math.round(bootAgoMs))]])}`, + `/api/recovery/inventory${buildQueryString([ + ['clientInstanceId', clientInstanceId], + ['bootAgoMs', Math.max(0, Math.round(bootAgoMs))], + ['machineId', options.machineId?.trim() || undefined], + ])}`, ) } diff --git a/src/lib/machine-identity.ts b/src/lib/machine-identity.ts new file mode 100644 index 000000000..b34962b9f --- /dev/null +++ b/src/lib/machine-identity.ts @@ -0,0 +1,223 @@ +import { + DEVICE_ID_STORAGE_KEY, + MACHINE_ID_STORAGE_KEY as STORED_MACHINE_ID_STORAGE_KEY, + MACHINE_SELECTION_RESET_STORAGE_KEY as STORED_MACHINE_SELECTION_RESET_STORAGE_KEY, + MACHINE_SELECTIONS_STORAGE_KEY as STORED_MACHINE_SELECTIONS_STORAGE_KEY, +} from '@/store/storage-keys' + +export const MACHINE_ID_STORAGE_KEY = STORED_MACHINE_ID_STORAGE_KEY +export const MACHINE_SELECTIONS_STORAGE_KEY = STORED_MACHINE_SELECTIONS_STORAGE_KEY +export const MACHINE_SELECTION_RESET_STORAGE_KEY = STORED_MACHINE_SELECTION_RESET_STORAGE_KEY +export const LEGACY_DEVICE_ID_STORAGE_KEY = DEVICE_ID_STORAGE_KEY + +export interface Machine { + id: string + label: string + /** Unix epoch milliseconds, as serialized by the server's MachineStore. */ + createdAt: number + /** Unix epoch milliseconds, as serialized by the server's MachineStore. */ + lastSeenAt: number +} + +export type MachineIdentityResolution = + | { kind: 'selected'; machine: Machine; source: 'saved' | 'legacy' | 'created' } + | { kind: 'chooser'; machines: Machine[]; suggestedLabel: string } + +type MachineSelectionMap = Record + +export type BrowserIdentityHints = { + platform?: string + userAgent?: string +} + +export type DesktopMachineApi = { + getHostname?: () => Promise +} + +function safeStorage(): Storage | undefined { + try { + if (typeof localStorage === 'undefined') return undefined + localStorage.getItem(MACHINE_ID_STORAGE_KEY) + return localStorage + } catch { + return undefined + } +} + +function nonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined +} + +function readSelections(storage: Pick | undefined): MachineSelectionMap { + if (!storage) return {} + try { + const raw = storage.getItem(MACHINE_SELECTIONS_STORAGE_KEY) + if (!raw) return {} + const parsed = JSON.parse(raw) + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {} + return Object.fromEntries( + Object.entries(parsed).flatMap(([serverInstanceId, machineId]) => { + const normalizedServerInstanceId = nonEmptyString(serverInstanceId) + const normalizedMachineId = nonEmptyString(machineId) + return normalizedServerInstanceId && normalizedMachineId + ? [[normalizedServerInstanceId, normalizedMachineId]] + : [] + }), + ) + } catch { + return {} + } +} + +function writeSelections(storage: Pick | undefined, selections: MachineSelectionMap): void { + if (!storage) return + try { + storage.setItem(MACHINE_SELECTIONS_STORAGE_KEY, JSON.stringify(selections)) + } catch { + // Storage can be disabled or full. The current browser session still has + // its resolved machine in Redux, so do not turn a write failure into a + // transport identity change. + } +} + +/** + * The selected machine is origin-scoped by the browser. Once a websocket + * supplies the durable server instance id, we additionally retain it in a + * small map keyed by that id. The direct key is needed before the first hello; + * the map protects a browser profile that later talks to more than one server. + */ +export function getSelectedMachineId(serverInstanceId?: string, storage = safeStorage()): string | undefined { + const normalizedServerInstanceId = nonEmptyString(serverInstanceId) + if (normalizedServerInstanceId) { + const fromServer = readSelections(storage)[normalizedServerInstanceId] + if (fromServer) return fromServer + } + try { + return nonEmptyString(storage?.getItem(MACHINE_ID_STORAGE_KEY)) + } catch { + return undefined + } +} + +export function persistSelectedMachineId( + machineId: string, + serverInstanceId?: string, + storage = safeStorage(), +): void { + const normalizedMachineId = nonEmptyString(machineId) + if (!normalizedMachineId || !storage) return + try { + storage.setItem(MACHINE_ID_STORAGE_KEY, normalizedMachineId) + // An explicit choice supersedes the one-shot instruction to ignore a + // legacy id after the user pressed Switch machine. + storage.removeItem(MACHINE_SELECTION_RESET_STORAGE_KEY) + } catch { + // Keep going: the server-instance map has the same best-effort semantics. + } + const normalizedServerInstanceId = nonEmptyString(serverInstanceId) + if (!normalizedServerInstanceId) return + const selections = readSelections(storage) + selections[normalizedServerInstanceId] = normalizedMachineId + writeSelections(storage, selections) +} + +/** Clear every cached selection for this browser origin. The reset marker + * prevents a retained legacy v2 id from silently selecting the old machine + * again after Switch machine. */ +export function clearSelectedMachineId(storage = safeStorage()): void { + if (!storage) return + try { + storage.removeItem(MACHINE_ID_STORAGE_KEY) + storage.removeItem(MACHINE_SELECTIONS_STORAGE_KEY) + storage.setItem(MACHINE_SELECTION_RESET_STORAGE_KEY, '1') + } catch { + // no-op when storage is unavailable + } +} + +export function getBrowserMachineLabel(hints: BrowserIdentityHints = {}): string { + const platform = hints.platform + ?? (typeof navigator !== 'undefined' ? navigator.platform : '') + const userAgent = hints.userAgent + ?? (typeof navigator !== 'undefined' ? navigator.userAgent : '') + const fingerprint = `${platform} ${userAgent}`.toLowerCase() + + if (fingerprint.includes('android')) return 'Android device 1' + if (fingerprint.includes('iphone')) return 'iPhone 1' + if (fingerprint.includes('ipad')) return 'iPad 1' + if (fingerprint.includes('win')) return 'Windows device 1' + if (fingerprint.includes('mac')) return 'macOS device 1' + if (fingerprint.includes('linux')) return 'Linux device 1' + return 'Browser device 1' +} + +function getDesktopApi(): DesktopMachineApi | undefined { + if (typeof window === 'undefined') return undefined + return (window as Window & { freshellDesktop?: DesktopMachineApi }).freshellDesktop +} + +/** + * Electron can name a machine from the renderer's own operating system. + * A browser intentionally gets only a broad platform label; the server owns + * label; the server may further canonicalize it when another machine already + * has the same name. + */ +export async function getSuggestedMachineLabel(): Promise { + const desktop = getDesktopApi() + if (typeof desktop?.getHostname === 'function') { + try { + const hostname = nonEmptyString(await desktop.getHostname()) + if (hostname) return hostname + } catch { + // Fall back to the browser-safe label when Electron IPC is unavailable. + } + } + return getBrowserMachineLabel() +} + +export async function resolveMachineIdentity({ + machines, + createMachine, + suggestedLabel, + serverInstanceId, + storage = safeStorage(), +}: { + machines: Machine[] + createMachine: (label: string) => Promise + suggestedLabel: string + serverInstanceId?: string + storage?: Storage +}): Promise { + const savedMachineId = getSelectedMachineId(serverInstanceId, storage) + const skipLegacyMigration = storage?.getItem(MACHINE_SELECTION_RESET_STORAGE_KEY) === '1' + const savedMachine = savedMachineId + ? machines.find((machine) => machine.id === savedMachineId) + : undefined + // A direct selection can be stale when this browser was last pointed at a + // different server. In that case a recognized legacy v2 id still wins over + // the chooser: it is the one durable identity this server explicitly knows. + const legacyMachineId = savedMachine || skipLegacyMigration + ? undefined + : nonEmptyString(storage?.getItem(LEGACY_DEVICE_ID_STORAGE_KEY)) + const legacyMachine = legacyMachineId + ? machines.find((machine) => machine.id === legacyMachineId) + : undefined + const selectedMachine = savedMachine ?? legacyMachine + + if (selectedMachine) { + persistSelectedMachineId(selectedMachine.id, serverInstanceId, storage) + return { + kind: 'selected', + machine: selectedMachine, + source: savedMachine ? 'saved' : 'legacy', + } + } + + if (machines.length === 0) { + const machine = await createMachine(suggestedLabel) + persistSelectedMachineId(machine.id, serverInstanceId, storage) + return { kind: 'selected', machine, source: 'created' } + } + + return { kind: 'chooser', machines, suggestedLabel } +} diff --git a/src/lib/machine-workspace.ts b/src/lib/machine-workspace.ts new file mode 100644 index 000000000..d29b23552 --- /dev/null +++ b/src/lib/machine-workspace.ts @@ -0,0 +1,78 @@ +import { getRecoveryInventory } from '@/lib/api' +import { bootCapturedAtMs } from '@/lib/recovery/boot-state' +import { buildRecoveryPlan } from '@/lib/recovery/build-recovery-plan' +import type { RecoveryInventory } from '@/lib/recovery/types' +import { addTerminalRestoreRequestId, armRecoveredLiveTerminalTarget } from '@/lib/terminal-restore' +import { getCurrentTabRegistryClientInstanceId } from '@/store/tabRegistrySync' +import { clearTabRegistryLocalClosed } from '@/store/tabRegistrySlice' +import { clearTabsForMachine, addTab } from '@/store/tabsSlice' +import { clearPanesForMachine, restoreLayout } from '@/store/panesSlice' +import type { PaneNode } from '@/store/paneTypes' +import type { RootState } from '@/store/store' + +type MachineWorkspaceStore = { + dispatch: (action: any) => unknown + getState: () => Pick +} + +function armTerminalRestores(state: Pick, tabIds: string[]): void { + const walk = (node: PaneNode | undefined): void => { + if (!node) return + if (node.type === 'leaf') { + if (node.content.kind === 'terminal' && node.content.sessionRef && node.content.createRequestId) { + addTerminalRestoreRequestId(node.content.createRequestId) + } + return + } + for (const child of node.children) walk(child) + } + for (const tabId of tabIds) walk(state.panes.layouts[tabId]) +} + +function assertInventoryIsScopedToMachine(inventory: RecoveryInventory, machineId: string): void { + const inventoryMachineId = inventory.device?.deviceId + if (inventoryMachineId && inventoryMachineId !== machineId) { + throw new Error( + `Refusing recovery for ${inventoryMachineId}: the selected machine is ${machineId}`, + ) + } +} + +/** + * Hydrate the selected machine's durable workspace before the websocket and + * tabs.sync are allowed to start. The server must honor the additive + * `machineId` inventory scope; checking the returned device id again keeps a + * stale server from restoring an arbitrary other machine into a fresh client. + */ +export async function restoreMachineWorkspace( + store: MachineWorkspaceStore, + machineId: string, +): Promise<{ restoredTabs: number }> { + const inventory = await getRecoveryInventory( + getCurrentTabRegistryClientInstanceId(), + Math.max(0, Date.now() - bootCapturedAtMs), + { machineId }, + ) + assertInventoryIsScopedToMachine(inventory, machineId) + const plans = inventory.recoverable ? buildRecoveryPlan(inventory) : [] + + // These are local cache actions, not tab/pane closes. Sync is still gated, + // so no blank or mixed-machine snapshot can reach the server mid-replace. + store.dispatch(clearTabsForMachine()) + store.dispatch(clearPanesForMachine()) + store.dispatch(clearTabRegistryLocalClosed()) + + for (const plan of plans) { + store.dispatch(addTab({ id: plan.tabId, title: plan.title })) + store.dispatch(restoreLayout({ + tabId: plan.tabId, + layout: plan.layout, + paneTitles: plan.paneTitles, + })) + for (const target of plan.liveTerminalReattach ?? []) { + armRecoveredLiveTerminalTarget(plan.tabId, target.paneId, target.terminalId) + } + } + armTerminalRestores(store.getState(), plans.map((plan) => plan.tabId)) + return { restoredTabs: plans.length } +} diff --git a/src/store/machineIdentitySlice.ts b/src/store/machineIdentitySlice.ts new file mode 100644 index 000000000..5f6e1c2e3 --- /dev/null +++ b/src/store/machineIdentitySlice.ts @@ -0,0 +1,66 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit' +import type { Machine } from '@/lib/machine-identity' + +export type MachineIdentityMode = 'server-managed' | 'legacy' +export type MachineIdentityStatus = 'resolving' | 'choosing' | 'restoring' | 'ready' | 'error' + +export interface MachineIdentityState { + status: MachineIdentityStatus + mode?: MachineIdentityMode + selectedMachine?: Machine + machines: Machine[] + suggestedLabel?: string + error?: string +} + +const initialState: MachineIdentityState = { + status: 'resolving', + machines: [], +} + +export const machineIdentitySlice = createSlice({ + name: 'machineIdentity', + initialState, + reducers: { + setMachineChooser: (state, action: PayloadAction<{ machines: Machine[]; suggestedLabel: string }>) => { + state.status = 'choosing' + state.mode = 'server-managed' + state.selectedMachine = undefined + state.machines = action.payload.machines + state.suggestedLabel = action.payload.suggestedLabel + state.error = undefined + }, + setMachineRestoring: (state, action: PayloadAction) => { + state.status = 'restoring' + state.mode = 'server-managed' + state.selectedMachine = action.payload + state.error = undefined + }, + setMachineReady: (state, action: PayloadAction<{ machine: Machine; mode: MachineIdentityMode }>) => { + state.status = 'ready' + state.mode = action.payload.mode + state.selectedMachine = action.payload.machine + state.error = undefined + }, + setMachineResolutionError: (state, action: PayloadAction) => { + state.status = 'error' + state.error = action.payload + }, + updateSelectedMachine: (state, action: PayloadAction) => { + state.selectedMachine = action.payload + state.machines = state.machines.map((machine) => ( + machine.id === action.payload.id ? action.payload : machine + )) + }, + }, +}) + +export const { + setMachineChooser, + setMachineRestoring, + setMachineReady, + setMachineResolutionError, + updateSelectedMachine, +} = machineIdentitySlice.actions + +export default machineIdentitySlice.reducer diff --git a/src/store/panesSlice.ts b/src/store/panesSlice.ts index 82ac41629..84a1c65ef 100644 --- a/src/store/panesSlice.ts +++ b/src/store/panesSlice.ts @@ -1287,6 +1287,28 @@ export const panesSlice = createSlice({ delete state.restoreFallbackAttemptsByPane?.[tabId] }, + /** See tabs/clearTabsForMachine. This runs before selected-machine + * recovery while no terminal components are mounted, so it deliberately + * bypasses the ordinary close workflow rather than producing false close + * records for another machine's panes. */ + clearPanesForMachine: (state) => { + state.layouts = {} + state.activePane = {} + state.paneTitles = {} + state.paneTitleSetByUser = {} + state.renameRequestTabId = null + state.renameRequestPaneId = null + state.zoomedPane = {} + state.refreshRequestsByPane = {} + state.focusEpochByPaneId = {} + state.restoreFallbackAttemptsByPane = {} + state.deadSessionAdjudication = [] + state.reconcileWarming = null + state.reconcilePendingPanes = {} + state.closingTabs = {} + state.closingPanes = {} + }, + splitPane: ( state, action: PayloadAction<{ @@ -2737,6 +2759,7 @@ export const { initLayout, restoreLayout, resetLayout, + clearPanesForMachine, splitPane, addPane, closePane, diff --git a/src/store/persistMiddleware.ts b/src/store/persistMiddleware.ts index 99ad3784d..8edaf33c9 100644 --- a/src/store/persistMiddleware.ts +++ b/src/store/persistMiddleware.ts @@ -727,6 +727,12 @@ export const persistMiddleware: Middleware<{}, PersistState> = (store) => { if (a.type === 'tabs/removeTab') { userClosedTabsIntent = true } + // A selected-machine restore is an explicit, server-confirmed cache + // replacement. If that machine is empty, persist the empty workspace + // rather than retaining the previous machine's local layout forever. + if (a.type === 'tabs/clearTabsForMachine') { + userClosedTabsIntent = true + } if (a.type.startsWith('panes/') && panesChanged) { panesDirty = true scheduleFlush() diff --git a/src/store/storage-keys.ts b/src/store/storage-keys.ts index 61c576a9a..a0a0258b8 100644 --- a/src/store/storage-keys.ts +++ b/src/store/storage-keys.ts @@ -14,6 +14,9 @@ export const STORAGE_KEYS = { deviceFingerprint: 'freshell.device-fingerprint.v2', deviceAliases: 'freshell.device-aliases.v2', deviceDismissed: 'freshell.device-dismissed.v1', + machineId: 'freshell.machine-id.v1', + machineSelections: 'freshell.machine-selections.v1', + machineSelectionReset: 'freshell.machine-selection-reset.v1', tabRegistryClientInstanceId: 'freshell.tabs.client-instance-id.v1', tabRegistrySnapshotRevision: 'freshell.tabs.snapshot-revision.v1', inputHistory: 'freshell.input-history.v1', @@ -34,5 +37,8 @@ export const DEVICE_LABEL_CUSTOM_STORAGE_KEY = STORAGE_KEYS.deviceLabelCustom export const DEVICE_FINGERPRINT_STORAGE_KEY = STORAGE_KEYS.deviceFingerprint export const DEVICE_ALIASES_STORAGE_KEY = STORAGE_KEYS.deviceAliases export const DEVICE_DISMISSED_STORAGE_KEY = STORAGE_KEYS.deviceDismissed +export const MACHINE_ID_STORAGE_KEY = STORAGE_KEYS.machineId +export const MACHINE_SELECTIONS_STORAGE_KEY = STORAGE_KEYS.machineSelections +export const MACHINE_SELECTION_RESET_STORAGE_KEY = STORAGE_KEYS.machineSelectionReset export const TAB_REGISTRY_CLIENT_INSTANCE_ID_STORAGE_KEY = STORAGE_KEYS.tabRegistryClientInstanceId export const TAB_REGISTRY_SNAPSHOT_REVISION_STORAGE_KEY = STORAGE_KEYS.tabRegistrySnapshotRevision diff --git a/src/store/store.ts b/src/store/store.ts index 0742bfc95..53db8c575 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -22,6 +22,7 @@ import paneRuntimeActivityReducer from './paneRuntimeActivitySlice' import hostStatsReducer from './hostStatsSlice' import { networkReducer } from './networkSlice' import tabRegistryReducer from './tabRegistrySlice' +import machineIdentityReducer from './machineIdentitySlice' import extensionsReducer from './extensionsSlice' import deckReducer from './deckSlice' import { perfMiddleware } from './perfMiddleware' @@ -74,6 +75,9 @@ export const store = configureStore({ hostStats: hostStatsReducer, network: networkReducer, tabRegistry: tabRegistryReducer, + // Server-owned workspace selection. This state gates renderer mounts and + // all tab-registry transport until a machine has been resolved. + machineIdentity: machineIdentityReducer, extensions: extensionsReducer, // Ephemeral device state — never persisted (allowlist rule) deck: deckReducer, diff --git a/src/store/tabRegistrySlice.ts b/src/store/tabRegistrySlice.ts index 999ae0f4f..dffb68d04 100644 --- a/src/store/tabRegistrySlice.ts +++ b/src/store/tabRegistrySlice.ts @@ -7,7 +7,6 @@ import { getClosedTabRetentionDaysPreference } from '@/lib/browser-preferences' import { DEVICE_ALIASES_STORAGE_KEY, DEVICE_DISMISSED_STORAGE_KEY, - DEVICE_FINGERPRINT_STORAGE_KEY, DEVICE_ID_STORAGE_KEY, DEVICE_LABEL_CUSTOM_STORAGE_KEY, DEVICE_LABEL_STORAGE_KEY, @@ -48,21 +47,11 @@ function normalizeDeviceLabel(input: string): string { } function buildDefaultDeviceLabel(hints: DeviceMetaHints = {}): string { - const hostName = hints.hostName?.trim() - if (hostName) return normalizeDeviceLabel(hostName) const platform = hints.platform || (typeof navigator !== 'undefined' ? (navigator.platform || 'device') : 'device') return normalizeDeviceLabel(platform.toLowerCase()) } -function buildDeviceFingerprint(hints: DeviceMetaHints = {}): string { - const ua = typeof navigator !== 'undefined' ? navigator.userAgent : 'unknown' - const platform = typeof navigator !== 'undefined' - ? (navigator.platform || 'device') - : (hints.platform || 'device') - return `${platform}|${ua}` -} - function loadDeviceAliases(storage: Storage | null): Record { if (!storage) return {} try { @@ -122,21 +111,9 @@ function loadDeviceMeta(hints: DeviceMetaHints = {}): { deviceId: string; device } let deviceId = storage.getItem(DEVICE_ID_STORAGE_KEY) || '' - const fingerprint = buildDeviceFingerprint(hints) - const storedFingerprint = storage.getItem(DEVICE_FINGERPRINT_STORAGE_KEY) || '' - const shouldRotateDeviceId = - !deviceId || - deviceId === 'device-unknown' || - (storedFingerprint && storedFingerprint !== fingerprint) - if (!deviceId) { - deviceId = randomId() - } - if (shouldRotateDeviceId) { + if (!deviceId || deviceId === 'device-unknown') { deviceId = randomId() storage.setItem(DEVICE_ID_STORAGE_KEY, deviceId) - storage.setItem(DEVICE_FINGERPRINT_STORAGE_KEY, fingerprint) - } else if (!storedFingerprint) { - storage.setItem(DEVICE_FINGERPRINT_STORAGE_KEY, fingerprint) } let deviceLabel = storage.getItem(DEVICE_LABEL_STORAGE_KEY) || '' @@ -147,13 +124,11 @@ function loadDeviceMeta(hints: DeviceMetaHints = {}): { deviceId: string; device storage.setItem(DEVICE_LABEL_STORAGE_KEY, deviceLabel) storage.setItem(DEVICE_LABEL_CUSTOM_STORAGE_KEY, '0') } else if (!isCustomLabel) { - const normalizedCurrent = normalizeDeviceLabel(deviceLabel) - if (normalizedCurrent !== defaultLabel) { - deviceLabel = defaultLabel - storage.setItem(DEVICE_LABEL_STORAGE_KEY, deviceLabel) - } else { - deviceLabel = normalizedCurrent - } + // Device labels and IDs once rotated when the browser fingerprint or the + // *server's* hostname changed. A server-owned machine selection now owns + // that decision. Preserve a legacy value exactly enough for the server to + // recognize and migrate it; never use hostName as a client identity hint. + deviceLabel = normalizeDeviceLabel(deviceLabel) } else { deviceLabel = normalizeDeviceLabel(deviceLabel) } @@ -282,7 +257,7 @@ export const tabRegistrySlice = createSlice({ state.deviceLabel = action.payload.deviceLabel }, setTabRegistryDeviceLabel: (state, action: PayloadAction) => { - state.deviceLabel = normalizeDeviceLabel(action.payload) + state.deviceLabel = action.payload.trim() || 'device' }, setTabRegistryDeviceAliases: (state, action: PayloadAction>) => { state.deviceAliases = action.payload diff --git a/src/store/tabsSlice.ts b/src/store/tabsSlice.ts index 617a0a391..6374fea48 100644 --- a/src/store/tabsSlice.ts +++ b/src/store/tabsSlice.ts @@ -370,6 +370,18 @@ export const tabsSlice = createSlice({ state.activeTabId = state.tabs[nextIndex]?.id ?? state.tabs[0].id } }, + /** + * A server-confirmed machine switch replaces the local cache before tab + * registry sync begins. This is deliberately not a user close: it must + * not emit pane-close evidence for tabs owned by the previously selected + * machine. + */ + clearTabsForMachine: (state) => { + state.tabs = [] + state.activeTabId = null + state.renameRequestTabId = null + state.tombstones = [] + }, hydrateTabs: (state, action: PayloadAction) => { const meta = (action as PayloadAction).meta const remoteTabs = (action.payload.tabs || []).map(migrateTabFields) @@ -455,6 +467,7 @@ export const { clearTabRenameRequest, updateTab, removeTab, + clearTabsForMachine, hydrateTabs, reorderTabs, switchToNextTab, diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index e6ac24864..5ba6cd120 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -9,6 +9,13 @@ interface ImportMeta { readonly env: ImportMetaEnv } +interface Window { + freshellDesktop?: { + isElectron?: boolean + getHostname?: () => Promise + } +} + declare const __PERF_LOGGING__: string declare const __FRESHELL_BUILD_ID__: string diff --git a/test/e2e-browser/specs/machine-identity.spec.ts b/test/e2e-browser/specs/machine-identity.spec.ts new file mode 100644 index 000000000..b31c78ad3 --- /dev/null +++ b/test/e2e-browser/specs/machine-identity.spec.ts @@ -0,0 +1,36 @@ +import { test, expect } from '../helpers/fixtures.js' + +test.describe('server-owned machine identity', () => { + test('a fresh browser chooses a machine before it opens a websocket or pushes tabs', async ({ + page, + serverInfo, + harness, + }) => { + await page.addInitScript(() => { + localStorage.clear() + sessionStorage.clear() + }) + await page.route('**/api/machines', async (route) => { + await route.fulfill({ + json: { + machines: [{ + id: 'machine-existing', + label: 'Existing coding machine', + createdAt: 1_789_171_200_000, + lastSeenAt: 1_789_171_200_000, + }], + }, + }) + }) + + await page.goto(`${serverInfo.baseUrl}/?token=${serverInfo.token}&e2e=1`) + await harness.waitForHarness() + + await expect(page.getByRole('dialog', { name: 'Choose a machine' })).toBeVisible() + await expect(page.getByRole('button', { name: /use existing coding machine/i })).toBeVisible() + await expect(page.getByRole('button', { name: 'Add this machine' })).toBeVisible() + + expect(await harness.getSentWsMessages()).toEqual([]) + expect(await harness.getConnectionStatus()).not.toBe('ready') + }) +}) diff --git a/test/e2e/settings-devices-flow.test.tsx b/test/e2e/settings-devices-flow.test.tsx index e18e42da1..9c7fcd72a 100644 --- a/test/e2e/settings-devices-flow.test.tsx +++ b/test/e2e/settings-devices-flow.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { Provider } from 'react-redux' import { configureStore } from '@reduxjs/toolkit' import SettingsView from '@/components/SettingsView' @@ -8,16 +8,18 @@ import tabsReducer from '@/store/tabsSlice' import connectionReducer from '@/store/connectionSlice' import sessionsReducer from '@/store/sessionsSlice' import { networkReducer } from '@/store/networkSlice' -import tabRegistryReducer, { type TabRegistryState } from '@/store/tabRegistrySlice' -import { DEVICE_DISMISSED_STORAGE_KEY } from '@/store/storage-keys' -import type { RegistryTabRecord } from '@/store/tabRegistryTypes' +import tabRegistryReducer from '@/store/tabRegistrySlice' +import machineIdentityReducer, { setMachineReady } from '@/store/machineIdentitySlice' import { composeResolvedSettings, createDefaultServerSettings, resolveLocalSettings, } from '@shared/settings' +const renameMachine = vi.hoisted(() => vi.fn()) + vi.mock('@/lib/api', () => ({ + renameMachine: (...args: unknown[]) => renameMachine(...args), api: { patch: vi.fn().mockResolvedValue({}), get: vi.fn().mockResolvedValue({}), @@ -27,50 +29,19 @@ vi.mock('@/lib/api', () => ({ }, })) -function makeRecord(overrides: Partial): RegistryTabRecord { - return { - tabKey: 'remote-a:tab-1', - tabId: 'tab-1', - serverInstanceId: 'srv-test', - deviceId: 'remote-a', - deviceLabel: 'studio-mac', - tabName: 'work item', - status: 'open', - revision: 1, - createdAt: 1, - updatedAt: 2, - paneCount: 1, - titleSetByUser: false, - panes: [], - ...overrides, - } -} - -function createTabRegistryState(overrides: Partial = {}): TabRegistryState { - return { - ...(tabRegistryReducer(undefined, { type: '@@INIT' }) as TabRegistryState), - deviceId: 'local-device', - deviceLabel: 'local-device', - localOpen: [], - sameDeviceOpen: [], - remoteOpen: [], - devices: [], - closed: [], - localClosed: {}, - closedTabRetentionDays: 30, - loading: false, - searchRangeDays: 30, - ...overrides, - } +const MACHINE = { + id: 'machine-desktop', + label: 'DANDESKTOP', + createdAt: 1_789_171_200_000, + lastSeenAt: 1_789_171_200_000, } -function createStore(tabRegistryState: Partial = {}) { +function createStore() { const serverSettings = createDefaultServerSettings({ loggingDebug: defaultSettings.logging.debug, }) const localSettings = resolveLocalSettings() - - return configureStore({ + const store = configureStore({ reducer: { settings: settingsReducer, tabs: tabsReducer, @@ -78,13 +49,11 @@ function createStore(tabRegistryState: Partial = {}) { sessions: sessionsReducer, network: networkReducer, tabRegistry: tabRegistryReducer, + machineIdentity: machineIdentityReducer, }, - middleware: (getDefault) => - getDefault({ - serializableCheck: { - ignoredPaths: ['sessions.expandedProjects'], - }, - }), + middleware: (getDefault) => getDefault({ + serializableCheck: { ignoredPaths: ['sessions.expandedProjects'] }, + }), preloadedState: { settings: { serverSettings, @@ -93,43 +62,26 @@ function createStore(tabRegistryState: Partial = {}) { loaded: true, lastSavedAt: undefined, }, - tabRegistry: createTabRegistryState(tabRegistryState), }, }) + store.dispatch(setMachineReady({ machine: MACHINE, mode: 'server-managed' })) + return store } -describe('settings devices management flow (e2e)', () => { +describe('settings machine management flow (e2e)', () => { beforeEach(() => { localStorage.clear() - vi.useFakeTimers() + renameMachine.mockReset() }) afterEach(() => { cleanup() - vi.useRealTimers() + localStorage.clear() }) - it('renders server-backed device rows, deletes one remote device, and renders Devices last', async () => { - const store = createStore({ - remoteOpen: [ - makeRecord({ deviceId: 'remote-a', deviceLabel: 'studio-mac', tabKey: 'remote-a:tab-1' }), - ], - devices: [ - { deviceId: 'remote-a', deviceLabel: 'studio-mac', lastSeenAt: 10 }, - { deviceId: 'remote-b', deviceLabel: 'studio-mac', lastSeenAt: 5 }, - ], - closed: [ - makeRecord({ - deviceId: 'remote-b', - deviceLabel: 'studio-mac', - tabKey: 'remote-b:tab-2', - tabId: 'tab-2', - status: 'closed', - closedAt: 5, - updatedAt: 5, - }), - ], - }) + it('renames the selected machine and exposes the switch control through Settings', async () => { + renameMachine.mockResolvedValue({ ...MACHINE, label: 'Dan desktop' }) + const store = createStore() render( @@ -137,21 +89,18 @@ describe('settings devices management flow (e2e)', () => { , ) - fireEvent.click(screen.getByRole('tab', { name: /^network$/i })) - expect(screen.getByRole('heading', { name: /^network$/i })).toBeInTheDocument() - expect(screen.getByRole('switch', { name: /remote access/i })).toBeInTheDocument() - fireEvent.click(screen.getByRole('tab', { name: /^advanced$/i })) - expect(screen.getAllByLabelText('Device name for studio-mac')).toHaveLength(2) - expect(screen.getByRole('heading', { name: 'Devices' })).toBeInTheDocument() + expect(screen.getByRole('heading', { name: 'Machine' })).toBeInTheDocument() - fireEvent.click(screen.getAllByRole('button', { name: 'Delete device studio-mac' })[0]) - - await act(async () => { - await Promise.resolve() + fireEvent.change(screen.getByRole('textbox', { name: 'Machine name' }), { + target: { value: 'Dan desktop' }, }) + fireEvent.click(screen.getByRole('button', { name: 'Rename machine' })) - expect(screen.getAllByLabelText('Device name for studio-mac')).toHaveLength(1) - expect(JSON.parse(localStorage.getItem(DEVICE_DISMISSED_STORAGE_KEY) || '[]')).toEqual(['remote-a']) + await waitFor(() => { + expect(renameMachine).toHaveBeenCalledWith(MACHINE.id, 'Dan desktop') + }) + expect(store.getState().machineIdentity.selectedMachine?.label).toBe('Dan desktop') + expect(screen.getByRole('button', { name: 'Switch machine' })).toBeInTheDocument() }) }) diff --git a/test/unit/client/components/App.machine-identity.test.tsx b/test/unit/client/components/App.machine-identity.test.tsx new file mode 100644 index 000000000..60207822c --- /dev/null +++ b/test/unit/client/components/App.machine-identity.test.tsx @@ -0,0 +1,237 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, render, screen, waitFor } from '@testing-library/react' +import { Provider } from 'react-redux' +import { configureStore } from '@reduxjs/toolkit' +import App from '@/App' +import settingsReducer, { defaultSettings } from '@/store/settingsSlice' +import tabsReducer from '@/store/tabsSlice' +import connectionReducer from '@/store/connectionSlice' +import sessionsReducer from '@/store/sessionsSlice' +import panesReducer from '@/store/panesSlice' +import tabRegistryReducer from '@/store/tabRegistrySlice' +import terminalMetaReducer from '@/store/terminalMetaSlice' +import extensionsReducer from '@/store/extensionsSlice' +import machineIdentityReducer from '@/store/machineIdentitySlice' +import { networkReducer } from '@/store/networkSlice' +import { MACHINE_ID_STORAGE_KEY, type Machine } from '@/lib/machine-identity' +import { + composeResolvedSettings, + createDefaultServerSettings, + resolveLocalSettings, +} from '@shared/settings' + +vi.mock('@/components/TabContent', () => ({ default: () =>
})) +vi.mock('@/components/Sidebar', () => ({ default: () =>