From bedaccbc46d0ced7a01a5f29df3477ee4f5eb7be Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sun, 30 Aug 2026 11:48:30 -0300 Subject: [PATCH 01/12] test(session): stabilize force-kill timing Keep the assertion tied to the two-second supervisor interval while using the production-like one-second reap deadline from the shared test configuration. This avoids treating a loaded macOS runner's inability to reap within 50 ms as a runtime failure. --- crates/session/tests/pty_sessions/lifecycle.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/session/tests/pty_sessions/lifecycle.rs b/crates/session/tests/pty_sessions/lifecycle.rs index 0010d70..bee8e27 100644 --- a/crates/session/tests/pty_sessions/lifecycle.rs +++ b/crates/session/tests/pty_sessions/lifecycle.rs @@ -71,9 +71,9 @@ fn stop_force_kills_a_process_that_ignores_graceful_signals() { #[test] fn force_kill_reaps_without_waiting_for_the_supervisor_interval() { + let supervisor_interval = Duration::from_secs(2); let config = SessionManagerConfig { - supervisor_interval: Duration::from_secs(2), - kill_wait: Duration::from_millis(50), + supervisor_interval, ..test_config() }; let runtime = TestRuntime::with_config(config); @@ -83,8 +83,12 @@ fn force_kill_reaps_without_waiting_for_the_supervisor_interval() { let started = Instant::now(); let snapshot = runtime.manager.kill(session_id).unwrap(); + let elapsed = started.elapsed(); - assert!(started.elapsed() < Duration::from_millis(500)); + assert!( + elapsed < supervisor_interval, + "force kill took {elapsed:?}, so it may have waited for the supervisor" + ); assert_eq!(snapshot.status, SessionStatus::Exited); let (terminal_transitions, exit_events) = count_terminal_events(&mut events); assert_eq!(terminal_transitions, 1); From c69be7b63cb00dedfe5885a408d84636d28a85d7 Mon Sep 17 00:00:00 2001 From: guicybercode Date: Thu, 3 Sep 2026 10:28:38 -0300 Subject: [PATCH 02/12] fix(session): keep process snapshots monotonic --- crates/session/src/runtime/process_tree.rs | 87 +++++++++++++++++++++- 1 file changed, 83 insertions(+), 4 deletions(-) diff --git a/crates/session/src/runtime/process_tree.rs b/crates/session/src/runtime/process_tree.rs index 50eca30..d2b14b6 100644 --- a/crates/session/src/runtime/process_tree.rs +++ b/crates/session/src/runtime/process_tree.rs @@ -1,7 +1,10 @@ use std::{ collections::{BTreeMap, BTreeSet, HashMap}, io, - sync::{Arc, OnceLock}, + sync::{ + Arc, OnceLock, + atomic::{AtomicU64, Ordering}, + }, time::{Duration, Instant}, }; @@ -12,11 +15,13 @@ const SNAPSHOT_CACHE_TTL: Duration = Duration::from_millis(20); #[derive(Clone)] struct CachedSnapshot { + sequence: u64, captured_at: Instant, records: Arc<[ProcessRecord]>, } static PROCESS_SNAPSHOT_CACHE: OnceLock>> = OnceLock::new(); +static NEXT_SNAPSHOT_SEQUENCE: AtomicU64 = AtomicU64::new(1); #[derive(Clone, Debug, Eq, PartialEq)] struct ProcessIdentity { @@ -40,6 +45,7 @@ pub(super) struct TrackedProcess { pub(super) struct ProcessTree { known: BTreeMap, + root_snapshot_sequence: u64, scan_timeout: Duration, max_tracked_processes: usize, } @@ -54,6 +60,7 @@ impl ProcessTree { let root_pid = root_pid.as_raw(); let mut tree = Self { known: BTreeMap::new(), + root_snapshot_sequence: records.sequence, scan_timeout, max_tracked_processes, }; @@ -69,7 +76,10 @@ impl ProcessTree { } pub fn refresh(&mut self) -> io::Result> { - let snapshot = process_snapshot(self.scan_timeout, false)?; + // A global scan can begin before this tree's root exists and publish + // after the root-proving scan. Never let that older view prune the root. + let snapshot = + process_snapshot_after(self.scan_timeout, false, Some(self.root_snapshot_sequence))?; self.absorb(&snapshot.records) } @@ -144,6 +154,7 @@ impl ProcessTree { fn with_root_for_test(root: ProcessRecord, max_tracked_processes: usize) -> Self { Self { known: BTreeMap::from([(root.identity.pid, root.identity)]), + root_snapshot_sequence: 1, scan_timeout: Duration::from_secs(1), max_tracked_processes, } @@ -151,7 +162,16 @@ impl ProcessTree { } fn process_snapshot(timeout: Duration, force: bool) -> io::Result { + process_snapshot_after(timeout, force, None) +} + +fn process_snapshot_after( + timeout: Duration, + force: bool, + minimum_sequence: Option, +) -> io::Result { let started = Instant::now(); + let sequence = next_snapshot_sequence()?; let cache = PROCESS_SNAPSHOT_CACHE.get_or_init(|| Mutex::new(None)); if !force { let Some(cached) = cache.try_lock_for(timeout) else { @@ -161,7 +181,7 @@ fn process_snapshot(timeout: Duration, force: bool) -> io::Result io::Result io::Result io::Result { + // The counter is only an ordering token; the cache mutex publishes the + // snapshot data, so no cross-thread memory ordering is required here. + NEXT_SNAPSHOT_SEQUENCE + .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |sequence| { + sequence.checked_add(1) + }) + .map_err(|_| io::Error::other("process-tree snapshot sequence exhausted")) +} + +fn cached_snapshot_is_usable(snapshot: &CachedSnapshot, minimum_sequence: Option) -> bool { + snapshot.captured_at.elapsed() <= SNAPSHOT_CACHE_TTL + && minimum_sequence.is_none_or(|minimum| snapshot.sequence >= minimum) +} + +fn publish_snapshot(cache: &mut Option, snapshot: CachedSnapshot) { + let should_publish = cache + .as_ref() + .is_none_or(|cached| snapshot.sequence > cached.sequence); + if should_publish { + *cache = Some(snapshot); + } +} + #[cfg(target_os = "linux")] fn scan_processes_uncached(timeout: Duration) -> io::Result> { const MAX_PROCESS_SNAPSHOT_RECORDS: usize = 262_144; @@ -365,6 +412,38 @@ mod tests { } } + fn snapshot( + sequence: u64, + captured_at: Instant, + records: impl Into>, + ) -> CachedSnapshot { + CachedSnapshot { + sequence, + captured_at, + records: records.into(), + } + } + + #[test] + fn out_of_order_scan_cannot_replace_a_newer_cached_snapshot() { + let newer = snapshot(2, Instant::now(), vec![record(200, 1, 200, "newer")]); + let older = snapshot(1, Instant::now(), vec![record(100, 1, 100, "older")]); + let mut cache = Some(newer); + + publish_snapshot(&mut cache, older); + + let cached = cache.expect("newer cache entry should be retained"); + assert_eq!(cached.sequence, 2); + assert_eq!(cached.records[0].identity.pid, 200); + } + + #[test] + fn cached_snapshot_from_before_root_proof_is_not_usable() { + let stale = snapshot(1, Instant::now(), Vec::::new()); + + assert!(!cached_snapshot_is_usable(&stale, Some(2))); + } + #[test] fn only_proven_descendants_are_retained_across_group_changes() { let root = record(100, 1, 100, "root"); From b843c1975f59b3633f1c065a0779f6b51143be43 Mon Sep 17 00:00:00 2001 From: guicybercode Date: Thu, 3 Sep 2026 10:32:05 -0300 Subject: [PATCH 03/12] fix(session): advance process snapshot floor --- crates/session/src/runtime/process_tree.rs | 54 ++++++++++++++++------ 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/crates/session/src/runtime/process_tree.rs b/crates/session/src/runtime/process_tree.rs index d2b14b6..04bc9b5 100644 --- a/crates/session/src/runtime/process_tree.rs +++ b/crates/session/src/runtime/process_tree.rs @@ -45,7 +45,7 @@ pub(super) struct TrackedProcess { pub(super) struct ProcessTree { known: BTreeMap, - root_snapshot_sequence: u64, + latest_snapshot_sequence: u64, scan_timeout: Duration, max_tracked_processes: usize, } @@ -56,36 +56,45 @@ impl ProcessTree { scan_timeout: Duration, max_tracked_processes: usize, ) -> io::Result { - let records = process_snapshot(scan_timeout, true)?; + let snapshot = process_snapshot(scan_timeout, true)?; let root_pid = root_pid.as_raw(); let mut tree = Self { known: BTreeMap::new(), - root_snapshot_sequence: records.sequence, + latest_snapshot_sequence: snapshot.sequence, scan_timeout, max_tracked_processes, }; - if let Some(root) = records + if let Some(root) = snapshot .records .iter() .find(|record| record.identity.pid == root_pid && !record.zombie) { tree.known.insert(root_pid, root.identity.clone()); } - let _ = tree.absorb(&records.records)?; + let _ = tree.absorb_snapshot(&snapshot)?; Ok(tree) } pub fn refresh(&mut self) -> io::Result> { - // A global scan can begin before this tree's root exists and publish - // after the root-proving scan. Never let that older view prune the root. - let snapshot = - process_snapshot_after(self.scan_timeout, false, Some(self.root_snapshot_sequence))?; - self.absorb(&snapshot.records) + // A global scan can begin before this tree's latest evidence and + // publish afterward. Never let that older view prune a proven process. + let snapshot = process_snapshot_after( + self.scan_timeout, + false, + Some(self.latest_snapshot_sequence), + )?; + self.absorb_snapshot(&snapshot) } pub fn refresh_fresh(&mut self) -> io::Result> { let snapshot = process_snapshot(self.scan_timeout, true)?; - self.absorb(&snapshot.records) + self.absorb_snapshot(&snapshot) + } + + fn absorb_snapshot(&mut self, snapshot: &CachedSnapshot) -> io::Result> { + let processes = self.absorb(&snapshot.records)?; + self.latest_snapshot_sequence = self.latest_snapshot_sequence.max(snapshot.sequence); + Ok(processes) } fn absorb(&mut self, records: &[ProcessRecord]) -> io::Result> { @@ -154,7 +163,7 @@ impl ProcessTree { fn with_root_for_test(root: ProcessRecord, max_tracked_processes: usize) -> Self { Self { known: BTreeMap::from([(root.identity.pid, root.identity)]), - root_snapshot_sequence: 1, + latest_snapshot_sequence: 1, scan_timeout: Duration::from_secs(1), max_tracked_processes, } @@ -438,12 +447,31 @@ mod tests { } #[test] - fn cached_snapshot_from_before_root_proof_is_not_usable() { + fn cached_snapshot_from_before_latest_tree_evidence_is_not_usable() { let stale = snapshot(1, Instant::now(), Vec::::new()); assert!(!cached_snapshot_is_usable(&stale, Some(2))); } + #[test] + fn fresh_snapshot_advances_the_tree_cache_floor() { + let root = record(100, 1, 100, "root"); + let child = record(101, 100, 101, "child"); + let mut tree = ProcessTree::with_root_for_test(root.clone(), 8); + let newer = snapshot(3, Instant::now(), vec![root.clone(), child]); + + tree.absorb_snapshot(&newer) + .expect("newer process-tree evidence should be accepted"); + + let stale = snapshot(2, Instant::now(), vec![root]); + assert_eq!(tree.latest_snapshot_sequence, 3); + assert!(tree.known.contains_key(&101)); + assert!(!cached_snapshot_is_usable( + &stale, + Some(tree.latest_snapshot_sequence) + )); + } + #[test] fn only_proven_descendants_are_retained_across_group_changes() { let root = record(100, 1, 100, "root"); From 7693468ed7dba150a0d8fc5ea56e37f18bf85e45 Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sat, 5 Sep 2026 21:47:04 -0300 Subject: [PATCH 04/12] refactor(session): separate worktree preparation from process launch --- crates/session/src/create.rs | 227 ++++++++++++---- crates/session/src/lib.rs | 5 +- crates/session/src/remove.rs | 20 ++ crates/session/src/saga.rs | 115 ++++++-- crates/session/tests/prepare_saga.rs | 328 +++++++++++++++++++++++ crates/storage/src/sessions.rs | 65 +++-- crates/storage/src/worktrees.rs | 45 +++- crates/storage/tests/agents_worktrees.rs | 78 ++++++ 8 files changed, 773 insertions(+), 110 deletions(-) create mode 100644 crates/session/tests/prepare_saga.rs diff --git a/crates/session/src/create.rs b/crates/session/src/create.rs index c64f9d9..2504449 100644 --- a/crates/session/src/create.rs +++ b/crates/session/src/create.rs @@ -1,7 +1,7 @@ -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; -use cli_master_core::wire::SessionIsolation; +use cli_master_core::wire::{RelativeDirectory, SessionIsolation}; use cli_master_core::{ AgentId, ProjectId, Session, SessionId, SessionStatus, Worktree, WorktreeId, }; @@ -18,6 +18,25 @@ use crate::token::now_ms; const DEFAULT_PTY_COLS: u16 = 80; const DEFAULT_PTY_ROWS: u16 = 24; +#[derive(Clone, Copy)] +pub(crate) enum Launch<'a> { + Prepare(Option<&'a RelativeDirectory>), + Start, +} + +impl<'a> Launch<'a> { + fn relative_directory(self) -> Option<&'a RelativeDirectory> { + match self { + Self::Prepare(relative) => relative, + Self::Start => None, + } + } + + fn starts_process(self) -> bool { + matches!(self, Self::Start) + } +} + /// Named saga effect after which tests may inject a failure. #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum CreateStep { @@ -82,7 +101,7 @@ pub struct CreateSession { /// Durable result of a completed create saga. #[derive(Clone, Debug, Eq, PartialEq)] pub struct CreatedSession { - /// Persisted session, including daemon-observed pid after spawn. + /// Persisted session; preparing metadata alone never assigns a pid. pub session: Session, /// Managed worktree when isolation requested one. pub worktree: Option, @@ -94,10 +113,11 @@ pub(crate) fn create( saga: &SessionWorktreeSaga, request: &CreateSession, faults: &CreateFaults, + launch: Launch<'_>, ) -> Result { match request.isolation { - SessionIsolation::Current => create_current(saga, request, faults), - SessionIsolation::NewWorktree => create_worktree(saga, request, faults), + SessionIsolation::Current => create_current(saga, request, faults, launch), + SessionIsolation::NewWorktree => create_worktree(saga, request, faults, launch), } } @@ -105,21 +125,31 @@ fn create_current( saga: &SessionWorktreeSaga, request: &CreateSession, faults: &CreateFaults, + launch: Launch<'_>, ) -> Result { let now = now_ms(); let project = require_project(saga, request.project_id)?; let agent = require_agent(saga, request.agent_id)?; + let cwd = resolve_session_directory(&project.path, launch.relative_directory())?; + let command = agent + .command_for_cwd(cwd.clone()) + .map_err(SagaError::from)?; maybe_fail(faults, CreateStep::Plan)?; maybe_fail(faults, CreateStep::PersistCreating)?; maybe_fail(faults, CreateStep::GitAdd)?; let session_id = SessionId::new(); - insert_starting_session(saga, session_id, request, project.path.clone(), now)?; + let session = session_row(saga, session_id, request, cwd, now, launch); + saga.storage().insert_session(&session)?; maybe_fail(faults, CreateStep::PersistActive).inspect_err(|_| { discard_session(saga, session_id); })?; - let command = agent - .command_for_cwd(project.path.clone()) - .map_err(SagaError::from)?; + if !launch.starts_process() { + return Ok(CreatedSession { + session: session_dto(session, None, None), + worktree: None, + plan: None, + }); + } let spawned = saga .spawner .spawn(SpawnRequest { @@ -159,10 +189,11 @@ fn create_worktree( saga: &SessionWorktreeSaga, request: &CreateSession, faults: &CreateFaults, + launch: Launch<'_>, ) -> Result { let now = now_ms(); let project = require_project(saga, request.project_id)?; - let agent = require_agent(saga, request.agent_id)?; + require_agent(saga, request.agent_id)?; let worktree_id = WorktreeId::new(); let session_id = SessionId::new(); let short_id = request @@ -170,11 +201,22 @@ fn create_worktree( .clone() .unwrap_or_else(|| short_id_for(worktree_id)); let plan = saga.git.plan_worktree( - &project.path, + project.repository_root.as_ref().unwrap_or(&project.path), &request.managed_root, &request.name, &short_id, )?; + let selected_directory = canonical_directory(&project.path)?; + let project_relative = selected_directory + .strip_prefix(plan.repository_root()) + .map_err(|_| { + SagaError::new( + SagaErrorKind::InvalidInput, + "selected project directory is outside its Git repository root", + "Register a directory inside the repository and try again", + ) + .with_path(&project.path) + })?; if let Some(hook) = &faults.after_plan { hook(&plan); } @@ -208,10 +250,10 @@ fn create_worktree( saga, request, faults, - &agent, &plan, - (worktree_id, session_id), + (worktree_id, session_id, project_relative), now, + launch, ) } @@ -219,33 +261,50 @@ fn persist_spawn_and_run( saga: &SessionWorktreeSaga, request: &CreateSession, faults: &CreateFaults, - agent: &cli_master_storage::StoredAgent, plan: &WorktreePlan, - ids: (WorktreeId, SessionId), + ids: (WorktreeId, SessionId, &Path), now: i64, + launch: Launch<'_>, ) -> Result { - let (worktree_id, session_id) = ids; - if let Err(error) = insert_starting_session( - saga, - session_id, - request, - plan.destination().to_path_buf(), - now, - ) { - return Err(compensate(saga, plan, worktree_id, None, error)); - } - if let Err(error) = activate_worktree(saga, worktree_id, session_id, now) { - discard_session(saga, session_id); - return Err(compensate(saga, plan, worktree_id, None, error)); + let (worktree_id, session_id, project_relative) = ids; + let selected_root = resolve_directory( + plan.destination(), + &plan.destination().join(project_relative), + ) + .map_err(|error| compensate(saga, plan, worktree_id, None, error))?; + let cwd = resolve_session_directory(&selected_root, launch.relative_directory()) + .map_err(|error| compensate(saga, plan, worktree_id, None, error))?; + let agent = require_agent(saga, request.agent_id) + .map_err(|error| compensate(saga, plan, worktree_id, None, error))?; + let command = agent + .command_for_cwd(cwd.clone()) + .map_err(|error| compensate(saga, plan, worktree_id, None, SagaError::from(error)))?; + let session = session_row(saga, session_id, request, cwd, now, launch); + let persisted = + saga.storage() + .insert_prepared_session_with_worktree(&session, worktree_id, now); + if let Err(error) = persisted { + return Err(compensate( + saga, + plan, + worktree_id, + None, + SagaError::from(error), + )); } if let Err(error) = maybe_fail(faults, CreateStep::PersistActive) { - discard_session(saga, session_id); return Err(compensate(saga, plan, worktree_id, Some(session_id), error)); } - let command = agent - .command_for_cwd(plan.destination().to_path_buf()) - .map_err(SagaError::from)?; + if !launch.starts_process() { + let worktree = require_worktree(saga, worktree_id) + .map_err(|error| compensate(saga, plan, worktree_id, Some(session_id), error))?; + return Ok(CreatedSession { + session: session_dto(session, Some(&worktree), None), + worktree: Some(worktree_dto(worktree)), + plan: Some(plan.clone()), + }); + } let spawned = match saga.spawner.spawn(SpawnRequest { session_id, project_id: request.project_id, @@ -311,46 +370,35 @@ fn persist_creating( .map_err(SagaError::from) } -fn insert_starting_session( +fn session_row( saga: &SessionWorktreeSaga, session_id: SessionId, request: &CreateSession, cwd: PathBuf, now: i64, -) -> Result<(), SagaError> { - let row = StoredSession { + launch: Launch<'_>, +) -> StoredSession { + StoredSession { id: session_id, project_id: request.project_id, agent_id: request.agent_id, name: request.name.clone(), cwd, - status: SessionStatus::Starting, + status: if launch.starts_process() { + SessionStatus::Starting + } else { + SessionStatus::Unknown + }, runtime_pid: None, - daemon_instance_id: Some(saga.daemon_instance_id.clone()), + daemon_instance_id: launch + .starts_process() + .then(|| saga.daemon_instance_id.clone()), exit_code: None, error_code: None, created_at_ms: now, updated_at_ms: now, - last_activity_at_ms: Some(now), - }; - saga.storage().insert_session(&row).map_err(SagaError::from) -} - -fn activate_worktree( - saga: &SessionWorktreeSaga, - worktree_id: WorktreeId, - session_id: SessionId, - now: i64, -) -> Result<(), SagaError> { - saga.storage() - .update_worktree_state( - worktree_id, - WorktreeState::Active, - false, - Some(session_id), - now, - ) - .map_err(SagaError::from) + last_activity_at_ms: launch.starts_process().then_some(now), + } } fn persist_running( @@ -424,7 +472,15 @@ fn compensate( original } Err(error) => { - mark_orphaned(saga, worktree_id, session_id); + // Successful session deletion clears the foreign-key association; + // do not try to reattach an identifier that no longer exists. + let remaining_session = saga + .storage() + .get_worktree(worktree_id) + .ok() + .flatten() + .and_then(|worktree| worktree.session_id); + mark_orphaned(saga, worktree_id, remaining_session); if error.kind() == cli_master_git::GitErrorKind::PartialWorktree { return SagaError::from(error).with_worktree_id(worktree_id); } @@ -510,3 +566,58 @@ pub(crate) fn require_worktree( .with_worktree_id(worktree_id) }) } + +/// Resolves an existing session directory inside its daemon-selected root. +/// +/// Both paths are canonicalized so a child symlink cannot escape the project +/// or managed worktree. The typed relative directory has already passed wire +/// validation for parent traversal and absolute paths. +/// +/// # Errors +/// +/// Returns an error when either directory is unavailable or the resolved child +/// is outside the canonical root. +pub fn resolve_session_directory( + root: &Path, + relative_directory: Option<&RelativeDirectory>, +) -> Result { + let directory = relative_directory.map_or_else( + || root.to_path_buf(), + |relative| root.join(relative.as_str()), + ); + resolve_directory(root, &directory) +} + +fn resolve_directory(root: &Path, directory: &Path) -> Result { + let canonical_root = canonical_directory(root)?; + let canonical_directory = canonical_directory(directory)?; + if !canonical_directory.starts_with(&canonical_root) { + return Err(SagaError::new( + SagaErrorKind::InvalidInput, + "session directory resolves outside its project or worktree root", + "Choose an existing directory inside the session root", + ) + .with_path(directory)); + } + Ok(canonical_directory) +} + +fn canonical_directory(directory: &Path) -> Result { + let resolved = directory.canonicalize().map_err(|error| { + SagaError::new( + SagaErrorKind::InvalidInput, + format!("session directory could not be opened: {error}"), + "Choose an existing directory inside the session root and check its permissions", + ) + .with_path(directory) + })?; + if !resolved.is_dir() { + return Err(SagaError::new( + SagaErrorKind::InvalidInput, + "session working directory is not a directory", + "Choose an existing directory inside the session root", + ) + .with_path(directory)); + } + Ok(resolved) +} diff --git a/crates/session/src/lib.rs b/crates/session/src/lib.rs index cc56796..3d57e91 100644 --- a/crates/session/src/lib.rs +++ b/crates/session/src/lib.rs @@ -30,7 +30,10 @@ pub use config::{ DEFAULT_REPLAY_MAX_BYTES, DEFAULT_REPLAY_MAX_CHUNKS, MAX_EVENT_CAPACITY, MAX_TRACKED_PROCESSES, SessionManagerConfig, }; -pub use create::{CreateFaults, CreateSession, CreateStep, CreatedSession, LockHook, PlanHook}; +pub use create::{ + CreateFaults, CreateSession, CreateStep, CreatedSession, LockHook, PlanHook, + resolve_session_directory, +}; pub use error::{SagaError, SagaErrorKind, SessionError}; pub use event::{ IoOperation, OutputChunk, ReconnectSnapshot, SessionEvent, SessionHandle, SessionSnapshot, diff --git a/crates/session/src/remove.rs b/crates/session/src/remove.rs index 8d9a54e..ae309f0 100644 --- a/crates/session/src/remove.rs +++ b/crates/session/src/remove.rs @@ -15,6 +15,26 @@ use crate::saga::{SessionWorktreeSaga, require_project}; use crate::spawn::SessionSpawner; use crate::token::now_ms; +pub(crate) fn cancel_pending_removal( + saga: &SessionWorktreeSaga, + worktree_id: WorktreeId, +) -> Result<(), SagaError> { + let _mutation = lock_mutation(&saga.mutations, worktree_id)?; + let stored = require_worktree(saga, worktree_id)?; + let _destination = lock_destination(&saga.destinations, stored.path.clone())?; + saga.tokens.discard_for(worktree_id); + if stored.state == WorktreeState::RemovePending { + saga.storage().update_worktree_state( + worktree_id, + WorktreeState::Active, + stored.is_dirty, + stored.session_id, + now_ms(), + )?; + } + Ok(()) +} + pub(crate) fn prepare_remove( saga: &SessionWorktreeSaga, worktree_id: WorktreeId, diff --git a/crates/session/src/saga.rs b/crates/session/src/saga.rs index 3be54aa..3b4cab2 100644 --- a/crates/session/src/saga.rs +++ b/crates/session/src/saga.rs @@ -1,6 +1,8 @@ -use std::sync::{Mutex, MutexGuard, PoisonError}; +use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; -use cli_master_core::wire::{ConfirmationToken, SessionIsolation, WorktreePrepareRemoveResponse}; +use cli_master_core::wire::{ + ConfirmationToken, RelativeDirectory, SessionIsolation, WorktreePrepareRemoveResponse, +}; use cli_master_core::{AgentId, Project, ProjectId, SessionId, WorktreeId}; use cli_master_git::Git; use cli_master_storage::{Storage, StoredAgent}; @@ -15,7 +17,7 @@ use crate::{ /// Orchestrates recoverable worktree-backed session creation and removal. pub struct SessionWorktreeSaga { pub(crate) git: Git, - storage: Mutex, + storage: Arc>, pub(crate) spawner: S, pub(crate) daemon_instance_id: String, pub(crate) destinations: DestinationLocks, @@ -34,6 +36,25 @@ impl SessionWorktreeSaga { storage: Storage, spawner: S, daemon_instance_id: impl Into, + ) -> Result { + Self::new_with_shared_storage( + git, + Arc::new(Mutex::new(storage)), + spawner, + daemon_instance_id, + ) + } + + /// Builds a saga sharing the daemon's single metadata owner. + /// + /// # Errors + /// + /// Returns an error when `daemon_instance_id` is blank. + pub fn new_with_shared_storage( + git: Git, + storage: Arc>, + spawner: S, + daemon_instance_id: impl Into, ) -> Result { let daemon_instance_id = daemon_instance_id.into(); if daemon_instance_id.trim().is_empty() { @@ -45,7 +66,7 @@ impl SessionWorktreeSaga { } Ok(Self { git, - storage: Mutex::new(storage), + storage, spawner, daemon_instance_id, destinations: DestinationLocks::default(), @@ -73,23 +94,44 @@ impl SessionWorktreeSaga { request: &CreateSession, faults: &CreateFaults, ) -> Result { - if request.name.trim().is_empty() { - return Err(SagaError::new( - SagaErrorKind::InvalidInput, - "session name must not be blank", - "Provide a user-facing session name", - )); - } - if request.isolation == SessionIsolation::NewWorktree && !request.managed_root.is_absolute() - { - return Err(SagaError::new( - SagaErrorKind::InvalidInput, - "managed worktree root must be an absolute path", - "Pass the daemon-owned managed worktree root", - ) - .with_path(&request.managed_root)); - } - crate::create::create(self, request, faults) + validate_create(request)?; + crate::create::create(self, request, faults, crate::create::Launch::Start) + } + + /// Prepares durable session metadata and optional Git isolation without launching a process. + /// + /// The returned session has `unknown` status and no runtime owner until the + /// daemon starts it through `SessionManager`. + /// + /// # Errors + /// + /// Returns an error when directory validation, persistence, or Git preparation fails. + pub fn prepare_session( + &self, + request: &CreateSession, + relative_directory: Option<&RelativeDirectory>, + ) -> Result { + self.prepare_session_injected(request, relative_directory, &CreateFaults::default()) + } + + /// Prepares session metadata with deterministic fault hooks for compensation tests. + /// + /// # Errors + /// + /// Returns the same failures as [`Self::prepare_session`] plus injected faults. + pub fn prepare_session_injected( + &self, + request: &CreateSession, + relative_directory: Option<&RelativeDirectory>, + faults: &CreateFaults, + ) -> Result { + validate_create(request)?; + crate::create::create( + self, + request, + faults, + crate::create::Launch::Prepare(relative_directory), + ) } /// Inspects whether a managed worktree can be removed and issues a token when safe. @@ -104,6 +146,18 @@ impl SessionWorktreeSaga { crate::remove::prepare_remove(self, worktree_id) } + /// Invalidates removal confirmation before a daemon-owned session starts again. + /// + /// The daemon serializes this operation with session launch and removal. + /// + /// # Errors + /// + /// Returns an error when the worktree is missing, a mutation is already in + /// progress, or the pending state cannot be restored. + pub fn cancel_pending_removal(&self, worktree_id: WorktreeId) -> Result<(), SagaError> { + crate::remove::cancel_pending_removal(self, worktree_id) + } + /// Removes a clean unused worktree using a matching confirmation token. /// /// # Errors @@ -195,3 +249,22 @@ pub(crate) fn require_agent( } Ok(agent) } + +fn validate_create(request: &CreateSession) -> Result<(), SagaError> { + if request.name.trim().is_empty() { + return Err(SagaError::new( + SagaErrorKind::InvalidInput, + "session name must not be blank", + "Provide a user-facing session name", + )); + } + if request.isolation == SessionIsolation::NewWorktree && !request.managed_root.is_absolute() { + return Err(SagaError::new( + SagaErrorKind::InvalidInput, + "managed worktree root must be an absolute path", + "Pass the daemon-owned managed worktree root", + ) + .with_path(&request.managed_root)); + } + Ok(()) +} diff --git a/crates/session/tests/prepare_saga.rs b/crates/session/tests/prepare_saga.rs new file mode 100644 index 0000000..0bf8522 --- /dev/null +++ b/crates/session/tests/prepare_saga.rs @@ -0,0 +1,328 @@ +mod support; + +use std::fs; +use std::os::unix::fs::symlink; +use std::sync::{Arc, Mutex}; + +use cli_master_core::{ + SessionStatus, + wire::{RelativeDirectory, SessionIsolation}, +}; +use cli_master_git::Git; +use cli_master_session::{ + CreateFaults, CreateStep, FakeSpawner, SagaErrorKind, SessionWorktreeSaga, +}; +use cli_master_storage::{Storage, WorktreeState}; +use support::{Fixture, git}; + +#[test] +fn prepared_worktree_has_no_runtime_and_survives_recovery() { + let fixture = Fixture::new(); + let shared = Arc::new(Mutex::new( + Storage::open_migrated(&fixture.database).unwrap(), + )); + let saga = SessionWorktreeSaga::new_with_shared_storage( + Git::discover().unwrap(), + Arc::clone(&shared), + FakeSpawner::failing(), + support::DAEMON_ID, + ) + .unwrap(); + let prepared = saga + .prepare_session(&fixture.request("Prepare", None), None) + .unwrap(); + let worktree = prepared.worktree.unwrap(); + assert_eq!(prepared.session.status, SessionStatus::Unknown); + assert!(prepared.session.pid.is_none()); + assert!(prepared.session.pty_id.is_none()); + assert!(worktree.path.is_dir()); + assert_eq!(worktree.session_id, Some(prepared.session.id)); + assert_eq!(worktree.state, cli_master_core::WorktreeState::Active); + let storage = shared.lock().unwrap(); + let stored = storage.get_session(prepared.session.id).unwrap().unwrap(); + assert!(stored.daemon_instance_id.is_none()); + assert!(stored.last_activity_at_ms.is_none()); + assert_eq!( + storage + .recover_stale_sessions_for_daemon("next-daemon", stored.updated_at_ms + 1) + .unwrap(), + 0 + ); + drop(storage); + assert_eq!(saga.recover().unwrap(), Default::default()); + saga.delete_session(prepared.session.id).unwrap(); + assert!( + worktree.path.is_dir(), + "deleting metadata must preserve the worktree" + ); + assert!( + shared + .lock() + .unwrap() + .get_worktree(worktree.id) + .unwrap() + .unwrap() + .session_id + .is_none() + ); +} + +#[test] +fn relative_directory_is_resolved_in_the_new_checkout() { + let fixture = Fixture::new(); + fs::create_dir_all(fixture.repository.join("apps/api")).unwrap(); + fs::write(fixture.repository.join("apps/api/README.md"), "api\n").unwrap(); + git(&fixture.repository, ["add", "."]); + git(&fixture.repository, ["commit", "-m", "api directory"]); + let saga = fixture.saga(FakeSpawner::failing()); + let relative = RelativeDirectory::try_new("apps/api").unwrap(); + let prepared = saga + .prepare_session(&fixture.request("API", None), Some(&relative)) + .unwrap(); + let root = prepared.worktree.unwrap().path; + assert_eq!( + prepared.session.cwd, + root.join("apps/api").canonicalize().unwrap() + ); + assert!( + !prepared + .session + .cwd + .starts_with(fixture.repository.canonicalize().unwrap()) + ); +} + +#[test] +fn project_subdirectory_is_preserved_for_current_and_worktree_sessions() { + for isolation in [SessionIsolation::Current, SessionIsolation::NewWorktree] { + let fixture = Fixture::new(); + fs::create_dir_all(fixture.repository.join("apps/api")).unwrap(); + fs::write(fixture.repository.join("apps/api/README.md"), "api\n").unwrap(); + git(&fixture.repository, ["add", "."]); + git(&fixture.repository, ["commit", "-m", "api directory"]); + let storage = Storage::open(&fixture.database).unwrap(); + let mut project = storage.get_project(fixture.project_id).unwrap().unwrap(); + storage.remove_project_metadata(project.id).unwrap(); + project.path = fixture.repository.join("apps"); + project.repository_root = Some(fixture.repository.canonicalize().unwrap()); + storage.insert_project(&project).unwrap(); + let saga = fixture.saga(FakeSpawner::failing()); + let mut request = fixture.request("API", None); + request.isolation = isolation; + let prepared = saga + .prepare_session(&request, Some(&RelativeDirectory::try_new("api").unwrap())) + .unwrap(); + let root = prepared + .worktree + .map_or(fixture.repository, |worktree| worktree.path); + assert_eq!( + prepared.session.cwd, + root.join("apps/api").canonicalize().unwrap() + ); + } +} + +#[test] +fn cancelled_removal_invalidates_the_old_token_and_restores_active_metadata() { + let fixture = Fixture::new(); + let saga = fixture.saga(FakeSpawner::failing()); + let prepared = saga + .prepare_session(&fixture.request("Cancel removal", None), None) + .unwrap(); + let worktree = prepared.worktree.unwrap(); + let cli_master_core::wire::WorktreePrepareRemoveResponse::Ready { + confirmation_token, .. + } = saga.prepare_remove(worktree.id).unwrap() + else { + panic!("prepared clean worktree should be removable"); + }; + saga.cancel_pending_removal(worktree.id).unwrap(); + let stored = Storage::open(&fixture.database) + .unwrap() + .get_worktree(worktree.id) + .unwrap() + .unwrap(); + assert_eq!(stored.state, WorktreeState::Active); + assert_eq!(stored.session_id, Some(prepared.session.id)); + assert_eq!( + saga.remove_worktree(worktree.id, &confirmation_token) + .unwrap_err() + .kind(), + SagaErrorKind::InvalidToken + ); + assert!(worktree.path.is_dir()); + let cli_master_core::wire::WorktreePrepareRemoveResponse::Ready { + confirmation_token: new_token, + .. + } = saga.prepare_remove(worktree.id).unwrap() + else { + panic!("a fresh confirmation should still be available"); + }; + saga.remove_worktree(worktree.id, &new_token).unwrap(); + assert!(!worktree.path.exists()); +} + +#[test] +fn current_directory_can_be_prepared_without_a_git_repository() { + let fixture = Fixture::new(); + fs::remove_dir_all(fixture.repository.join(".git")).unwrap(); + fs::create_dir(fixture.repository.join("child")).unwrap(); + let saga = fixture.saga(FakeSpawner::failing()); + let mut request = fixture.request("Current", None); + request.isolation = SessionIsolation::Current; + let prepared = saga + .prepare_session( + &request, + Some(&RelativeDirectory::try_new("child").unwrap()), + ) + .unwrap(); + assert_eq!( + prepared.session.cwd, + fixture.repository.join("child").canonicalize().unwrap() + ); + assert!(prepared.worktree.is_none()); + assert!(!fixture.managed.exists()); +} + +#[test] +fn current_directory_rejects_symlink_escape_without_metadata() { + let fixture = Fixture::new(); + symlink(fixture.temp.path(), fixture.repository.join("outside")).unwrap(); + let saga = fixture.saga(FakeSpawner::failing()); + let mut request = fixture.request("Escape", None); + request.isolation = SessionIsolation::Current; + let error = saga + .prepare_session( + &request, + Some(&RelativeDirectory::try_new("outside").unwrap()), + ) + .unwrap_err(); + assert_eq!(error.kind(), SagaErrorKind::InvalidInput); + assert!( + Storage::open(&fixture.database) + .unwrap() + .list_sessions() + .unwrap() + .is_empty() + ); +} + +#[test] +fn worktree_symlink_escape_is_compensated_without_deleting_the_target() { + let fixture = Fixture::new(); + let outside = fixture.temp.path().join("outside"); + fs::create_dir(&outside).unwrap(); + fs::write(outside.join("keep.txt"), "keep\n").unwrap(); + symlink(&outside, fixture.repository.join("outside")).unwrap(); + git(&fixture.repository, ["add", "."]); + git(&fixture.repository, ["commit", "-m", "linked directory"]); + let saga = fixture.saga(FakeSpawner::failing()); + let error = saga + .prepare_session( + &fixture.request("Escape", None), + Some(&RelativeDirectory::try_new("outside").unwrap()), + ) + .unwrap_err(); + assert_eq!(error.kind(), SagaErrorKind::InvalidInput); + assert_eq!( + fs::read_to_string(outside.join("keep.txt")).unwrap(), + "keep\n" + ); + let storage = Storage::open(&fixture.database).unwrap(); + assert!(storage.list_sessions().unwrap().is_empty()); + assert!(storage.list_worktrees().unwrap().is_empty()); + assert_eq!( + Git::discover() + .unwrap() + .list_worktrees(&fixture.repository) + .unwrap() + .len(), + 1 + ); +} + +#[test] +fn missing_directory_in_checkout_rolls_back_git_and_metadata() { + let fixture = Fixture::new(); + fs::create_dir(fixture.repository.join("untracked-directory")).unwrap(); + let saga = fixture.saga(FakeSpawner::failing()); + let error = saga + .prepare_session( + &fixture.request("Missing", None), + Some(&RelativeDirectory::try_new("untracked-directory").unwrap()), + ) + .unwrap_err(); + assert_eq!(error.kind(), SagaErrorKind::InvalidInput); + let storage = Storage::open(&fixture.database).unwrap(); + assert!(storage.list_sessions().unwrap().is_empty()); + assert!(storage.list_worktrees().unwrap().is_empty()); + assert_eq!( + Git::discover() + .unwrap() + .list_worktrees(&fixture.repository) + .unwrap() + .len(), + 1 + ); +} + +#[test] +fn metadata_only_faults_compensate_each_durable_effect() { + for step in [ + CreateStep::Plan, + CreateStep::PersistCreating, + CreateStep::GitAdd, + CreateStep::PersistActive, + ] { + let fixture = Fixture::new(); + let saga = fixture.saga(FakeSpawner::failing()); + let error = saga + .prepare_session_injected( + &fixture.request("Fault", None), + None, + &CreateFaults { + fail_after: Some(step), + ..CreateFaults::default() + }, + ) + .unwrap_err(); + assert_eq!(error.kind(), SagaErrorKind::InjectedFailure); + let storage = Storage::open(&fixture.database).unwrap(); + assert!(storage.list_sessions().unwrap().is_empty(), "{step:?}"); + assert!(storage.list_worktrees().unwrap().is_empty(), "{step:?}"); + assert_eq!( + Git::discover() + .unwrap() + .list_worktrees(&fixture.repository) + .unwrap() + .len(), + 1 + ); + } +} + +#[test] +fn compensation_preserves_dirty_user_data_after_metadata_preparation() { + let fixture = Fixture::new(); + let saga = fixture.saga(FakeSpawner::failing()); + let error = saga + .prepare_session_injected( + &fixture.request("Dirty", None), + None, + &CreateFaults { + fail_after: Some(CreateStep::PersistActive), + after_git_add: Some(Arc::new(|plan| { + fs::write(plan.destination().join("keep.txt"), "keep\n").unwrap(); + })), + ..CreateFaults::default() + }, + ) + .unwrap_err(); + assert_eq!(error.kind(), SagaErrorKind::PartialWorktree); + let storage = Storage::open(&fixture.database).unwrap(); + assert!(storage.list_sessions().unwrap().is_empty()); + let worktrees = storage.list_worktrees().unwrap(); + assert_eq!(worktrees.len(), 1); + assert_eq!(worktrees[0].state, WorktreeState::Orphaned); + assert!(worktrees[0].path.join("keep.txt").is_file()); +} diff --git a/crates/storage/src/sessions.rs b/crates/storage/src/sessions.rs index ad3633d..0a3c1f2 100644 --- a/crates/storage/src/sessions.rs +++ b/crates/storage/src/sessions.rs @@ -25,36 +25,8 @@ impl Storage { /// Returns an error for invalid metadata, missing project/agent references, /// duplicate IDs, or database failures. pub fn insert_session(&self, session: &StoredSession) -> Result<(), StorageError> { - session.validate()?; - let cwd = path_to_sql_value(&session.cwd, "session cwd")?; self.with_connection("insert session", |connection| { - connection - .execute( - "INSERT INTO sessions ( - id, project_id, agent_id, name, cwd, status, runtime_pid, - daemon_instance_id, exit_code, error_code, created_at, - updated_at, last_activity_at - ) VALUES ( - ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13 - )", - params![ - session.id.to_string(), - session.project_id.to_string(), - session.agent_id.to_string(), - session.name, - cwd, - session_status_to_database(session.status), - session.runtime_pid.map(i64::from), - session.daemon_instance_id, - session.exit_code, - session.error_code, - session.created_at_ms, - session.updated_at_ms, - session.last_activity_at_ms, - ], - ) - .map_err(|error| map_write_error(error, "session"))?; - Ok(()) + insert_session_on_connection(connection, session) }) } @@ -269,3 +241,38 @@ fn require_changed(changed: usize, id: SessionId) -> Result<(), StorageError> { Ok(()) } } + +pub(crate) fn insert_session_on_connection( + connection: &Connection, + session: &StoredSession, +) -> Result<(), StorageError> { + session.validate()?; + let cwd = path_to_sql_value(&session.cwd, "session cwd")?; + connection + .execute( + "INSERT INTO sessions ( + id, project_id, agent_id, name, cwd, status, runtime_pid, + daemon_instance_id, exit_code, error_code, created_at, + updated_at, last_activity_at + ) VALUES ( + ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13 + )", + params![ + session.id.to_string(), + session.project_id.to_string(), + session.agent_id.to_string(), + session.name, + cwd, + session_status_to_database(session.status), + session.runtime_pid.map(i64::from), + session.daemon_instance_id, + session.exit_code, + session.error_code, + session.created_at_ms, + session.updated_at_ms, + session.last_activity_at_ms, + ], + ) + .map_err(|error| map_write_error(error, "session"))?; + Ok(()) +} diff --git a/crates/storage/src/worktrees.rs b/crates/storage/src/worktrees.rs index 913299f..bcd1fa7 100644 --- a/crates/storage/src/worktrees.rs +++ b/crates/storage/src/worktrees.rs @@ -7,7 +7,7 @@ use rusqlite::{Row, params}; use crate::Storage; use crate::error::{StorageError, corrupt_data, map_write_error, persisted_validation}; -use crate::models::{StoredWorktree, WorktreeState, validate_timestamp}; +use crate::models::{StoredSession, StoredWorktree, WorktreeState, validate_timestamp}; use crate::paths::{path_from_sql_value, path_to_sql_value}; use crate::values::timestamp_from_sql_value; @@ -15,6 +15,49 @@ const WORKTREE_COLUMNS: &str = "id, project_id, session_id, path, branch, state, is_dirty, created_at, updated_at"; impl Storage { + /// Inserts a session and activates its previously prepared worktree atomically. + /// + /// Git has already created the working copy. A failed association must leave + /// no unassociated session row, while the `creating` worktree remains available + /// for compensation or crash recovery. + /// + /// # Errors + /// + /// Returns an error for invalid session metadata, missing or non-creating + /// worktrees, cross-project associations, or database failures. + pub fn insert_prepared_session_with_worktree( + &self, + session: &StoredSession, + worktree_id: WorktreeId, + updated_at_ms: i64, + ) -> Result<(), StorageError> { + validate_timestamp("worktree updated_at_ms", updated_at_ms)?; + self.transaction(|transaction| { + crate::sessions::insert_session_on_connection(transaction, session)?; + let changed = transaction + .execute( + "UPDATE worktrees SET state = 'active', session_id = ?1, updated_at = ?2 + WHERE id = ?3 AND project_id = ?4 AND state = 'creating' AND session_id IS NULL", + params![ + session.id.to_string(), + updated_at_ms, + worktree_id.to_string(), + session.project_id.to_string(), + ], + ) + .map_err(|error| map_write_error(error, "worktree"))?; + if changed == 0 { + return Err(StorageError::InvalidInput { + field: "worktree association", + reason: + "worktree must be unassociated, creating, and belong to the session project" + .to_owned(), + }); + } + Ok(()) + }) + } + /// Inserts worktree metadata without running Git or creating directories. /// /// # Errors diff --git a/crates/storage/tests/agents_worktrees.rs b/crates/storage/tests/agents_worktrees.rs index 575ed6c..9a9e501 100644 --- a/crates/storage/tests/agents_worktrees.rs +++ b/crates/storage/tests/agents_worktrees.rs @@ -336,6 +336,84 @@ struct WorktreeScenario { worktree: StoredWorktree, } +#[test] +fn prepared_session_and_worktree_commit_together_or_roll_back() { + let scenario = seeded_worktree_scenario(); + let candidate = session( + scenario.first_project_id, + scenario.agent_id, + SessionStatus::Unknown, + None, + None, + ); + let mut prepared = worktree( + scenario.first_project_id, + None, + std::path::Path::new("/tmp/prepared-worktree"), + "agent/prepared", + ); + scenario.storage.insert_worktree(&prepared).unwrap(); + scenario + .storage + .insert_prepared_session_with_worktree(&candidate, prepared.id, CREATED_AT_MS + 1) + .unwrap(); + assert_eq!( + scenario.storage.get_session(candidate.id).unwrap(), + Some(candidate.clone()) + ); + prepared.state = WorktreeState::Active; + prepared.session_id = Some(candidate.id); + prepared.updated_at_ms = CREATED_AT_MS + 1; + assert_eq!( + scenario.storage.get_worktree(prepared.id).unwrap(), + Some(prepared.clone()) + ); + + let replacement = session( + scenario.first_project_id, + scenario.agent_id, + SessionStatus::Unknown, + None, + None, + ); + assert!( + scenario + .storage + .insert_prepared_session_with_worktree(&replacement, prepared.id, CREATED_AT_MS + 2) + .is_err() + ); + assert!( + scenario + .storage + .get_session(replacement.id) + .unwrap() + .is_none() + ); + assert_eq!( + scenario.storage.get_worktree(prepared.id).unwrap(), + Some(prepared) + ); + + let invalid = session( + scenario.first_project_id, + scenario.agent_id, + SessionStatus::Unknown, + None, + None, + ); + assert!( + scenario + .storage + .insert_prepared_session_with_worktree( + &invalid, + cli_master_core::WorktreeId::new(), + CREATED_AT_MS + 2 + ) + .is_err() + ); + assert!(scenario.storage.get_session(invalid.id).unwrap().is_none()); +} + fn seeded_worktree_scenario() -> WorktreeScenario { let storage = Storage::open_in_memory().expect("database should open"); storage.migrate().expect("database should migrate"); From 3c25a85eeceaeb4d924784c681e301191b2bc6f8 Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sat, 5 Sep 2026 21:49:11 -0300 Subject: [PATCH 05/12] feat(daemon): expose isolated session and safe worktree lifecycle over IPC --- apps/desktop/src/ipc/domain.ts | 12 + apps/desktop/src/ipc/methods.ts | 34 + crates/core/src/protocol.rs | 19 + crates/core/src/wire/method.rs | 3 + crates/core/src/wire/mod.rs | 4 +- crates/core/src/wire/request.rs | 9 + crates/core/src/wire/response.rs | 8 + crates/daemon/src/server.rs | 94 ++- crates/daemon/src/sessions.rs | 155 ++++- crates/daemon/src/sessions/worktrees.rs | 267 ++++++++ crates/daemon/tests/worktree_ipc.rs | 831 ++++++++++++++++++++++++ protocol/catalog.json | 1 + 12 files changed, 1389 insertions(+), 48 deletions(-) create mode 100644 apps/desktop/src/ipc/domain.ts create mode 100644 apps/desktop/src/ipc/methods.ts create mode 100644 crates/daemon/src/sessions/worktrees.rs create mode 100644 crates/daemon/tests/worktree_ipc.rs diff --git a/apps/desktop/src/ipc/domain.ts b/apps/desktop/src/ipc/domain.ts new file mode 100644 index 0000000..7cb4aa6 --- /dev/null +++ b/apps/desktop/src/ipc/domain.ts @@ -0,0 +1,12 @@ +/** Additive daemon contracts. Existing UI DTOs remain owned by types.ts. */ +export type * from "./types"; +import type { Worktree } from "./types"; + +/** List managed worktrees; omission includes every registered project. */ +export interface WorktreeListRequest { + readonly projectId?: string; +} + +export interface WorktreeListResponse { + readonly worktrees: readonly Worktree[]; +} diff --git a/apps/desktop/src/ipc/methods.ts b/apps/desktop/src/ipc/methods.ts new file mode 100644 index 0000000..7b00a06 --- /dev/null +++ b/apps/desktop/src/ipc/methods.ts @@ -0,0 +1,34 @@ +/** Mirror of cli_master_core::wire::method; protocol/catalog.json is checked in tests. */ +export const IPC_METHODS = [ + "system.hello", + "state.snapshot", + "project.add", + "project.list", + "project.rename", + "project.remove", + "agent.list", + "agent.detect", + "agent.set_enabled", + "agent.custom.create", + "agent.custom.update", + "agent.custom.remove", + "session.create", + "session.list", + "session.rename", + "session.start", + "session.restart", + "session.stop", + "session.delete", + "session.write", + "session.resize", + "session.subscribe", + "session.unsubscribe", + "git.status", + "git.diff", + "worktree.list", + "worktree.prepare_remove", + "worktree.remove", + "diagnostics.get" +] as const; + +export type IpcMethod = (typeof IPC_METHODS)[number]; diff --git a/crates/core/src/protocol.rs b/crates/core/src/protocol.rs index 254ee50..49b3cd4 100644 --- a/crates/core/src/protocol.rs +++ b/crates/core/src/protocol.rs @@ -278,6 +278,25 @@ mod tests { assert_eq!(catalog["protocolVersion"], PROTOCOL_V1); assert_eq!(catalog["applicationVersion"], APPLICATION_VERSION); assert_eq!(APPLICATION_VERSION, env!("CARGO_PKG_VERSION")); + assert_eq!( + catalog["methods"], + serde_json::json!(crate::wire::method::ALL) + ); + assert_eq!( + catalog["events"], + serde_json::json!(crate::wire::event_name::ALL) + ); + } + + #[test] + fn typescript_method_mirror_matches_the_rust_catalog() { + let mirror = include_str!("../../../apps/desktop/src/ipc/methods.ts"); + let (_, array) = mirror.split_once("= ").expect("method array assignment"); + let (array, _) = array + .split_once(" as const;") + .expect("readonly method array"); + let methods: Vec = serde_json::from_str(array).expect("JSON method literals"); + assert_eq!(methods, crate::wire::method::ALL); } #[test] diff --git a/crates/core/src/wire/method.rs b/crates/core/src/wire/method.rs index d4633ea..96bffd5 100644 --- a/crates/core/src/wire/method.rs +++ b/crates/core/src/wire/method.rs @@ -55,6 +55,8 @@ pub const GIT_STATUS: &str = "git.status"; /// Read a bounded textual diff for a registered project, session, or worktree. pub const GIT_DIFF: &str = "git.diff"; +/// List managed worktrees, optionally constrained to a project. +pub const WORKTREE_LIST: &str = "worktree.list"; /// Inspect whether a managed worktree can be safely removed. pub const WORKTREE_PREPARE_REMOVE: &str = "worktree.prepare_remove"; /// Remove a managed worktree after token-bound state confirmation. @@ -90,6 +92,7 @@ pub const ALL: &[&str] = &[ SESSION_UNSUBSCRIBE, GIT_STATUS, GIT_DIFF, + WORKTREE_LIST, WORKTREE_PREPARE_REMOVE, WORKTREE_REMOVE, DIAGNOSTICS_GET, diff --git a/crates/core/src/wire/mod.rs b/crates/core/src/wire/mod.rs index cc73091..71737a2 100644 --- a/crates/core/src/wire/mod.rs +++ b/crates/core/src/wire/mod.rs @@ -26,7 +26,7 @@ pub use request::{ GitTarget, ProjectAddRequest, ProjectRemoveRequest, ProjectRenameRequest, SessionCreateRequest, SessionDeleteRequest, SessionIsolation, SessionListRequest, SessionRenameRequest, SessionResizeRequest, SessionRestartRequest, SessionStartRequest, SessionStopRequest, - SessionSubscribeRequest, SessionUnsubscribeRequest, SessionWriteRequest, + SessionSubscribeRequest, SessionUnsubscribeRequest, SessionWriteRequest, WorktreeListRequest, WorktreePrepareRemoveRequest, WorktreeRemoveRequest, validate_agent_command, }; pub use response::{ @@ -37,7 +37,7 @@ pub use response::{ ProjectListResponse, ProjectRemoveResponse, ProjectRenameResponse, SessionCreateResponse, SessionDeleteResponse, SessionListResponse, SessionRenameResponse, SessionResizeResponse, SessionRestartResponse, SessionStartResponse, SessionStopResponse, SessionSubscribeResponse, - SessionUnsubscribeResponse, SessionWriteResponse, StateSnapshotResponse, + SessionUnsubscribeResponse, SessionWriteResponse, StateSnapshotResponse, WorktreeListResponse, WorktreePrepareRemoveResponse, WorktreeRemovalBlocker, WorktreeRemoveResponse, }; pub use value::{ diff --git a/crates/core/src/wire/request.rs b/crates/core/src/wire/request.rs index 34632b1..2fbff93 100644 --- a/crates/core/src/wire/request.rs +++ b/crates/core/src/wire/request.rs @@ -464,6 +464,15 @@ pub struct GitDiffRequest { pub path: Option, } +/// Request to list managed worktrees, optionally for one project. +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct WorktreeListRequest { + /// Project filter; omitted to return all managed worktrees. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_id: Option, +} + /// Request to prepare safe managed-worktree removal. #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(deny_unknown_fields, rename_all = "camelCase")] diff --git a/crates/core/src/wire/response.rs b/crates/core/src/wire/response.rs index 20afb29..1a059a9 100644 --- a/crates/core/src/wire/response.rs +++ b/crates/core/src/wire/response.rs @@ -235,6 +235,14 @@ pub struct GitDiffResponse { pub binary: bool, } +/// Durable managed worktrees visible to the local client. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeListResponse { + /// Managed worktrees matching the requested project filter. + pub worktrees: Vec, +} + /// Safe reason a worktree cannot currently be removed. #[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "snake_case")] diff --git a/crates/daemon/src/server.rs b/crates/daemon/src/server.rs index 43ce49d..855d60a 100644 --- a/crates/daemon/src/server.rs +++ b/crates/daemon/src/server.rs @@ -17,7 +17,8 @@ use cli_master_core::{ SessionOutputEvent, SessionOutputGapEvent, SessionRenameRequest, SessionReplayCompleteEvent, SessionResizeRequest, SessionRestartRequest, SessionStartRequest, SessionStatusChangedEvent, SessionStopRequest, - SessionSubscribeRequest, SessionWriteRequest, event_name, method, + SessionSubscribeRequest, SessionWriteRequest, WorktreeListRequest, + WorktreePrepareRemoveRequest, WorktreeRemoveRequest, event_name, method, }, }; use cli_master_git::Git; @@ -59,13 +60,13 @@ pub struct HelloResponse { pub struct StateSnapshot { /// Applied `SQLite` schema migration version. pub schema_version: u32, - /// Registered projects. Empty until project persistence is wired in. + /// Registered projects. pub projects: Vec, - /// Available agent definitions. Empty until registry persistence is wired in. + /// Available agent definitions. pub agents: Vec, - /// Known sessions. Empty until the session manager is wired in. + /// Known sessions, including managed worktree associations. pub sessions: Vec, - /// Managed worktrees. Empty until Git orchestration is wired in. + /// Durable managed worktrees, including recoverable partial operations. pub worktrees: Vec, } @@ -146,6 +147,8 @@ impl Daemon { sessions: SessionRegistry::new( session_storage, DaemonInstanceId::from_uuid(instance_id), + config.data_directory().join("worktrees"), + git.clone(), ) .map_err(|error| { DaemonError::initialization( @@ -525,7 +528,10 @@ fn validate_peer(_stream: &UnixStream) -> Result<(), io::Error> { clippy::too_many_lines, reason = "the versioned IPC method table is intentionally kept in one auditable dispatcher" )] -async fn dispatch(request: RequestEnvelope, state: &ServerState) -> ResponseEnvelope { +async fn dispatch( + request: RequestEnvelope, + state: &Arc, +) -> ResponseEnvelope { if request.kind != EnvelopeKind::Request { return ResponseEnvelope::failure( request.request_id, @@ -560,7 +566,7 @@ async fn dispatch(request: RequestEnvelope, state: &ServerState) -> Respo projects, agents, sessions, - worktrees: Vec::new(), + worktrees: state.sessions.worktrees()?, }) }), method::PROJECT_ADD => decode_payload(request.payload) @@ -582,27 +588,34 @@ async fn dispatch(request: RequestEnvelope, state: &ServerState) -> Respo state.sessions.create_custom_agent(payload) }) .and_then(encode_response), - method::SESSION_CREATE => decode_payload(request.payload) - .and_then(|payload: SessionCreateRequest| state.sessions.create(payload)) - .and_then(encode_response), + method::SESSION_CREATE => match decode_payload::(request.payload) { + Ok(payload) => session_operation(state, move |sessions| sessions.create(payload)).await, + Err(error) => Err(error), + }, method::SESSION_LIST => decode_payload(request.payload) .and_then(|payload: SessionListRequest| state.sessions.list_sessions(payload)) .and_then(encode_response), method::SESSION_RENAME => decode_payload(request.payload) .and_then(|payload: SessionRenameRequest| state.sessions.rename(&payload)) .and_then(encode_response), - method::SESSION_START => decode_payload(request.payload) - .and_then(|payload: SessionStartRequest| state.sessions.start(payload)) - .and_then(encode_response), - method::SESSION_RESTART => decode_payload(request.payload) - .and_then(|payload: SessionRestartRequest| state.sessions.restart(payload)) - .and_then(encode_response), - method::SESSION_STOP => decode_payload(request.payload) - .and_then(|payload: SessionStopRequest| state.sessions.stop(payload)) - .and_then(encode_response), - method::SESSION_DELETE => decode_payload(request.payload) - .and_then(|payload: SessionDeleteRequest| state.sessions.delete(payload)) - .and_then(encode_response), + method::SESSION_START => match decode_payload::(request.payload) { + Ok(payload) => session_operation(state, move |sessions| sessions.start(payload)).await, + Err(error) => Err(error), + }, + method::SESSION_RESTART => match decode_payload::(request.payload) { + Ok(payload) => { + session_operation(state, move |sessions| sessions.restart(payload)).await + } + Err(error) => Err(error), + }, + method::SESSION_STOP => match decode_payload::(request.payload) { + Ok(payload) => session_operation(state, move |sessions| sessions.stop(payload)).await, + Err(error) => Err(error), + }, + method::SESSION_DELETE => match decode_payload::(request.payload) { + Ok(payload) => session_operation(state, move |sessions| sessions.delete(payload)).await, + Err(error) => Err(error), + }, method::SESSION_WRITE => decode_payload(request.payload) .and_then(|payload: SessionWriteRequest| state.sessions.write(&payload)) .and_then(encode_response), @@ -610,6 +623,26 @@ async fn dispatch(request: RequestEnvelope, state: &ServerState) -> Respo .and_then(|payload: SessionResizeRequest| state.sessions.resize(payload)) .and_then(encode_response), method::SESSION_UNSUBSCRIBE => encode_response(EmptyResponse::default()), + method::WORKTREE_LIST => decode_payload(request.payload) + .and_then(|payload: WorktreeListRequest| state.sessions.list_worktrees(payload)) + .and_then(encode_response), + method::WORKTREE_PREPARE_REMOVE => { + match decode_payload::(request.payload) { + Ok(payload) => { + session_operation(state, move |sessions| { + sessions.prepare_worktree_removal(payload) + }) + .await + } + Err(error) => Err(error), + } + } + method::WORKTREE_REMOVE => match decode_payload::(request.payload) { + Ok(payload) => { + session_operation(state, move |sessions| sessions.remove_worktree(&payload)).await + } + Err(error) => Err(error), + }, method::DIAGNOSTICS_GET => encode_response(&state.diagnostics), method::GIT_STATUS => match decode_payload::(request.payload) { Ok(payload) => { @@ -642,6 +675,23 @@ async fn dispatch(request: RequestEnvelope, state: &ServerState) -> Respo } } +/// Git and PTY lifecycle calls may block; keep client I/O and output delivery responsive. +async fn session_operation(state: &Arc, operation: F) -> Result +where + T: Serialize, + F: FnOnce(&SessionRegistry) -> Result + Send + 'static, +{ + let state = Arc::clone(state); + tokio::task::spawn_blocking(move || operation(&state.sessions).and_then(encode_response)) + .await + .map_err(|_| { + ApiError::new( + "session_operation_failed", + "The session operation could not complete.", + ) + })? +} + fn decode_payload(payload: Value) -> Result where T: for<'de> Deserialize<'de>, diff --git a/crates/daemon/src/sessions.rs b/crates/daemon/src/sessions.rs index 1192e5c..625938b 100644 --- a/crates/daemon/src/sessions.rs +++ b/crates/daemon/src/sessions.rs @@ -1,7 +1,7 @@ use std::collections::BTreeMap; use std::env; use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex, MutexGuard}; +use std::sync::{Arc, Mutex, MutexGuard, Weak}; use std::time::{SystemTime, UNIX_EPOCH}; use cli_master_core::wire::{ @@ -14,12 +14,16 @@ use cli_master_core::wire::{ use cli_master_core::{ AgentId, AgentSource, ApiError, DaemonInstanceId, Project, Session, SessionId, SessionStatus, }; +use cli_master_git::Git; use cli_master_session::{ - SessionError, SessionManager, SessionSnapshot, SessionSubscription, TerminalSize, + SessionError, SessionManager, SessionSnapshot, SessionSubscription, SessionWorktreeSaga, + TerminalSize, }; use cli_master_storage::{SessionRuntimeUpdate, Storage, StorageError, StoredAgent, StoredSession}; use uuid::Uuid; +mod worktrees; + const INITIAL_COLUMNS: u16 = 100; const INITIAL_ROWS: u16 = 30; const SHELL_AGENT_ID: u128 = 0x018f_0000_0000_7000_8000_0000_0000_0001; @@ -32,17 +36,44 @@ pub(super) struct SessionRegistry { storage: Arc>, manager: SessionManager, daemon_instance_id: DaemonInstanceId, + worktree_saga: Option>, + git: Option, + managed_root: PathBuf, + /// Serializes start/delete/removal so Git cannot remove a directory during spawn. + lifecycle: Mutex<()>, + session_operations: Mutex>>>, } impl SessionRegistry { pub(super) fn new( storage: Storage, daemon_instance_id: DaemonInstanceId, + managed_root: PathBuf, + git: Option, ) -> Result { + let storage = Arc::new(Mutex::new(storage)); + let manager = SessionManager::default(); + let worktree_saga = git + .clone() + .map(|git| { + SessionWorktreeSaga::new_with_shared_storage( + git, + Arc::clone(&storage), + manager.clone(), + daemon_instance_id.to_string(), + ) + }) + .transpose() + .map_err(worktrees::saga_error)?; let registry = Self { - storage: Arc::new(Mutex::new(storage)), - manager: SessionManager::default(), + storage, + manager, daemon_instance_id, + worktree_saga, + git, + managed_root, + lifecycle: Mutex::new(()), + session_operations: Mutex::new(BTreeMap::new()), }; registry.seed_builtin_agents()?; let now = unix_timestamp_ms()?; @@ -50,6 +81,9 @@ impl SessionRegistry { .storage()? .recover_stale_sessions_for_daemon(&daemon_instance_id.to_string(), now) .map_err(storage_error)?; + if let Some(saga) = ®istry.worktree_saga { + saga.recover().map_err(worktrees::saga_error)?; + } Ok(registry) } @@ -115,10 +149,8 @@ impl SessionRegistry { } pub(super) fn sessions(&self) -> Result, ApiError> { - self.storage()? - .list_sessions() - .map_err(storage_error) - .map(|sessions| sessions.into_iter().map(stored_session).collect()) + self.list_sessions(SessionListRequest { project_id: None }) + .map(|response| response.sessions) } pub(super) fn list_sessions( @@ -132,18 +164,18 @@ impl SessionRegistry { } .map_err(storage_error)? .into_iter() - .map(stored_session) - .collect(); + .map(|session| worktrees::session_with_worktree(&storage, session)) + .collect::, _>>()?; Ok(SessionListResponse { sessions }) } pub(super) fn create(&self, request: SessionCreateRequest) -> Result { - if request.isolation != SessionIsolation::Current { - return Err(ApiError::new( - "worktree_sessions_unavailable", - "Isolated worktree sessions are not available in this canvas yet.", - ) - .with_action("Choose the current project directory and try again.")); + let _lifecycle = self.lifecycle()?; + if let Some(saga) = &self.worktree_saga { + return self.prepare_with_saga(saga, request); + } + if request.isolation == SessionIsolation::NewWorktree { + return Err(worktrees::git_unavailable()); } let storage = self.storage()?; let project = storage @@ -192,10 +224,16 @@ impl SessionRegistry { } pub(super) fn start(&self, request: SessionStartRequest) -> Result { + let operation = self.session_operation(request.session_id)?; + let _session = operation.lock().map_err(|_| session_state_unavailable())?; + let _lifecycle = self.lifecycle()?; self.start_id(request.session_id) } pub(super) fn restart(&self, request: SessionRestartRequest) -> Result { + let operation = self.session_operation(request.session_id)?; + let _session = operation.lock().map_err(|_| session_state_unavailable())?; + let _lifecycle = self.lifecycle()?; if let Ok(snapshot) = self.manager.snapshot(request.session_id) { if is_live(snapshot.status) { self.manager @@ -210,6 +248,9 @@ impl SessionRegistry { } pub(super) fn stop(&self, request: SessionStopRequest) -> Result { + // Stopping only reduces worktree use. Do not queue it behind a slow checkout. + let operation = self.session_operation(request.session_id)?; + let _session = operation.lock().map_err(|_| session_state_unavailable())?; let snapshot = self .manager .stop(request.session_id) @@ -219,6 +260,9 @@ impl SessionRegistry { } pub(super) fn delete(&self, request: SessionDeleteRequest) -> Result { + let operation = self.session_operation(request.session_id)?; + let _session = operation.lock().map_err(|_| session_state_unavailable())?; + let _lifecycle = self.lifecycle()?; if let Ok(snapshot) = self.manager.snapshot(request.session_id) { if is_live(snapshot.status) { return Err(ApiError::new( @@ -227,10 +271,17 @@ impl SessionRegistry { ) .with_action("Stop the session, then try deleting it again.")); } + // A process may have exited while no client was subscribed to its events. + self.persist_snapshot(&snapshot)?; self.manager .remove(request.session_id) .map_err(session_error)?; } + if let Some(saga) = &self.worktree_saga { + saga.delete_session(request.session_id) + .map_err(worktrees::saga_error)?; + return Ok(EmptyResponse::default()); + } self.storage()? .remove_session_metadata(request.session_id) .map_err(storage_error)?; @@ -311,7 +362,7 @@ impl SessionRegistry { .ok_or_else(|| not_found("session", session_id))?; if let Ok(snapshot) = self.manager.snapshot(session_id) { if is_live(snapshot.status) { - return Ok(stored_session(stored)); + return worktrees::session_with_worktree(&storage, stored); } self.manager.remove(session_id).map_err(session_error)?; } @@ -319,8 +370,19 @@ impl SessionRegistry { .get_agent(stored.agent_id) .map_err(storage_error)? .ok_or_else(|| not_found("agent", stored.agent_id))?; + if !agent.enabled { + return Err(ApiError::new( + "agent_disabled", + "The selected terminal command is disabled.", + )); + } let command = agent.command_for_cwd(&stored.cwd).map_err(storage_error)?; drop(storage); + let worktree_id = self.validate_start_directory(&stored)?; + if let (Some(saga), Some(worktree_id)) = (&self.worktree_saga, worktree_id) { + saga.cancel_pending_removal(worktree_id) + .map_err(worktrees::saga_error)?; + } let size = TerminalSize::new(INITIAL_ROWS, INITIAL_COLUMNS).map_err(session_error)?; let handle = self .manager @@ -328,7 +390,11 @@ impl SessionRegistry { .map_err(session_error)?; let snapshot = self.manager.snapshot(session_id).map_err(session_error)?; debug_assert_eq!(handle.id, session_id); - self.persist_snapshot(&snapshot)?; + if let Err(error) = self.persist_snapshot(&snapshot) { + cli_master_session::SessionSpawner::rollback(&self.manager, session_id) + .map_err(worktrees::saga_error)?; + return Err(error); + } self.get(session_id) } @@ -378,6 +444,34 @@ impl SessionRegistry { .with_action("Restart Jig and try again.") }) } + + fn lifecycle(&self) -> Result, ApiError> { + self.lifecycle + .lock() + .map_err(|_| session_state_unavailable()) + } + + fn session_operation(&self, session_id: SessionId) -> Result>, ApiError> { + let mut operations = self + .session_operations + .lock() + .map_err(|_| session_state_unavailable())?; + operations.retain(|_, operation| operation.strong_count() > 0); + if let Some(operation) = operations.get(&session_id).and_then(Weak::upgrade) { + return Ok(operation); + } + let operation = Arc::new(Mutex::new(())); + operations.insert(session_id, Arc::downgrade(&operation)); + Ok(operation) + } +} + +fn session_state_unavailable() -> ApiError { + ApiError::new( + "session_state_unavailable", + "Session operations are unavailable.", + ) + .with_action("Restart the daemon and try again.") } fn agent_record(agent: StoredAgent) -> Result { @@ -421,18 +515,18 @@ fn stored_session(session: StoredSession) -> Session { } fn load_session(storage: &Storage, session_id: SessionId) -> Result { - storage + let session = storage .get_session(session_id) .map_err(storage_error)? - .map(stored_session) - .ok_or_else(|| not_found("session", session_id)) + .ok_or_else(|| not_found("session", session_id))?; + worktrees::session_with_worktree(storage, session) } fn session_directory( project: &Project, relative_directory: Option<&cli_master_core::wire::RelativeDirectory>, ) -> Result { - let root = project.repository_root.as_ref().unwrap_or(&project.path); + let root = &project.path; let directory = relative_directory.map_or_else(|| root.clone(), |relative| root.join(relative.as_str())); if !directory.is_dir() { @@ -442,14 +536,27 @@ fn session_directory( ) .with_action("Choose an existing directory inside the project.")); } - directory.canonicalize().map_err(|error| { + let canonical = directory.canonicalize().map_err(|error| { ApiError::new( "session_directory_unavailable", "The terminal working directory could not be opened.", ) .with_action("Check the directory permissions and try again.") .with_detail("reason", error.to_string()) - }) + })?; + let root = root.canonicalize().map_err(|_| { + ApiError::new( + "session_directory_unavailable", + "The project directory could not be opened.", + ) + })?; + if !canonical.starts_with(&root) { + return Err(ApiError::new( + "session_invalid_input", + "The terminal directory escapes the project.", + )); + } + Ok(canonical) } fn resolve_executable(executable: &str) -> Option { diff --git a/crates/daemon/src/sessions/worktrees.rs b/crates/daemon/src/sessions/worktrees.rs new file mode 100644 index 0000000..9c8f4d0 --- /dev/null +++ b/crates/daemon/src/sessions/worktrees.rs @@ -0,0 +1,267 @@ +use cli_master_core::wire::{ + SessionCreateRequest, WorktreeListRequest, WorktreeListResponse, WorktreePrepareRemoveRequest, + WorktreePrepareRemoveResponse, WorktreeRemovalBlocker, WorktreeRemoveRequest, +}; +use cli_master_core::{ApiError, Session, Worktree, WorktreeState}; +use cli_master_session::{CreateSession, SagaError, SessionManager, SessionWorktreeSaga}; +use cli_master_storage::{Storage, StoredSession, StoredWorktree}; + +use super::{SessionRegistry, not_found, storage_error, stored_session}; + +impl SessionRegistry { + pub(super) fn prepare_with_saga( + &self, + saga: &SessionWorktreeSaga, + request: SessionCreateRequest, + ) -> Result { + // The root belongs to the daemon; callers cannot supply paths or branches. + let creation = CreateSession { + project_id: request.project_id, + agent_id: request.agent_id, + name: request.name.into_inner(), + isolation: request.isolation, + managed_root: self.managed_root.join(request.project_id.to_string()), + short_id: None, + }; + saga.prepare_session(&creation, request.relative_directory.as_ref()) + .map(|created| created.session) + .map_err(saga_error) + } + + pub(crate) fn worktrees(&self) -> Result, ApiError> { + self.list_worktrees(WorktreeListRequest::default()) + .map(|response| response.worktrees) + } + + pub(crate) fn list_worktrees( + &self, + request: WorktreeListRequest, + ) -> Result { + let storage = self.storage()?; + let worktrees = match request.project_id { + Some(project_id) => storage.list_worktrees_for_project(project_id), + None => storage.list_worktrees(), + } + .map_err(storage_error)?; + Ok(WorktreeListResponse { + worktrees: worktrees.into_iter().map(worktree_dto).collect(), + }) + } + + pub(crate) fn prepare_worktree_removal( + &self, + request: WorktreePrepareRemoveRequest, + ) -> Result { + let _lifecycle = self.lifecycle()?; + let saga = self.worktree_saga.as_ref().ok_or_else(git_unavailable)?; + if self.has_live_worktree_user(request.worktree_id)? { + return Ok(WorktreePrepareRemoveResponse::Blocked { + worktree_id: request.worktree_id, + is_dirty: self + .storage()? + .get_worktree(request.worktree_id) + .map_err(storage_error)? + .is_some_and(|worktree| worktree.is_dirty), + blockers: vec![ + WorktreeRemovalBlocker::Running, + WorktreeRemovalBlocker::InUse, + ], + }); + } + saga.prepare_remove(request.worktree_id).map_err(saga_error) + } + + pub(crate) fn remove_worktree( + &self, + request: &WorktreeRemoveRequest, + ) -> Result { + let _lifecycle = self.lifecycle()?; + let saga = self.worktree_saga.as_ref().ok_or_else(git_unavailable)?; + if self.has_live_worktree_user(request.worktree_id)? { + return Err(ApiError::new( + "worktree_confirmation_invalid", + "A live session now uses this worktree.", + ) + .with_action("Stop its sessions and prepare removal again.")); + } + saga.remove_worktree(request.worktree_id, &request.confirmation_token) + .map_err(saga_error)?; + Ok(cli_master_core::wire::EmptyResponse::default()) + } + + fn has_live_worktree_user( + &self, + worktree_id: cli_master_core::WorktreeId, + ) -> Result { + let (worktree, sessions) = { + let storage = self.storage()?; + let worktree = storage + .get_worktree(worktree_id) + .map_err(storage_error)? + .ok_or_else(|| not_found("worktree", worktree_id))?; + let sessions = storage.list_sessions().map_err(storage_error)?; + (worktree, sessions) + }; + let root = worktree.path.canonicalize().unwrap_or(worktree.path); + let mut live = false; + for session in sessions { + // Also cover sessions registered directly at this worktree or a child folder. + let cwd = session.cwd.canonicalize().unwrap_or(session.cwd); + if worktree.session_id == Some(session.id) || cwd.starts_with(&root) { + if let Ok(snapshot) = self.manager.snapshot(session.id) { + self.persist_snapshot(&snapshot)?; + live |= snapshot.status.is_live(); + } else { + live |= session.status.is_live(); + } + } + } + Ok(live) + } +} + +pub(super) fn session_with_worktree( + storage: &Storage, + stored: StoredSession, +) -> Result { + let worktree = storage + .list_worktrees_for_project(stored.project_id) + .map_err(storage_error)? + .into_iter() + .find(|worktree| worktree.session_id == Some(stored.id)); + let mut session = stored_session(stored); + if let Some(worktree) = worktree { + session.branch = Some(worktree.branch); + session.worktree_id = Some(worktree.id); + session.worktree_path = Some(worktree.path); + } + Ok(session) +} + +impl SessionRegistry { + pub(super) fn validate_start_directory( + &self, + stored: &StoredSession, + ) -> Result, ApiError> { + let (project, worktree) = { + let storage = self.storage()?; + let project = storage + .get_project(stored.project_id) + .map_err(storage_error)? + .ok_or_else(|| not_found("project", stored.project_id))?; + let worktree = storage + .list_worktrees_for_project(stored.project_id) + .map_err(storage_error)? + .into_iter() + .find(|worktree| worktree.session_id == Some(stored.id)); + (project, worktree) + }; + let worktree_id = worktree.as_ref().map(|worktree| worktree.id); + let root = if let Some(worktree) = worktree { + if !matches!( + worktree.state, + cli_master_storage::WorktreeState::Active + | cli_master_storage::WorktreeState::RemovePending + ) { + return Err( + ApiError::new("worktree_not_active", "This worktree needs recovery.") + .with_action("Resolve the incomplete operation before starting."), + ); + } + if worktree + .path + .canonicalize() + .map_err(|_| directory_unavailable())? + != worktree.path + { + return Err(directory_unavailable()); + } + let git = self.git.as_ref().ok_or_else(git_unavailable)?; + let registered = git + .list_worktrees(&project.path) + .map_err(|error| saga_error(error.into()))?; + let inspection = git + .inspect_repository(&worktree.path) + .map_err(|error| saga_error(error.into()))?; + if inspection.repository_root.as_ref() != Some(&worktree.path) + || inspection.branch.as_deref() != Some(worktree.branch.as_str()) + || !registered.iter().any(|entry| { + entry.path == worktree.path + && entry.branch.as_deref() == Some(worktree.branch.as_str()) + && !entry.prunable + }) + { + return Err(ApiError::new( + "worktree_identity_changed", + "The managed Git worktree identity changed.", + ) + .with_action("Restore the original worktree or create a new isolated session.")); + } + worktree.path + } else { + project.path + }; + let canonical_root = root.canonicalize().map_err(|_| directory_unavailable())?; + let cwd = stored + .cwd + .canonicalize() + .map_err(|_| directory_unavailable())?; + if canonical_root != root || cwd != stored.cwd || !cwd.is_dir() || !cwd.starts_with(root) { + return Err(directory_unavailable()); + } + Ok(worktree_id) + } +} + +fn directory_unavailable() -> ApiError { + ApiError::new( + "session_directory_unavailable", + "The session directory is missing or outside its project/worktree.", + ) + .with_action("Restore the directory or create a session in an existing project folder.") +} + +fn worktree_dto(stored: StoredWorktree) -> Worktree { + Worktree { + id: stored.id, + project_id: stored.project_id, + session_id: stored.session_id, + path: stored.path, + branch: stored.branch, + is_dirty: stored.is_dirty, + state: match stored.state { + cli_master_storage::WorktreeState::Creating => WorktreeState::Creating, + cli_master_storage::WorktreeState::Active => WorktreeState::Active, + cli_master_storage::WorktreeState::RemovePending => WorktreeState::RemovePending, + cli_master_storage::WorktreeState::Orphaned => WorktreeState::Orphaned, + }, + created_at_ms: stored.created_at_ms, + updated_at_ms: stored.updated_at_ms, + } +} + +pub(super) fn git_unavailable() -> ApiError { + ApiError::new( + "git_unavailable", + "Git is required to manage isolated worktrees.", + ) + .with_action("Install Git and restart the daemon.") +} + +#[allow( + clippy::needless_pass_by_value, + reason = "owned adapter for Result::map_err" +)] +pub(super) fn saga_error(error: SagaError) -> ApiError { + let mut api = ApiError::new(error.code(), error.message()).with_action(error.action()); + if let Some(path) = error.path() { + api = api.with_detail("path", path.to_string_lossy().into_owned()); + } + if let Some(id) = error.worktree_id() { + api = api.with_detail("worktreeId", id.to_string()); + } + if let Some(id) = error.session_id() { + api = api.with_detail("sessionId", id.to_string()); + } + api +} diff --git a/crates/daemon/tests/worktree_ipc.rs b/crates/daemon/tests/worktree_ipc.rs new file mode 100644 index 0000000..98f36fe --- /dev/null +++ b/crates/daemon/tests/worktree_ipc.rs @@ -0,0 +1,831 @@ +use std::collections::BTreeMap; +use std::fs; +use std::os::unix::fs::symlink; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +use cli_master_core::wire::{ + AgentRecord, ConfirmationToken, StateSnapshotResponse, WorktreeListResponse, + WorktreePrepareRemoveResponse, WorktreeRemovalBlocker, method, +}; +use cli_master_core::{ + AgentId, CommandSpec, Project, ProjectId, RequestEnvelope, ResponseEnvelope, ResponsePayload, + Session, SessionStatus, Worktree, WorktreeId, WorktreeState, +}; +use cli_master_daemon::{Daemon, DaemonConfig, DaemonError, MAX_FRAME_LENGTH}; +use cli_master_session::{SessionManager, SessionManagerConfig, TerminalSize}; +use cli_master_storage::{SessionRuntimeUpdate, Storage}; +use futures_util::{SinkExt, StreamExt}; +use serde::de::DeserializeOwned; +use serde_json::{Value, json}; +use tempfile::TempDir; +use tokio::net::UnixStream; +use tokio::task::JoinHandle; +use tokio_util::codec::{Framed, LengthDelimitedCodec}; +use tokio_util::sync::CancellationToken; + +type Client = Framed; + +struct RunningDaemon { + config: DaemonConfig, + cancellation: CancellationToken, + task: JoinHandle>, +} + +impl RunningDaemon { + fn start(root: &Path) -> Self { + let config = DaemonConfig::from_paths(root.join("data"), root.join("run")); + let daemon = Daemon::bind(config.clone()).expect("daemon should bind"); + let cancellation = CancellationToken::new(); + let task_cancellation = cancellation.clone(); + let task = tokio::spawn(async move { daemon.run(task_cancellation).await }); + Self { + config, + cancellation, + task, + } + } + + async fn connect(&self) -> Client { + let stream = UnixStream::connect(self.config.socket_path()) + .await + .expect("client should connect"); + LengthDelimitedCodec::builder() + .max_frame_length(MAX_FRAME_LENGTH) + .new_framed(stream) + } + + async fn stop(self) { + self.cancellation.cancel(); + tokio::time::timeout(Duration::from_secs(10), self.task) + .await + .expect("daemon should stop before timeout") + .expect("daemon task should join") + .expect("daemon should stop cleanly"); + } +} + +struct Fixture { + root: TempDir, + repository: PathBuf, + observed_cwd: PathBuf, + project_id: ProjectId, + agent_id: AgentId, +} + +impl Fixture { + async fn new(project_directory: &str) -> (Self, RunningDaemon, Client) { + let root = TempDir::new().expect("temporary directory should exist"); + let repository = root.path().join("repository"); + fs::create_dir_all(repository.join("apps/api")).unwrap(); + fs::write(repository.join("apps/api/tracked.txt"), "original\n").unwrap(); + git(&repository, &["init", "-b", "main"]); + git( + &repository, + &["config", "user.email", "tests@example.invalid"], + ); + git(&repository, &["config", "user.name", "CLI Master Tests"]); + git(&repository, &["add", "."]); + git(&repository, &["commit", "-m", "initial"]); + let script = root.path().join("observe-cwd.sh"); + fs::write(&script, "pwd -P > \"$1\"\nexec /bin/cat\n").unwrap(); + let observed_cwd = root.path().join("observed-cwd"); + let daemon = RunningDaemon::start(root.path()); + let mut client = daemon.connect().await; + let project: Project = call( + &mut client, + method::PROJECT_ADD, + json!({"path": repository.join(project_directory)}), + ) + .await; + let agent: AgentRecord = call( + &mut client, + method::AGENT_CUSTOM_CREATE, + json!({ + "displayName": "Worktree fixture", + "command": { + "executable": "/bin/sh", + "args": [script, observed_cwd], + "env": {} + } + }), + ) + .await; + ( + Self { + root, + repository, + observed_cwd, + project_id: project.id, + agent_id: agent.id, + }, + daemon, + client, + ) + } + + async fn create(&self, client: &mut Client, relative_directory: Option<&str>) -> Session { + let mut payload = json!({ + "projectId": self.project_id, + "agentId": self.agent_id, + "name": "Isolated task", + "isolation": "new_worktree" + }); + if let Some(relative_directory) = relative_directory { + payload["relativeDirectory"] = json!(relative_directory); + } + call(client, method::SESSION_CREATE, payload).await + } +} + +fn git(cwd: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(cwd) + .env("GIT_TERMINAL_PROMPT", "0") + .output() + .expect("git fixture should start"); + assert!( + output.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +async fn exchange(client: &mut Client, method: &str, payload: Value) -> ResponseEnvelope { + let request = RequestEnvelope::v1(method, payload); + client + .send(serde_json::to_vec(&request).unwrap().into()) + .await + .expect("request should send"); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let bytes = client + .next() + .await + .expect("response frame should arrive") + .expect("frame should be valid"); + let envelope: Value = serde_json::from_slice(&bytes).unwrap(); + if envelope["kind"] == "event" { + continue; + } + let response: ResponseEnvelope = serde_json::from_value(envelope).unwrap(); + assert_eq!(response.request_id, request.request_id); + return response; + } + }) + .await + .unwrap_or_else(|_| panic!("{method} should respond before timeout")) +} + +async fn call(client: &mut Client, method: &str, payload: Value) -> T { + match exchange(client, method, payload).await.payload { + ResponsePayload::Success { data } => { + serde_json::from_value(data).expect("response should match its wire DTO") + } + ResponsePayload::Error { error } => panic!("{method} failed: {error:?}"), + } +} + +async fn failure(client: &mut Client, method: &str, payload: Value) -> String { + match exchange(client, method, payload).await.payload { + ResponsePayload::Error { error } => error.code, + ResponsePayload::Success { data } => panic!("{method} unexpectedly succeeded: {data}"), + } +} + +async fn worktrees(client: &mut Client, project_id: Option) -> Vec { + let payload = project_id.map_or_else(|| json!({}), |id| json!({"projectId": id})); + let response: WorktreeListResponse = call(client, method::WORKTREE_LIST, payload).await; + response.worktrees +} + +async fn prepare(client: &mut Client, worktree_id: WorktreeId) -> WorktreePrepareRemoveResponse { + call( + client, + method::WORKTREE_PREPARE_REMOVE, + json!({"worktreeId": worktree_id}), + ) + .await +} + +async fn ready_token(client: &mut Client, worktree_id: WorktreeId) -> ConfirmationToken { + let response = prepare(client, worktree_id).await; + let WorktreePrepareRemoveResponse::Ready { + worktree_id: prepared_id, + confirmation_token, + expires_at_ms, + } = response + else { + panic!("clean unused worktree should be removable: {response:?}"); + }; + assert_eq!(prepared_id, worktree_id); + assert!(expires_at_ms > 1_700_000_000_000); + confirmation_token +} + +async fn remove(client: &mut Client, worktree_id: WorktreeId, token: ConfirmationToken) { + let _: Value = call( + client, + method::WORKTREE_REMOVE, + json!({"worktreeId": worktree_id, "confirmationToken": token}), + ) + .await; +} + +async fn wait_for_file(path: &Path) -> String { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + if let Ok(contents) = fs::read_to_string(path) { + if !contents.is_empty() { + return contents; + } + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("child should report before timeout") +} + +#[tokio::test] +#[allow( + clippy::too_many_lines, + reason = "one socket lifecycle proves preparation, launch and independent metadata deletion" +)] +async fn create_prepares_without_spawning_then_start_uses_the_isolated_subdirectory() { + let (fixture, daemon, mut client) = Fixture::new("").await; + let created = fixture.create(&mut client, Some("apps/api")).await; + let worktree_id = created + .worktree_id + .expect("session should identify its worktree"); + let path = created.worktree_path.as_ref().expect("worktree path"); + assert_eq!(created.status, SessionStatus::Unknown); + assert_eq!(created.pid, None); + assert_eq!(created.pty_id, None); + assert_eq!( + failure( + &mut client, + method::SESSION_WRITE, + json!({"sessionId": created.id, "base64": "eA=="}) + ) + .await, + "session_not_found", + "prepared metadata must not have a writable PTY" + ); + assert_eq!(created.cwd, path.join("apps/api")); + assert!( + !fixture.observed_cwd.exists(), + "create must not run the command" + ); + assert_eq!( + fs::read_to_string(created.cwd.join("tracked.txt")).unwrap(), + "original\n" + ); + assert_ne!(path, &fixture.repository); + + let snapshot: StateSnapshotResponse = + call(&mut client, method::STATE_SNAPSHOT, json!({})).await; + assert_eq!(snapshot.sessions, std::slice::from_ref(&created)); + let listed = worktrees(&mut client, Some(fixture.project_id)).await; + assert_eq!(snapshot.worktrees, listed); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, worktree_id); + assert_eq!(listed[0].session_id, Some(created.id)); + assert_eq!(listed[0].path, *path); + assert_eq!(Some(&listed[0].branch), created.branch.as_ref()); + assert_eq!(listed[0].state, WorktreeState::Active); + assert!( + worktrees(&mut client, Some(ProjectId::new())) + .await + .is_empty() + ); + + let started: Session = call( + &mut client, + method::SESSION_START, + json!({"sessionId": created.id}), + ) + .await; + assert!(started.status.is_live()); + assert!(started.pid.is_some()); + assert!(started.pty_id.is_some()); + assert_eq!(started.worktree_id, Some(worktree_id)); + assert_eq!(started.cwd, created.cwd); + assert_eq!( + PathBuf::from(wait_for_file(&fixture.observed_cwd).await.trim()), + created.cwd + ); + + let _: Session = call( + &mut client, + method::SESSION_STOP, + json!({"sessionId": created.id}), + ) + .await; + let _: Value = call( + &mut client, + method::SESSION_DELETE, + json!({"sessionId": created.id}), + ) + .await; + assert!( + path.join("apps/api/tracked.txt").is_file(), + "deleting metadata must preserve the checkout" + ); + let snapshot: StateSnapshotResponse = + call(&mut client, method::STATE_SNAPSHOT, json!({})).await; + assert!(snapshot.sessions.is_empty()); + assert_eq!(snapshot.worktrees.len(), 1); + assert_eq!(snapshot.worktrees[0].session_id, None); + + let token = ready_token(&mut client, worktree_id).await; + remove(&mut client, worktree_id, token).await; + assert!(!path.exists()); + assert!(fixture.repository.join("apps/api/tracked.txt").is_file()); + assert!(worktrees(&mut client, None).await.is_empty()); + daemon.stop().await; +} + +#[tokio::test] +async fn start_rejects_a_worktree_replaced_by_a_symlink_to_the_original_project() { + let (fixture, daemon, mut client) = Fixture::new("").await; + let created = fixture.create(&mut client, Some("apps/api")).await; + let path = created.worktree_path.unwrap(); + let moved = fixture.root.path().join("moved-worktree"); + fs::rename(&path, &moved).unwrap(); + symlink(&fixture.repository, &path).unwrap(); + + let response = exchange( + &mut client, + method::SESSION_START, + json!({"sessionId": created.id}), + ) + .await; + // Restore Git's registered path before checking the result so the fixture + // stays recoverable even if a regression unexpectedly starts the command. + fs::remove_file(&path).unwrap(); + fs::rename(&moved, &path).unwrap(); + + let ResponsePayload::Error { error } = response.payload else { + panic!("a substituted worktree path must not launch an agent"); + }; + assert_eq!(error.code, "session_directory_unavailable"); + assert!(!fixture.observed_cwd.exists()); + for root in [&path, &fixture.repository] { + assert_eq!( + fs::read_to_string(root.join("apps/api/tracked.txt")).unwrap(), + "original\n" + ); + } + let snapshot: StateSnapshotResponse = + call(&mut client, method::STATE_SNAPSHOT, json!({})).await; + assert_eq!(snapshot.sessions[0].status, SessionStatus::Unknown); + assert_eq!(snapshot.sessions[0].pid, None); + assert_eq!(snapshot.worktrees[0].path, path); + daemon.stop().await; +} + +#[tokio::test] +async fn concurrent_stop_and_restart_keep_the_same_session_metadata_consistent_with_its_runtime() { + let (fixture, daemon, mut control) = Fixture::new("").await; + let created = fixture.create(&mut control, None).await; + let mut stopping = daemon.connect().await; + let mut restarting = daemon.connect().await; + + for _ in 0..3 { + let _: Session = call( + &mut control, + method::SESSION_START, + json!({"sessionId": created.id}), + ) + .await; + let (stopped, restarted) = tokio::join!( + call::( + &mut stopping, + method::SESSION_STOP, + json!({"sessionId": created.id}) + ), + call::( + &mut restarting, + method::SESSION_RESTART, + json!({"sessionId": created.id}) + ), + ); + assert!(!stopped.status.is_live()); + assert_eq!(stopped.pid, None); + assert!(restarted.status.is_live()); + assert!(restarted.pid.is_some()); + assert_eq!(restarted.worktree_id, created.worktree_id); + + let snapshot: StateSnapshotResponse = + call(&mut control, method::STATE_SNAPSHOT, json!({})).await; + assert_eq!(snapshot.sessions.len(), 1); + let durable = &snapshot.sessions[0]; + assert_eq!(durable.id, created.id); + let write = exchange( + &mut control, + method::SESSION_WRITE, + json!({"sessionId": created.id, "base64": "cHJvYmUK"}), + ) + .await; + match write.payload { + ResponsePayload::Success { .. } => { + assert!( + durable.status.is_live(), + "a writable runtime must be recorded live" + ); + assert_eq!(durable.pid, restarted.pid); + } + ResponsePayload::Error { error } => { + assert_eq!(error.code, "session_not_running"); + assert!(!durable.status.is_live()); + assert_eq!(durable.pid, None); + } + } + } + + let stopped: Session = call( + &mut control, + method::SESSION_STOP, + json!({"sessionId": created.id}), + ) + .await; + assert!(!stopped.status.is_live()); + assert_eq!(stopped.pid, None); + let token = ready_token(&mut control, created.worktree_id.unwrap()).await; + remove(&mut control, created.worktree_id.unwrap(), token).await; + daemon.stop().await; +} + +#[tokio::test] +async fn dirty_worktrees_block_removal_and_changes_after_confirmation_invalidate_the_token() { + let (fixture, daemon, mut client) = Fixture::new("").await; + let created = fixture.create(&mut client, None).await; + let worktree_id = created.worktree_id.unwrap(); + let path = created.worktree_path.unwrap(); + let token = ready_token(&mut client, worktree_id).await; + fs::write(path.join("user-notes.txt"), "preserve this\n").unwrap(); + + assert_eq!( + failure( + &mut client, + method::WORKTREE_REMOVE, + json!({ + "worktreeId": worktree_id, "confirmationToken": token + }) + ) + .await, + "worktree_confirmation_invalid" + ); + assert_eq!( + fs::read_to_string(path.join("user-notes.txt")).unwrap(), + "preserve this\n" + ); + let blocked = prepare(&mut client, worktree_id).await; + let WorktreePrepareRemoveResponse::Blocked { + is_dirty, blockers, .. + } = blocked + else { + panic!("untracked content must block removal: {blocked:?}"); + }; + assert!(is_dirty); + assert!(blockers.contains(&WorktreeRemovalBlocker::UntrackedFiles)); + assert_eq!( + worktrees(&mut client, None).await[0].state, + WorktreeState::Active + ); + + fs::remove_file(path.join("user-notes.txt")).unwrap(); + let token = ready_token(&mut client, worktree_id).await; + remove(&mut client, worktree_id, token).await; + assert!(!path.exists()); + daemon.stop().await; +} + +#[tokio::test] +async fn a_live_session_blocks_removal_and_old_tokens_cannot_override_changed_ownership() { + let (fixture, daemon, mut client) = Fixture::new("").await; + let created = fixture.create(&mut client, None).await; + let worktree_id = created.worktree_id.unwrap(); + let path = created.worktree_path.unwrap(); + let token = ready_token(&mut client, worktree_id).await; + let _: Session = call( + &mut client, + method::SESSION_START, + json!({"sessionId": created.id}), + ) + .await; + wait_for_file(&fixture.observed_cwd).await; + assert_eq!( + failure( + &mut client, + method::WORKTREE_REMOVE, + json!({ + "worktreeId": worktree_id, "confirmationToken": token + }) + ) + .await, + "worktree_confirmation_invalid" + ); + let blocked = prepare(&mut client, worktree_id).await; + let WorktreePrepareRemoveResponse::Blocked { + is_dirty, blockers, .. + } = blocked + else { + panic!("the live session must block removal: {blocked:?}"); + }; + assert!(!is_dirty); + assert!( + blockers.contains(&WorktreeRemovalBlocker::Running) + || blockers.contains(&WorktreeRemovalBlocker::InUse) + ); + assert_eq!( + failure( + &mut client, + method::SESSION_DELETE, + json!({"sessionId": created.id}) + ) + .await, + "session_still_running" + ); + assert!(path.is_dir()); + + let _: Session = call( + &mut client, + method::SESSION_STOP, + json!({"sessionId": created.id}), + ) + .await; + let token = ready_token(&mut client, worktree_id).await; + let _: Value = call( + &mut client, + method::SESSION_DELETE, + json!({"sessionId": created.id}), + ) + .await; + assert_eq!( + failure( + &mut client, + method::WORKTREE_REMOVE, + json!({ + "worktreeId": worktree_id, "confirmationToken": token + }) + ) + .await, + "worktree_confirmation_invalid" + ); + assert!(path.is_dir()); + let token = ready_token(&mut client, worktree_id).await; + remove(&mut client, worktree_id, token).await; + daemon.stop().await; +} + +#[tokio::test] +async fn an_unsubscribed_session_can_be_deleted_after_its_process_exits_on_its_own() { + let (fixture, daemon, mut client) = Fixture::new("").await; + fs::write( + fixture.root.path().join("observe-cwd.sh"), + "pwd -P > \"$1\"\nIFS= read -r line\n", + ) + .unwrap(); + let created = fixture.create(&mut client, None).await; + let worktree_id = created.worktree_id.unwrap(); + let path = created.worktree_path.unwrap(); + let started: Session = call( + &mut client, + method::SESSION_START, + json!({"sessionId": created.id}), + ) + .await; + assert!(started.status.is_live()); + wait_for_file(&fixture.observed_cwd).await; + + // Let the command return normally, with no subscription driving durable + // status updates and no explicit stop operation that could hide the bug. + let _: Value = call( + &mut client, + method::SESSION_WRITE, + json!({"sessionId": created.id, "base64": "ZG9uZQo="}), + ) + .await; + tokio::time::timeout(Duration::from_secs(5), async { + loop { + match exchange( + &mut client, + method::SESSION_DELETE, + json!({"sessionId": created.id}), + ) + .await + .payload + { + ResponsePayload::Success { .. } => break, + ResponsePayload::Error { error } => { + assert_eq!(error.code, "session_still_running"); + } + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("metadata should become deletable after the child exits"); + + assert!(path.join("apps/api/tracked.txt").is_file()); + let snapshot: StateSnapshotResponse = + call(&mut client, method::STATE_SNAPSHOT, json!({})).await; + assert!(snapshot.sessions.is_empty()); + assert_eq!(snapshot.worktrees[0].id, worktree_id); + assert_eq!(snapshot.worktrees[0].session_id, None); + daemon.stop().await; +} + +#[tokio::test] +async fn another_session_using_the_checkout_blocks_removal_without_a_worktree_association() { + let (fixture, daemon, mut client) = Fixture::new("").await; + let isolated = fixture.create(&mut client, None).await; + let worktree_id = isolated.worktree_id.unwrap(); + let path = isolated.worktree_path.unwrap(); + let token = ready_token(&mut client, worktree_id).await; + let project: Project = call( + &mut client, + method::PROJECT_ADD, + json!({"path": path.join("apps/api")}), + ) + .await; + let other: Session = call( + &mut client, + method::SESSION_CREATE, + json!({ + "projectId": project.id, + "agentId": fixture.agent_id, + "name": "Independent terminal", + "isolation": "current" + }), + ) + .await; + assert_eq!(other.worktree_id, None); + let _: Session = call( + &mut client, + method::SESSION_START, + json!({"sessionId": other.id}), + ) + .await; + wait_for_file(&fixture.observed_cwd).await; + let error = failure( + &mut client, + method::WORKTREE_REMOVE, + json!({"worktreeId": worktree_id, "confirmationToken": token}), + ) + .await; + assert!( + ["worktree_confirmation_invalid", "worktree_in_use"].contains(&error.as_str()), + "removal should reject changed usage: {error}" + ); + let blocked = prepare(&mut client, worktree_id).await; + let WorktreePrepareRemoveResponse::Blocked { blockers, .. } = blocked else { + panic!("another session using this checkout must block removal: {blocked:?}"); + }; + assert!(blockers.contains(&WorktreeRemovalBlocker::InUse)); + assert!(path.is_dir()); + + let _: Session = call( + &mut client, + method::SESSION_STOP, + json!({"sessionId": other.id}), + ) + .await; + let token = ready_token(&mut client, worktree_id).await; + remove(&mut client, worktree_id, token).await; + assert!(!path.exists()); + daemon.stop().await; +} + +#[tokio::test] +async fn prepared_worktrees_survive_restart_with_the_project_subdirectory_preserved() { + let (fixture, first, mut client) = Fixture::new("apps").await; + let created = fixture.create(&mut client, Some("api")).await; + let worktree_id = created.worktree_id.unwrap(); + let path = created.worktree_path.as_ref().unwrap(); + assert_eq!(created.cwd, path.join("apps/api")); + drop(client); + first.stop().await; + + let second = RunningDaemon::start(fixture.root.path()); + let mut client = second.connect().await; + let snapshot: StateSnapshotResponse = + call(&mut client, method::STATE_SNAPSHOT, json!({})).await; + assert_eq!(snapshot.sessions.len(), 1); + assert_eq!(snapshot.sessions[0].id, created.id); + assert_eq!(snapshot.sessions[0].cwd, created.cwd); + assert_eq!(snapshot.sessions[0].worktree_id, Some(worktree_id)); + assert_eq!(snapshot.sessions[0].status, SessionStatus::Unknown); + assert_eq!(snapshot.sessions[0].pid, None); + assert_eq!(snapshot.worktrees.len(), 1); + assert_eq!(snapshot.worktrees[0].state, WorktreeState::Active); + assert_eq!(snapshot.worktrees[0].path, *path); + assert_eq!(snapshot.worktrees, worktrees(&mut client, None).await); + assert!(!fixture.observed_cwd.exists()); + + let started: Session = call( + &mut client, + method::SESSION_START, + json!({"sessionId": created.id}), + ) + .await; + assert_eq!(started.cwd, created.cwd); + assert_eq!( + PathBuf::from(wait_for_file(&fixture.observed_cwd).await.trim()), + created.cwd + ); + let _: Session = call( + &mut client, + method::SESSION_STOP, + json!({"sessionId": created.id}), + ) + .await; + second.stop().await; +} + +#[tokio::test] +async fn restart_reconciles_a_worktree_session_without_signalling_an_unowned_stale_pid() { + let (fixture, first, mut client) = Fixture::new("").await; + let created = fixture.create(&mut client, None).await; + let database = first.config.database_path().to_path_buf(); + drop(client); + first.stop().await; + + // This separate manager owns the canary process. Its PID simulates an old + // persisted PID that now belongs to an unrelated process after a crash. + let canary_script = fixture.root.path().join("canary.sh"); + let canary_reply = fixture.root.path().join("canary-reply"); + fs::write( + &canary_script, + "IFS= read -r line\nprintf '%s' \"$line\" > \"$1\"\n", + ) + .unwrap(); + let canary_manager = SessionManager::new(SessionManagerConfig::default()).unwrap(); + let command = CommandSpec::try_from_parts( + "/bin/sh", + [ + canary_script.to_string_lossy().into_owned(), + canary_reply.to_string_lossy().into_owned(), + ], + fixture.root.path(), + BTreeMap::new(), + ) + .unwrap(); + let canary = canary_manager + .spawn(&command, TerminalSize::default()) + .unwrap(); + let canary_pid = canary_manager.snapshot(canary.id).unwrap().pid.unwrap(); + Storage::open(&database) + .unwrap() + .update_session_runtime( + created.id, + &SessionRuntimeUpdate { + status: SessionStatus::Running, + runtime_pid: Some(canary_pid), + daemon_instance_id: Some("crashed-daemon".to_owned()), + exit_code: None, + error_code: None, + last_activity_at_ms: None, + updated_at_ms: created.updated_at_ms, + }, + ) + .unwrap(); + + let second = RunningDaemon::start(fixture.root.path()); + let mut client = second.connect().await; + let snapshot: StateSnapshotResponse = + call(&mut client, method::STATE_SNAPSHOT, json!({})).await; + let recovered = &snapshot.sessions[0]; + assert_eq!(recovered.id, created.id); + assert_eq!(recovered.status, SessionStatus::Unknown); + assert_eq!(recovered.pid, None); + assert_eq!(recovered.pty_id, None); + assert_eq!(recovered.worktree_id, created.worktree_id); + assert_eq!(recovered.worktree_path, created.worktree_path); + assert_eq!(snapshot.worktrees[0].state, WorktreeState::Active); + assert_eq!( + failure( + &mut client, + method::SESSION_STOP, + json!({"sessionId": created.id}) + ) + .await, + "session_not_found" + ); + canary_manager.write(canary.id, b"still-alive\n").unwrap(); + assert_eq!(wait_for_file(&canary_reply).await, "still-alive"); + let durable = Storage::open(&database) + .unwrap() + .get_session(created.id) + .unwrap() + .unwrap(); + assert_eq!(durable.status, SessionStatus::Unknown); + assert_eq!(durable.runtime_pid, None); + assert_eq!(durable.daemon_instance_id, None); + canary_manager.shutdown().unwrap(); + second.stop().await; +} diff --git a/protocol/catalog.json b/protocol/catalog.json index fff6729..0efc949 100644 --- a/protocol/catalog.json +++ b/protocol/catalog.json @@ -27,6 +27,7 @@ "session.unsubscribe", "git.status", "git.diff", + "worktree.list", "worktree.prepare_remove", "worktree.remove", "diagnostics.get" From 72b4d4dd086a08d71d269005b1457ac8bef2a089 Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sat, 5 Sep 2026 21:52:14 -0300 Subject: [PATCH 06/12] docs(runtime): record parity evidence and integration contracts --- docs/codex/maestri-runtime-report.md | 94 ++++++++++++++ docs/maestri-runtime-parity.md | 177 +++++++++++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 docs/codex/maestri-runtime-report.md create mode 100644 docs/maestri-runtime-parity.md diff --git a/docs/codex/maestri-runtime-report.md b/docs/codex/maestri-runtime-report.md new file mode 100644 index 0000000..5eec583 --- /dev/null +++ b/docs/codex/maestri-runtime-report.md @@ -0,0 +1,94 @@ +# Relatório de integração S2 — runtime Maestri + +Data: **2026-09-05**. Branch: **feat/maestri-runtime**. +Worktree: `/Users/eguimacs/cli-master-runtime`. +Baseline de criação: `0ac8dd7d49eefee16e5efbf389994f551bc584f5`, obtido de +`origin/refactor/canvas-only-shell`. Integração final pertence à S1 nessa branch. + +O objetivo completo permanece na [matriz de runtime](../maestri-runtime-parity.md). +Esta entrega resolve o primeiro caminho backend. Floors, landing, editor, +canvas durável, presets, continuidade nativa, comunicação, rotinas e ambientes +remotos permanecem em desenvolvimento. Integração desktop não foi presumida. + +## Commits disponíveis + +| Commit | Alteração | Evidência local | +| --- | --- | --- | +| `7693468` | Saga separa preparação de início; associação SQLite transacional; subdiretórios; compensação e cancelamento de tokens. | Suite session/storage executada; após ajustes finais, 29 testes de create/prepare/remove passaram. Clippy session/storage sem warnings. | +| `3c25a85` | Daemon liga new_worktree, snapshot/listagem, preparo/remoção e recovery à saga compartilhando SessionManager e Storage. | 118 testes core/daemon passaram, incluindo 9 fluxos novos pelo socket real; Clippy dos quatro pacotes sem warnings. | + +Ambos usam a identidade Git configurada `guicybercode`, sem trailers +`Co-authored-by`. Publicados em `origin/feat/maestri-runtime`. + +## Contratos prontos para integração + +| Método | Request → response | Comportamento observado | +| --- | --- | --- | +| `session.create` | `{ projectId, name, agentId, isolation: "current" | "new_worktree", relativeDirectory? } → Session` | Prepara metadata/Git sem PTY ou processo. Retorna unknown sem pid; subdiretório cadastrado e relativeDirectory preservados. | +| `session.start` | `{ sessionId } → Session` | Início explícito por SessionManager no cwd persistido. Revalida raiz/identidade Git e invalida remoção preparada. | +| `session.list` / `state.snapshot` | Contratos existentes | Sessões incluem branch/worktreeId/worktreePath; snapshot inclui worktrees duráveis. | +| `worktree.list` | **Novo:** `{ projectId?: UUID } → { worktrees: Worktree[] }` | Todas as worktrees ou filtro por projeto; estados parciais permanecem visíveis. | +| `worktree.prepare_remove` | `{ worktreeId } → ready + confirmationToken/expiresAtMs ou blocked + isDirty/blockers` | Inspeção real de Git e uso por sessões, inclusive cwd sem associação direta. | +| `worktree.remove` | `{ worktreeId, confirmationToken } → {}` | Reinspeção e confirmação vinculada ao estado; dirty/ignored/uso impedem exclusão. | +| `session.delete` | `{ sessionId } → {}` | Remove apenas metadados parados; preserva diretório e branch. Saída sem assinante também pode ser excluída. | + +Rust wire, `protocol/catalog.json`, `ipc/methods.ts` e `ipc/domain.ts` +foram sincronizados. Os dois últimos são artefatos aditivos: o baseline havia +removido esses caminhos citados em AGENTS. Nenhum componente React, estado de +canvas, estilo ou bridge Tauri foi alterado. + +Erros relevantes: `worktree_confirmation_invalid`, `worktree_in_use`, +`worktree_dirty`, `worktree_not_active`, `worktree_identity_changed`, +`session_directory_unavailable`, `session_still_running`, `git_unavailable`. +As respostas têm mensagens/ações; não transportar env ou argumentos em logs. + +**Refresh explícito:** este incremento não implementa emissão global de +`worktree.updated`/`worktree.removed`. S1 deve reler snapshot/list após mutação +ou reconexão. A existência dos nomes no catálogo não prova entrega de eventos. + +## Verificação executada e limites + +PR de integração: [#44](https://github.com/guicybercode/Jig/pull/44), em draft. +CI e Packaging Linux/macOS iniciados; resultados ainda pendentes. + +Host local: macOS. Passaram: + +- `CARGO_INCREMENTAL=0 cargo test -p cli-master-core -p cli-master-daemon --locked` + — 118 testes, dos quais 9 novos em `worktree_ipc.rs`. +- Testes session/storage e 29 testes finais de sagas, conforme primeiro commit. +- `CARGO_INCREMENTAL=0 cargo clippy -p cli-master-core -p cli-master-storage -p cli-master-session -p cli-master-daemon --all-targets --locked -- -D warnings`. +- `cargo fmt --all -- --check`, `git diff --check` e `bash scripts/check-versions.sh`. +- Rustdoc dos quatro pacotes com `RUSTDOCFLAGS=-Dwarnings`. +- Typecheck frontend e 8 testes dos contratos/client IPC existentes. + +Os casos socket exercitam start/stop/restart concorrentes, troca de raiz por +symlink entre create/start, token após edição externa, sessão sem vínculo +usando o checkout, saída espontânea e restart com PID canário de outro manager. +A matriz CI Linux/macOS e o pacote desktop ainda exigem resultado externo; +não foram chamados de aprovados. O editor/canvas S1 ainda precisa consumir e +verificar estes contratos. Os IDs M14/M34/M35 continuam parciais no escopo total. + +## Acordo com XIRP/S3 + +Resposta publicada em +`/Users/eguimacs/cli-master-xirp/docs/codex/xirp-coordination-reply.md`: + +- S3 possui novos módulos knowledge e componentes isolados. +- **0004_knowledge_documents.sql reservada para S3**; worktrees não criam migração. +- Acordados `knowledge.list/save/delete`, kind prompt/context, UUIDv7, + escopo opcional de projeto, revisão inteira; update/delete exigem revisão, + conflito retorna `knowledge_conflict`. Ainda não são contratos publicados. +- S3 pode publicar registro aditivo de lib.rs/migração/wire/mirrors/dispatch na + própria branch junto de implementação/testes; S2 revisa e integra o commit. +- `knowledge.updated` só pode ser anunciado como funcional com emissão real. +- S2 possui file service, workspace/floor/canvas e organização/workflow. + S3 possui composição de rascunhos/contexto; entrega usa SessionManager/adapters. + +## Próxima etapa + +Implementar serviço local `file.list/read/write` com alvos registrados, +leitura limitada, caminhos Unix preservados, escrita atômica e revisão de +conteúdo, documentando limites de concorrência externa. S3 reutiliza a leitura +segura. Em seguida, workspace/floor e canvas durável com revisão e importação +explícita do localStorage pela S1. A matriz mantém os incrementos posteriores; +esta ordem não reduz o objetivo aos serviços de arquivos. diff --git a/docs/maestri-runtime-parity.md b/docs/maestri-runtime-parity.md new file mode 100644 index 0000000..472ea02 --- /dev/null +++ b/docs/maestri-runtime-parity.md @@ -0,0 +1,177 @@ +# Evidências e sequência de integração do runtime Maestri + +Auditoria inicial de S2 em **2026-09-05**, no baseline +`0ac8dd7d49eefee16e5efbf389994f551bc584f5`, branch +`feat/maestri-runtime`, worktree `/Users/eguimacs/cli-master-runtime`. +Mudanças em andamento somente promovem um estado após registrar o commit e +a execução que o comprova no [relatório de integração](codex/maestri-runtime-report.md). + +Este documento complementa a matriz central `docs/maestri-xirp-parity.md`, +mantida por S1. Os IDs M01–M48 e X01–X10 continuam sendo os dela; as descrições +completas, fontes por recurso e critérios visuais não são duplicados aqui. +Na auditoria, a matriz e `docs/codex/parallel-goals.md` foram lidos na worktree +principal, `/Users/eguimacs/cli-master`, onde ainda não estavam no baseline +de S2. Publicar este recorte não substitui integrar esses documentos centrais. + +## Fontes e interpretação + +O [changelog oficial](https://www.themaestri.app/en/changelog), consultado em +2026-09-05, chega a 0.45.3, de 2026-09-04. Ele amplia o escopo até controle +remoto de rotinas/floors, sessões persistentes e retomada de conversas. +As páginas de [arquivos](https://www.themaestri.app/en/docs/file-tree), +[workspaces](https://www.themaestri.app/en/docs/workspaces), +[floors](https://www.themaestri.app/en/docs/floors) e +[notas](https://www.themaestri.app/en/docs/notes) também foram consultadas. +As opções de implementação e os testes abaixo são propostas para Jig. +Não se infere equivalência Linux a partir de links de download no site. + +### Como ler a evidência + +- **A:** implementação funcional não encontrada no baseline examinado. +- **P:** infraestrutura parcial ou integração incompleta. +- **V:** fluxo comprovado por teste executado e evidência identificada. +- **L:** limite de plataforma ainda exige resultado explícito. +- **S1:** interface, Tauri e integração visual; **S2:** runtime/contratos; + **S3:** knowledge/workflows locais. Uma dependência de S1/S3 permanece aberta. + +Nenhuma linha recebe V nesta auditoria estática. Teste existente sem execução +é evidência de cobertura pretendida. Compilar um tipo, anunciar um método, +passar teste direto da saga ou renderizar uma UI com IPC mockado não comprova +o caminho desktop → socket → domínio → efeito real. + +| Evidência | Arquivos no baseline | Limite observado | +| --- | --- | --- | +| R1 | `crates/daemon/src/sessions.rs`, `server.rs` | PTY e `current` estão ligados ao daemon; `new_worktree` retorna `worktree_sessions_unavailable`; snapshot usa `worktrees: Vec::new()`. | +| R2 | `crates/session/src/{saga,create,remove,recover,token}.rs`, `tests/{create_saga,remove_saga,recovery}.rs` | Saga cria e inicia em uma operação interna; persistência e remoção segura existem, mas os métodos públicos de worktree não têm dispatch. | +| R3 | `crates/core/src/wire/{method,request,response,event_name}.rs`, `protocol/catalog.json` | Catálogo existente não contém files/workspace/floor/canvas/knowledge/routine/environment. Eventos anunciados precisam de auditoria de emissão. | +| R4 | `crates/storage/migrations/0001_initial.sql` até `0003_recovery_metadata.sql`, `src/{migrate,settings,recovery}.rs` | SQLite guarda projetos/agentes/sessões/worktrees/settings; não há documento canvas ou modelos posteriores. | +| R5 | `crates/daemon/src/projects.rs`, `crates/daemon/tests/daemon_ipc.rs` | Cadastro aceita pasta não Git e subdiretório; teste `project_registration_accepts_a_plain_folder` existe. Organização e importação de filhos não existem. | +| R6 | `crates/daemon/src/git_inspection.rs`, `crates/daemon/tests/git_inspection.rs`, `crates/git/src` | Inspeção status/diff por alvo registrado; não implementa o conjunto de ações de escrita Git. | +| R7 | `crates/agents/src/{builtins,registry}.rs`, `crates/daemon/src/sessions.rs` | Registry e seed próprio do daemon divergem; Gemini no adapter não implica preset executável no produto. | +| R8 | `crates/session/src/{manager,replay,runtime}.rs`, `crates/storage/src/recovery.rs` | Handles e replay são memória do daemon; reinício reconcilia metadados, sem retomar conversa nativa/tmux. | +| R9 | `apps/desktop/src/app/features/canvas/{canvas-state,useCanvasState}.ts` | Documento local contém notas/terminais/conexões; não existe serviço durável no daemon. Frontend é somente leitura nesta sessão. | +| R10 | `crates/daemon/tests/{daemon_ipc,git_inspection,event_stream}.rs`, `crates/e2e/tests/acceptance.rs` | Existem casos reais de IPC e testes da saga; o caso de duas sessões isoladas usa saga diretamente. Não prova isolamento pela API pública. | +| R11 | `docs/adr/0001-session-ownership.md` até `0004-git-worktree-safety.md`, `AGENTS.md` | Ownership, epoch-ms, revisão de contrato e token por estado são invariantes. Floors compartilhados/hook/arquivo precisam de decisões adicionais. | +| R12 | `.github/workflows/ci.yml`, `docs/{PACKAGING,backup-and-recovery,KNOWN_ISSUES}.md` | Matriz Linux/macOS e procedimentos existem; auditoria não executou CI/pacote nem restauração visual. | + +## Evidência posterior ao baseline + +Em 2026-09-05, `7693468` e `3c25a85` na branch `feat/maestri-runtime` +ligam preparação/start, snapshot/listagem e remoção segura ao socket real. +Os 118 testes core/daemon passaram no macOS, incluindo 9 novos de worktree; +sagas/storage, Clippy e contratos frontend também foram verificados conforme +[relatório S2](codex/maestri-runtime-report.md). Isso comprova o incremento de +runtime M14/M34/M35; **os IDs completos continuam P**, pois floors/landing e +integração desktop não estão concluídos. Linux/CI/pacote seguem sem resultado. +Nenhum recurso posterior ganha V com essa execução. + +## Recorte de runtime por ID central + +A coluna de fonte remete à capacidade de mesmo ID na matriz central. Linhas +agrupadas mantêm todos os IDs para auditoria sem redefinir seu escopo. + +| IDs / fonte central | Estado no baseline / evidência | Próximo efeito de runtime comprovável | Dependência de integração | +| --- | --- | --- | --- | +| M01, M02, M03, M10, M46 — canvas | P: R9 | Persistir composição versionada, identidade e relações sem guardar PTY. | S1 implementa interação/visual; S2 oferece armazenamento sem determinar gestos. | +| M04 — workspaces | P: R4/R5 | Diretório/identidade/ordenação por workspace após restart; herança de cwd explícita. | S1 rail/edição; decisão de identidade workspace versus project antes de migrar. | +| M05 — instruções/importação | A: R3 | Importar pacote revisado sem sobrescrever arquivos externos; revisão de conteúdo em escrita. | S3 regras, S2 serviço de arquivo/importação, S1 preview. | +| M06 — paleta/busca | P: R9 | Consultas de arquivos e documentos limitadas ao escopo autorizado. | S1 busca/atalhos; S3 índices de contexto. | +| M07 — notas | P: R9, persistência A: R4 | Markdown real, leitura após restart, escrita atômica e conflito externo preservado. | S1 edita/preview; distinguir nota gerenciada de referência a arquivo externo. | +| M08 — acesso a notas | A: R3 | Resolução autorizada de grafo com leitura limitada, atualização e revogação. | Conexão visual S1 não concede autorização sozinha. | +| M09 — fichários | A: R3/R4 | Mover página entre agrupamentos sem duplicar conteúdo/identidade. | S1 páginas; depende de notas e grafo duráveis. | +| M11, M16 — preferências/bloqueio | P: R4/R9 | Preferências por escopo e revisão; bloqueio impede escrita também pela API de agente. | S1 aparência/atalhos; settings genérico não prova painel funcional. | +| M12 — partituras | A: R3/R4 | Exportação própria versionada e importação revisável, referências remapeadas, sem dados de execução. | S1 seleção/preview; não anunciar leitura de `.maestri` sem fixture real. | +| M13 — mídia | A: R3 | Arquivos referenciados com ownership e leitura limitada; remover nó preserva fonte externa. | S1/Tauri preview/clipboard Linux e macOS. | +| M14 — terminais/presets | P: R1/R7 | Usar definições reais do registry em create/start e editar argv sem divergência de seed. | S1 seletor; testar cada preset anunciado. | +| M15 — roles | A: R3/R7 | Provisionamento por adaptador com revisão e origem, sem substituir arquivos inseguros/existentes. | S3 descoberta e conteúdo; S1 seleção. | +| M17 — atenção | P lifecycle: R8 | Registrar sinais verificáveis de espera/atenção separados de status do processo. | S1 notificações/navegação; silêncio PTY não é evidência de aprovação. | +| M18, M19 — entrada/composer | P PTY: R1/R8; demais A | Draft durável e entrega única de texto/anexos no ambiente correto. | S1 IME/seleção/teclas; S3 prompts inserem draft, não fazem envio implícito. | +| M20 — conexões | P visual: R9; domínio A | Grafo durável com revisões e permissões independentes de estilo. | S1 geometria e navegação entre floors. | +| M21 — comunicação | A: R3/R4 | Pedido/resposta entre duas sessões com correlação, prazo, cancelamento e autorização atual. | Requer cliente CLI local e alvo verificável; não proxyar tráfego dos vendors. | +| M22 — Maestro | A: R3 | Recrutamento/dispensa autorizado reutiliza SessionManager e modelos de workspace/floor. | S1 gerência; acesso comum não implica privilégio gerencial. | +| M23, M24 — rotinas | A: R3/R4 | Agenda/histórico duráveis; skip, execução única e restart não duplicam disparos. | Hooks pelo dono da execução; S1 UI/histórico; S3 workflow não é scheduler. | +| M25 — persistência de processo | P: R8 | Distinguir cliente reconectado de daemon novo e attachment tmux verificado. | S1 mostrar capacidade real; persistência após crash exige novo ADR. | +| M26 — conversa | P metadados: R8; resume A | Adapter persiste e valida identidade nativa; retoma a conversa escolhida. | Nunca reconstruir conversa a partir do PID ou de replay PTY. | +| M27, M28 — ambientes | A: R3/R7 | Resolver execução, cwd, arquivo e provisionamento no mesmo host; reconexão não duplica agente. | S1 ambiente/override; SSH/Docker/custom usam argv e transporte definido em ADR. | +| M29 — arquivo | A: R3 | Listar/criar/mover/remover sob raiz registrada, tratar nomes Unix e links sem escapar do escopo. | S1 árvore; S3 reutiliza segurança de I/O. | +| M30 — editor | A: R3 | Read/write em disco com limite de tamanho e revisão esperada; mudança externa gera conflito. | S1 buffers/edição; proteger conteúdo não salvo. | +| M31 — busca/tabs | A: R3/R4 | Busca cancelável com paginação estável; persistência de tabs/defaults. | S1 abre arquivo/linha; definir indexação e limites após file service. | +| M32 — Git local | P: R6 | Stage/unstage/commit e descarte revisado no repositório derivado do alvo. | S1 diff real; descarte precisa prova de estado, não um booleano force. | +| M33 — Git remoto/histórico | A: R6 | Git argv real com exclusão mútua, cancelamento e erros acionáveis. | S1 opções/history; credenciais continuam com Git do usuário. | +| M34 — floors | P infraestrutura: R2 | Primeiro session.create/start isolado pelo socket; depois floor com várias sessões e cwd compartilhado correto. | S1 canvas por floor; worktree por sessão não equivale a floor. | +| M35 — landing/remoção | P remoção: R2; landing A | Ligar preparo/token/remove e depois preview de landing vinculado a refs e estado atuais. | S1 confirmação; manter branch/diretório diante de conflito. | +| M36 — hooks | A: R3/R4 | Comandos ordenados com contexto, prazo, cancelamento e histórico via SessionManager. | Novo ADR de jobs; setup/teardown não abrem executor paralelo. | +| M37, M38, M39 — portais | A: R3 | Domínio de autorização e alvos de automação sobre bridge real, com revogação. | S1/Tauri webview e suporte Linux/macOS; adapter deve anunciar capacidades observadas. | +| M40 — dispositivos | A/L: R3/R12 | Sessão de dispositivo e operações verificadas no SDK local. | Android nos dois hosts; iOS local exige macOS/Xcode. Não chamar indisponibilidade de paridade. | +| M41, M42 — Wire | A: R3 | Transporte remoto autenticado reutiliza domínio; pareamento/revogação e feed recuperável. | Novo ADR e S1 cliente; socket local não prova protocolo Wire compatível. | +| M43 — Ombro | A/L: R3 | Decisão explícita sobre inferência local sem transformar o coordenador em proxy de agente. | Alternativa Linux e viabilidade medidas; badge de status não equivale a resumo. | +| M44, M45 — recuperação/plataforma | P: R4/R12 | Restaurar composição, notas e anexos reais com manifest/revisão; recuperar corrupção sem apagar dados. | S1 preview/restore, bridge de suspensão por plataforma. | +| M47 — agentes adicionais | P: R7 | Capacidade por versão/adaptador, envio/provisionamento/resume testados individualmente. | S1 exibe somente presets detectados e comportamentos suportados. | +| M48 — atualização | P build: R12 | Pacote atualizado preserva DB, ownership do daemon e identidade de conversa. | S1/Tauri/release e smoke Linux/macOS. | +| X01 — projetos/organização | P: R5 | Preservar pastas não Git e descoberta explícita; pin/importação persistentes. | S3 organização/descoberta, S1 navegação. | +| X02 — sessões gerais | P PTY: R1 | Objetivo/anexos/opções com destino verificável; sessão geral sem projeto fictício. | ADR de identidade/scope e S1 composer; S3 draft/contexto. | +| X03 — continuidade/fork | P: R8 | Fork/resume nativo quando suportado; shell vinculado com lifecycle independente. | S2 adapters/floor; S1 escolha explícita. | +| X04 — atividade/apresentação | P: R8/R9 | Atenção separada de processo/workflow, sem encerrar ao ocultar nó. | S1 grade/minimapa/busca. | +| X05 — preferências/diagnóstico | P: R4/R7/R12 | Preferências revisadas e diagnóstico sanitizado do fluxo real. | S3 regras; S1 UI; arquivos de credenciais não entram no editor. | +| X06 — regras/skills | A: R3 | Descoberta conhecida e leitura segura com origem/escopo; edição usa file service único. | S3 knowledge, S2 contratos compartilhados. | +| X07 — workflow/arquivo/prompts | A/P: R1/R6 | Metadados de workflow separados do processo; prompts/contexto persistem com revisão. | S3 responsável; S2 cleanup e métricas nativas, nunca custo inferido de PTY. | +| X08 — opções dos agentes | P: R7 | Registry consolidado e controles somente quando adapter comprova suporte. | S1 presets e opções. | +| X09 — contexto | A: R3/R4 | Knowledge global/projeto com origem, revisão, referências e busca/exportação. | S3 serviço/componentes; S2 migração/IPC; S1 montagem no canvas. | +| X10 — MCP/compartilhamento | A: R3 | Expor apenas contexto local autorizado; envio explícito fora da máquina em contrato próprio. | S3 ferramentas; S2 autorização/transporte; conector Portal continua opcional. | + +## Sequência de incrementos verticais + +1. **M14/M34/M35, base de X03:** separar preparação de criação e início na + saga; ligar `new_worktree`, snapshot/listagem e remoção no socket; executar + processo curto somente após `session.start`, incluindo subdiretórios, + concorrência, compensação, restart e token invalidado por edição externa. + S1 consome as respostas e comprova o fluxo no desktop. +2. **M29/M30 e base de M07/X06:** serviço de arquivos local compartilhado, + inicialmente list/read/write em alvo registrado e revisão de conteúdo. + Editor S1 lê, modifica e salva arquivo real; um segundo escritor produz + conflito visível, sem sobrescrita. S3 reutiliza o leitor seguro para regras. +3. **M04/M07/M20/M44 e base de X09:** workspace/documento canvas revisados, + nota Markdown em arquivo real e referências estáveis. S1 importa o documento + local existente uma única vez, confirma persistência e só depois muda a + fonte principal. Restart/reconnect recuperam nota, composição e vínculos. + S3 entrega knowledge em migração reservada, com refresh/eventos no mesmo IPC. +4. **M14/M15/M17/M19/M21/M25/M26/M47/X02/X03/X08:** registry único, + capacidades por adapter, roles, draft/anexo e resume. Em seguida autorização + de grafo e cliente CLI: duas sessões reais trocam pedido/resposta; revogação, + timeout e restart têm resultado explícito. Não confundir resume de conversa + com reanexar processo tmux; ambos precisam de aceitação própria. +5. **M32/M33/M34/M35/M36:** Git de escrita, floor compartilhado, landing e + hooks. Provar várias sessões no mesmo floor e terminais da ground floor + preservados; hook é um job sob SessionManager, com política documentada + para erro/timeout/teardown. Confirmar destruição com estado atualizado. +6. **M09/M11/M12/M23/M24/M44/M45:** fichários, preferências, exportação própria, + rotinas duráveis e snapshots restauráveis. Testar duas gravações concorrentes, + interrupção durante importação/backup e scheduler reiniciado antes/depois + de registrar disparo. Histórico registra skipped/failed, não conclusão falsa. +7. **M27/M28/M37–M42:** ambientes reais antes da automação remota: SSH/Docker/ + custom, arquivo/anexo no ambiente alvo e persistência tmux. Depois bridge + de portais/dispositivos e transporte remoto autorizado, cada um em ADR e + incremento próprio com capacidades por plataforma. +8. **M43/M45/M48 e aceitação transversal:** decisão sobre assistente local, + suspensão e atualização. Provar pacotes em Linux/macOS; reunir evidência + S1/S3 para os IDs ainda parciais. Prioridade posterior não remove requisito. + +Cada passo contém vários commits pequenos. Contrato só entra no catálogo +junto de handler utilizável e teste pelo socket; a sequência não autoriza +publicar stubs para reservar nomes. + +## Decisões necessárias antes de ampliar fronteiras + +| Tema do ADR futuro | Decisão que precisa ficar concreta | Alternativas e consequência | +| --- | --- | --- | +| Arquivos e revisão de documentos | Raiz/identidade autorizadas, encoding de nomes Unix, limits, revisões de conteúdo e escrita atômica. | Path relativo UTF-8 é menor, mas exclui nomes válidos; ID opaco/bytes preserva identidade. Não reutilizar `GitRelativePath` como path genérico. Definir symlinks e proteção contra troca de diretório durante a operação. | +| Workspace, floor e canvas | Project continua raiz registrada; workspace/floor são identidades próprias, com revisão de documento e vínculo de várias sessões ao isolamento. | Documento único facilita evolução S1, mas pode conflitar inteiro; tabelas por nó reduzem conflito e aumentam migrações. Escolha deve preservar migração do canvas existente e não duplicar truth em localStorage. | +| Notas e anexos | Ownership de arquivo gerenciado/externo, journal para SQLite+filesystem e recuperação. | SQLite de metadados + Markdown real preserva interoperabilidade, mas exige compensação/reconciliação; não esconder nota só em JSON de canvas. | +| Jobs, hooks e rotinas | CommandSpec/SessionManager como execução única, modelo durável de run, idempotência, cancellation e teardown. | Reusar sessão como job simplifica ownership; job explícito separa histórico/TTY. Ambos exigem um único executor e distinção prompt/programa. | +| Continuidade e ambientes | Resume por adapter versus attachment tmux, identidade de processo por ambiente e segredo/transporte. | Nova conversa não equivale a resume; PID antigo nunca prova ownership. Persistência opcional deve ter fallback explícito sem afirmar retomada. | +| Grafo/CLI, portais e Wire | Principais, autorização/revogação por recurso, correlação e transporte remoto separado do IPC local. | Grafo visual pode referenciar relações funcionais, mas estilos não concedem acesso. Não anunciar compatibilidade Wire sem contrato externo testado. | +| Inferência local e plataformas | Preservar coordenador local-first, alternativa Linux e política de capacidade por hardware. | Integrar inferência altera fronteira de produto; requer decisão registrada antes do código. | + +Os números de migração e ADR são alocados por S2 no momento de integrar, +considerando commits S3. A matriz central continua responsável por paridade +global; este recorte não conclui o goal de backend. From 910ed7dec140fc56e0e6896e46c05cfaa7a942db Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sat, 5 Sep 2026 21:36:15 -0300 Subject: [PATCH 07/12] docs(xirp): record local scope and integration contracts --- docs/adr/0005-local-knowledge.md | 74 ++++++++++++++++++++++++++ docs/codex/xirp-context-report.md | 37 +++++++++++++ docs/codex/xirp-integration-request.md | 25 +++++++++ docs/xirp-local-parity.md | 36 +++++++++++++ 4 files changed, 172 insertions(+) create mode 100644 docs/adr/0005-local-knowledge.md create mode 100644 docs/codex/xirp-context-report.md create mode 100644 docs/codex/xirp-integration-request.md create mode 100644 docs/xirp-local-parity.md diff --git a/docs/adr/0005-local-knowledge.md b/docs/adr/0005-local-knowledge.md new file mode 100644 index 0000000..abdea73 --- /dev/null +++ b/docs/adr/0005-local-knowledge.md @@ -0,0 +1,74 @@ +# ADR 0005: Local prompts, reusable context, and organization + +Status: proposed; shared contract and migration registration await S2 agreement. + +## Context + +CLI Master needs global/project prompts, reusable local context, and work +organization without assuming ownership of coding agents. The XIRP reference +separates optional Portal workspaces from local project/terminal operations. +The canvas remains the primary UI. Linux and macOS remain first-class. + +## Decision + +`knowledge` modules hold explicit user-authored saved prompts and context +records. Core types are pure. Storage uses the existing SQLite database; +daemon handlers expose the agreed versioned wire contract. No additional +Tauri client, file service, process executor, or database is introduced. + +A record has a UUIDv7 identity, a `prompt` or `context` kind, a nullable project +ID (null means global), title/body, monotonically increasing integer revision, +and epoch-millisecond timestamps. Project queries include project and global +records; global queries include only global records. Project deletion must +have a documented foreign-key policy, while archive preserves all records. + +Creation omits ID and expected revision. Updates/deletes supply both identity +and the last observed revision. A concurrent edit fails with a stable conflict +error and preserves the editor draft. Save-as-copy is explicit. Input and list +response sizes are bounded to fit the daemon's transport limits. Content must +not appear in Debug, error details, or logs. + +Selecting a saved prompt or context inserts editable text in the composer. +Only a separate explicit runtime action delivers it to an agent. Saving text +never writes terminal input. Reusing context is not restoring a vendor +conversation and does not persist a terminal transcript. The initial objective +must eventually be delivered through SessionManager/verified adapter options; +a stored text field alone is not completion of that requirement. + +Project/session pin/archive and session workflow are organization metadata. +They neither replace process status nor signal a process. Workflow states are +`backlog`, `in_progress`, `in_review`, `blocked`, and `done`; archive is a +separate flag. The runtime owner coordinates their shared types/storage. + +Rule/skill discovery inventories explicitly supported locations with origin, +format, scope, and precedence explanations. It reuses the runtime owner's +file-access boundary; discovered content is data and is never executed. +Credential paths are excluded. Symlinks, external changes, size/count limits, +encoding, and edit conflicts must have explicit behavior and real filesystem +tests. Reference support for symlinked skill directories is a parity item, +not permission to traverse arbitrary links or read unrelated home files. + +Agent-specific resume/fork/options require verified adapter capabilities. +Attention needs explicit trustworthy events. Token/cost usage needs native +records with provenance; unknown data is unavailable, never inferred from +terminal byte counts, silence, or process duration. + +## Integration and dependencies + +S2 owns shared Rust wire registration, catalog mirrors, migrations, daemon +composition/dispatch, runtime/adapters, and the generic file service. S3 sends +small contracts before editing those boundaries. S1 owns mounting the isolated +components in the canvas/palette and composer draft insertion. Working +components and green unit tests do not prove their integrated application flow. + +Portal authentication, shared workspaces, MCP exposure and transcript sharing +remain separate capabilities requiring real contracts/account verification. +Local context is a CLI Master extension and is not branded as Portal support. + +## Verification + +Contract tests reject malformed inputs. Persistence tests use real SQLite +files, reopen, competing connections, project scope, and FK behavior. Socket +roundtrips prove handlers and effects. Frontend tests use typed callbacks/the +project IPC client. Canvas integration, Linux/macOS CI and runtime evidence +are tracked separately from isolated component tests. diff --git a/docs/codex/xirp-context-report.md b/docs/codex/xirp-context-report.md new file mode 100644 index 0000000..2fe3b56 --- /dev/null +++ b/docs/codex/xirp-context-report.md @@ -0,0 +1,37 @@ +# XIRP local workflows report + +Branch: `feat/xirp-local-workflows`. +Worktree: `/Users/eguimacs/cli-master-xirp`. +Base: `0ac8dd7`, fetched from `origin/refactor/canvas-only-shell` on 2026-09-05. + +## Scope and evidence + +The source/acceptance inventory is [xirp-local-parity.md](../xirp-local-parity.md). +The architecture proposal is [ADR 0005](../adr/0005-local-knowledge.md). +The full user request remains open; documentation and isolated components are +intermediate deliverables. + +## Coordination + +A concrete proposal was placed in `docs/codex/xirp-integration-request.md` in +S1, S2 and S3 worktrees. S2 acknowledgment is pending for shared contracts, +module registrations, migration allocation and dispatch. S1 acknowledgment is +pending for canvas mounting and composer insertion. No shared code has been +changed by S3 at this point. + +Baseline drift reported to owners: active daemon dispatch is `server.rs`; +`client.rs` is uncompiled. Frontend currently has `types.ts`/`schema.ts` rather +than the mirrors named in AGENTS. Runtime registry/adapters need consolidation +by S2; S3 will not introduce another registry or Tauri client. + +## Work in progress + +- Pure validated knowledge types and contract tests (new module only). +- Isolated saved prompt/context editor/picker with typed callbacks and tests. +- Persisted handlers and integration follow the agreed shared boundaries. + +## Commits and checks + +Initial documentation commit: source review completed; `git diff --check`. +Implementation tests and runtime/platform evidence will be recorded here as +executed. No Linux/macOS runtime parity is claimed yet. diff --git a/docs/codex/xirp-integration-request.md b/docs/codex/xirp-integration-request.md new file mode 100644 index 0000000..0522dbf --- /dev/null +++ b/docs/codex/xirp-integration-request.md @@ -0,0 +1,25 @@ +# XIRP S3 integration request — 2026-09-05 + +S3 is active in `/Users/eguimacs/cli-master-xirp`, branch `feat/xirp-local-workflows`, based on latest `origin/refactor/canvas-only-shell` (`0ac8dd7`). The user explicitly requested coordination before shared changes. This file is a coordination message, not part of your commit unless useful. + +## S2 runtime / shared-contract owner + +Please reply in `/Users/eguimacs/cli-master-xirp/docs/codex/xirp-coordination-reply.md` or append below in this copy. I will inspect both main and runtime copies. + +Proposed S3 ownership: new `knowledge/` modules under core/storage/daemon; isolated React components under `app/features/knowledge/`; domain tests and XIRP report. No parallel process executor, file service, database, or Tauri client. + +First vertical increment: global/project saved prompts (UUIDv7, title/body/projectId nullable, created/updated epoch-ms, integer revision) and reusable local context documents (same scope/revision plus kind). Explicit insert creates composer draft, never hidden send. Suggest `knowledge.list`, `knowledge.save`, `knowledge.delete` with tagged kind `prompt|context`; optimistic revision on update/delete; `knowledge.updated` event for refresh. Stable existing `invalid_input`/not_found + agreed conflict error. Storage tables in current SQLite with project FK; no runtime side effects. + +Next: `knowledge.discover`/`knowledge.read` for explicitly known rule/skill paths, bounded UTF-8/no credentials/no symlink traversal; edit via your file service once available (no duplicate generic file service). Please advise your file contract/reusable safe reader. + +Organization metadata: propose separate project/session organization records for pinned/archived, session workflow (backlog/in_progress/in_review/blocked/done) separate from process status, initial objective + explicit local context references. Do you own these fields as planned? Please reserve/implement contracts or delegate an agreed narrow patch. Initial objective execution must use SessionManager/adapters; S3 can own drafting/context composition. + +Please confirm migration allocation, shared lib.rs registration, wire/mirrors/dispatch ownership. Can you delegate a small additive S3 patch to these files after review of this proposal, or supply a commit? No shared files have been changed by S3. + +## S1 canvas owner + +Will deliver isolated components with typed callbacks for saved prompts/context library, rules/skills inspector, and organization controls. Please identify preferred integration surface (canvas contextual panel/toolbar/palette) and coordinate real mounting/composer insertion. Your AppShell/canvas/global styles remain yours. S3 will publish commits and integration API in `docs/codex/xirp-context-report.md`; main canvas remains Maestri. + +## Responses + +Pending owner acknowledgment. diff --git a/docs/xirp-local-parity.md b/docs/xirp-local-parity.md new file mode 100644 index 0000000..4e56b21 --- /dev/null +++ b/docs/xirp-local-parity.md @@ -0,0 +1,36 @@ +# XIRP local parity + +Sources checked 2026-09-05 against XIRP changelog through v0.25.0 (2026-09-03). +Baseline: `0ac8dd7`, `origin/refactor/canvas-only-shell`. +This matrix covers the user's local XIRP request. It complements the main +session's `docs/maestri-xirp-parity.md`; Portal product features are not silently +added to this implementation's scope. + +| ID | Requirement and source | Owner / integration | Evidence / remaining work | +| --- | --- | --- | --- | +| X01 | Local project pin, rename, remove registration, non-Git folders, discover child repositories. [Projects](https://backstage.spotify.com/docs/xirp/projects) | S2 project base, S3 organization UI, S1 canvas | Baseline registers Git projects; pin/archive metadata, non-Git support and child discovery pending. Project archive is a user-requested extension. | +| X02 | Initial objective delivered to agent, attachments, checkout/worktree and agent-specific options. [Sessions](https://backstage.spotify.com/docs/xirp/sessions) | S2 execution/contracts, S3 draft/context selection, S1 composer | Goal delivery requires verified runtime path, not metadata only. Attachments/options pending runtime contract. | +| X03 | Resume/fork, agent switch preserving supported history, linked shell. [Sessions](https://backstage.spotify.com/docs/xirp/sessions), [Changelog](https://backstage.spotify.com/docs/xirp/changelog) | S2 adapters/runtime, S1 canvas | Current adapter capabilities do not expose verified conversation IDs, fork or usage. Await trusted native contracts and runtime integration. Local context reuse is a separate feature. | +| X04 | Working/idle/needs-input attention separate from process lifecycle. [Sessions](https://backstage.spotify.com/docs/xirp/sessions) | S2 event source, S3 indicators, S1 canvas | PTY silence is not proof of approval/input need. Explicit hooks or native signals required. | +| X05 | Local preferences, shortcuts, supported native settings, diagnostics. [Settings](https://backstage.spotify.com/docs/xirp/settings) | S2 settings/runtime, S3 rules/skills, S1 UI | Existing diagnostics/custom-agent creation are baseline only; do not expose unsupported options. Credentials excluded from editors. | +| X06 | Global/project rules and skills, including symlinked skill directories. [Projects](https://backstage.spotify.com/docs/xirp/projects), [Changelog](https://backstage.spotify.com/docs/xirp/changelog) | S3 discovery, S2 file boundary | Need verified location inventory and safe bounded reads with origins/scopes; docs do not enumerate complete formats or precedence. Symlink policy and editing depend on file service. | +| X07a | Saved global/project prompts, searchable picker and insertion. [Changelog v0.19.1](https://backstage.spotify.com/docs/xirp/changelog) | S3 knowledge, S2 shared registration, S1 canvas insertion | Isolated module implementation underway; persistence, socket, UI integration and cross-platform evidence pending. | +| X07b | Session archive and workflow: backlog/in_progress/in_review/blocked/done. [Changelog](https://backstage.spotify.com/docs/xirp/changelog) | S2 organization metadata, S3 controls, S1 filtering | Workflow must not mutate lifecycle. Session pin is a user-requested extension. | +| X07c | Tokens/spend per session/day. [Changelog v0.18.0](https://backstage.spotify.com/docs/xirp/changelog) | S2 verified native data, S3 presentation | Source documents feature existence, not a collector/format/pricing algorithm. Currently unavailable, not zero. No proxy or PTY-based estimates. | +| X07d | PR review, Doctor, cleanup. [Changelog](https://backstage.spotify.com/docs/xirp/changelog) | S2 runtime/Git, S1 UI | Existing Git status/diagnostics/removal-token safety are baseline; review and expanded cleanup require their real contracts. | +| X08 | Agent-specific options, including Cursor. [Changelog](https://backstage.spotify.com/docs/xirp/changelog) | S2 adapter registry, S1 UI | No flags invented from agent display name; support requires installed-version tests and authoritative CLI sources. | +| X09-local | Explicit reusable local context between sessions (user request). | S3 knowledge, S1 composer, S2 delivery | Local text records and explicit draft insertion planned. No transcript capture, autonomous summarization, or Portal connection implied. | +| X09-Portal | Shared workspaces, members, catalog links, resources, decisions/wiki. [Workspaces](https://backstage.spotify.com/docs/xirp/workspaces) | Separate authenticated connector | Portal-dependent; no account/API verification supplied. Not implemented or simulated. | +| X10 | Workspace context via MCP, manual transcript sharing, configured external integrations. [Workspaces](https://backstage.spotify.com/docs/xirp/workspaces) | Separate authenticated connector + S2 transport | Requires real account, scopes/API and explicit outbound preview. No silent publishing, account linking or telemetry. | + +The [XIRP overview](https://backstage.spotify.com/docs/xirp) confirms local +projects, terminals, files, rules, skills and worktrees can operate without +Portal. XIRP's macOS platform statement does not replace CLI Master's Linux +requirement. A macOS test does not count as Linux verification. + +## Completion evidence required + +Each implemented row must link its commit, relevant automated tests, persistent +or filesystem effect, real socket behavior and canvas consumption. Portable +source code alone does not demonstrate execution on both supported platforms. +Unknown capabilities remain open dependencies rather than claimed parity. From 1a74bbb2baddb1ca5decd473b849fe812f08d0c1 Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sat, 5 Sep 2026 21:50:20 -0300 Subject: [PATCH 08/12] feat(knowledge): persist scoped prompts and context over IPC --- apps/desktop/src/ipc/client.test.ts | 31 + apps/desktop/src/ipc/client.ts | 17 + apps/desktop/src/ipc/domain.ts | 45 ++ apps/desktop/src/ipc/knowledge-schema.test.ts | 39 ++ apps/desktop/src/ipc/knowledge-schema.ts | 64 +++ apps/desktop/src/ipc/methods.ts | 5 +- apps/desktop/src/test/mockIpc.ts | 6 + crates/core/src/knowledge/mod.rs | 530 ++++++++++++++++++ crates/core/src/lib.rs | 1 + crates/core/src/wire/method.rs | 10 + crates/core/src/wire/mod.rs | 6 + crates/core/tests/knowledge_catalog.rs | 19 + crates/core/tests/knowledge_contract.rs | 271 +++++++++ crates/daemon/src/knowledge/mod.rs | 68 +++ crates/daemon/src/lib.rs | 1 + crates/daemon/src/server.rs | 14 + crates/daemon/tests/knowledge_ipc.rs | 248 ++++++++ .../migrations/0004_knowledge_documents.sql | 29 + crates/storage/src/durability_tests.rs | 1 + crates/storage/src/knowledge/mod.rs | 402 +++++++++++++ crates/storage/src/lib.rs | 4 +- crates/storage/src/migrate.rs | 21 + crates/storage/tests/knowledge.rs | 509 +++++++++++++++++ docs/adr/0005-local-knowledge.md | 3 +- docs/codex/xirp-context-report.md | 100 +++- docs/codex/xirp-coordination-reply.md | 44 ++ docs/codex/xirp-integration-request.md | 21 + docs/xirp-local-parity.md | 6 +- protocol/catalog.json | 5 +- protocol/fixtures/knowledge-page.json | 25 + 30 files changed, 2515 insertions(+), 30 deletions(-) create mode 100644 apps/desktop/src/ipc/knowledge-schema.test.ts create mode 100644 apps/desktop/src/ipc/knowledge-schema.ts create mode 100644 crates/core/src/knowledge/mod.rs create mode 100644 crates/core/tests/knowledge_catalog.rs create mode 100644 crates/core/tests/knowledge_contract.rs create mode 100644 crates/daemon/src/knowledge/mod.rs create mode 100644 crates/daemon/tests/knowledge_ipc.rs create mode 100644 crates/storage/migrations/0004_knowledge_documents.sql create mode 100644 crates/storage/src/knowledge/mod.rs create mode 100644 crates/storage/tests/knowledge.rs create mode 100644 docs/codex/xirp-coordination-reply.md create mode 100644 protocol/fixtures/knowledge-page.json diff --git a/apps/desktop/src/ipc/client.test.ts b/apps/desktop/src/ipc/client.test.ts index f487293..be45fab 100644 --- a/apps/desktop/src/ipc/client.test.ts +++ b/apps/desktop/src/ipc/client.test.ts @@ -379,3 +379,34 @@ function requestFromArgs(value: unknown): RequestEnvelope { } return value.request as RequestEnvelope; } + +// Knowledge uses the same versioned request transport as all domain methods. +describe("knowledge IPC transport", () => { + it("keeps revisions and scope and surfaces conflicts without issuing session writes", async () => { + const entry = { + id: "0198f000-0000-7000-8000-000000000010", kind: "prompt", projectId: null, + title: "Review", body: "Review the selected changes", revision: 1, + createdAtMs: 100, updatedAtMs: 100, + }; + transport.invoke.mockReset(); + installWireResponder({ + "knowledge.list": { entries: [entry], nextCursor: null }, + "knowledge.save": entry, + "knowledge.delete": {}, + }); + const client = createTauriIpcClient(); + expect(await client.listKnowledge({ projectId: null, query: "review" })).toEqual({ entries: [entry], nextCursor: null }); + const input = { kind: "prompt" as const, projectId: null, title: entry.title, body: entry.body }; + expect(await client.saveKnowledge(input)).toEqual(entry); + await client.deleteKnowledge({ id: entry.id, expectedRevision: 1 }); + expect(capturedRequests().map((request) => request.method)).toEqual(["knowledge.list", "knowledge.save", "knowledge.delete"]); + expect(capturedRequests()[1].payload).toEqual(input); + expect(capturedRequests()[2].payload).toEqual({ id: entry.id, expectedRevision: 1 }); + + transport.invoke.mockImplementation(async (_command, { request }) => ({ + kind: "response", version: 1, requestId: request.requestId, status: "error", + error: { code: "knowledge_conflict", message: "This entry changed. Reload or save a copy." }, + })); + await expect(client.saveKnowledge({ ...input, id: entry.id, expectedRevision: 1 })).rejects.toMatchObject({ code: "knowledge_conflict" }); + }); +}); diff --git a/apps/desktop/src/ipc/client.ts b/apps/desktop/src/ipc/client.ts index e30335d..62b2cdb 100644 --- a/apps/desktop/src/ipc/client.ts +++ b/apps/desktop/src/ipc/client.ts @@ -1,3 +1,5 @@ +import { decodeKnowledgeEntry, decodeKnowledgePage } from "./knowledge-schema"; +import type { KnowledgeEntry, KnowledgeListRequest, KnowledgeListResponse, KnowledgeSaveRequest, KnowledgeDeleteRequest } from "./domain"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { openPath } from "@tauri-apps/plugin-opener"; @@ -60,6 +62,9 @@ export interface TerminalResizeInput { /** The sole frontend interface to daemon and native desktop capabilities. */ export interface IpcClient { readonly platform: AppPlatform; + listKnowledge(input: KnowledgeListRequest): Promise; + saveKnowledge(input: KnowledgeSaveRequest): Promise; + deleteKnowledge(input: KnowledgeDeleteRequest): Promise; initialize(): Promise; subscribe( handler: IpcEventHandler, @@ -136,6 +141,18 @@ export function toIpcError(error: unknown): IpcError { class TauriIpcClient implements IpcClient { readonly platform = detectPlatform(); + async listKnowledge(input: KnowledgeListRequest): Promise { + return decodeKnowledgePage(await this.request("knowledge.list", input)); + } + + async saveKnowledge(input: KnowledgeSaveRequest): Promise { + return decodeKnowledgeEntry(await this.request("knowledge.save", input)); + } + + async deleteKnowledge(input: KnowledgeDeleteRequest): Promise { + await this.request("knowledge.delete", input); + } + async initialize(): Promise { const hello = decodeHello(await this.request("system.hello", {})); if (hello.protocolVersion !== 1) { diff --git a/apps/desktop/src/ipc/domain.ts b/apps/desktop/src/ipc/domain.ts index 7cb4aa6..d53c607 100644 --- a/apps/desktop/src/ipc/domain.ts +++ b/apps/desktop/src/ipc/domain.ts @@ -10,3 +10,48 @@ export interface WorktreeListRequest { export interface WorktreeListResponse { readonly worktrees: readonly Worktree[]; } + +/** User-authored local text; no Portal connection or transcript capture. */ +export type KnowledgeKind = "prompt" | "context"; + +/** Mirrors the validated Rust knowledge entry. */ +export interface KnowledgeEntry { + readonly id: string; + readonly kind: KnowledgeKind; + readonly projectId: string | null; + readonly title: string; + readonly body: string; + readonly revision: number; + readonly createdAtMs: number; + readonly updatedAtMs: number; +} + +/** Null/omitted project selects globals; a project includes its global entries. */ +export interface KnowledgeListRequest { + readonly projectId?: string | null; + readonly kind?: KnowledgeKind; + readonly cursor?: string; + readonly query?: string; +} + +/** Bodies are included in bounded, cursor-based result pages. */ +export interface KnowledgeListResponse { + readonly entries: readonly KnowledgeEntry[]; + readonly nextCursor: string | null; +} + +/** A create omits id/revision; an update requires both. */ +export interface KnowledgeSaveRequest { + readonly id?: string; + readonly expectedRevision?: number; + readonly kind: KnowledgeKind; + readonly projectId: string | null; + readonly title: string; + readonly body: string; +} + +/** A stale delete leaves the existing text intact. */ +export interface KnowledgeDeleteRequest { + readonly id: string; + readonly expectedRevision: number; +} diff --git a/apps/desktop/src/ipc/knowledge-schema.test.ts b/apps/desktop/src/ipc/knowledge-schema.test.ts new file mode 100644 index 0000000..3004c9f --- /dev/null +++ b/apps/desktop/src/ipc/knowledge-schema.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import catalog from "../../../../protocol/catalog.json"; +import fixture from "../../../../protocol/fixtures/knowledge-page.json"; +import { IPC_METHODS } from "./methods"; +import { decodeKnowledgeEntry, decodeKnowledgePage } from "./knowledge-schema"; + +describe("knowledge Rust/TypeScript contract", () => { + it("decodes the same fixture as the Rust contract, including explicit nulls", () => { + expect(decodeKnowledgePage(fixture)).toEqual(fixture); + expect(fixture.nextCursor).toBeNull(); + expect(fixture.entries[0].projectId).toBeNull(); + }); + + it("mirrors every public method", () => { + expect(IPC_METHODS).toEqual(catalog.methods); + }); + + it("rejects unknown variants without echoing text", () => { + expect(() => decodeKnowledgeEntry({ ...fixture.entries[0], kind: "private content" })).toThrow("Invalid knowledge kind"); + }); + + it("rejects unsafe revisions, text bounds and timestamps", () => { + for (const change of [ + { revision: 0 }, { revision: Number.MAX_SAFE_INTEGER + 1 }, + { updatedAtMs: -1 }, { body: "\0" }, { title: "é".repeat(129) }, + { body: "a".repeat(65_537) }, + ]) expect(() => decodeKnowledgeEntry({ ...fixture.entries[0], ...change })).toThrow(); + }); + + it("shares Rust Unicode whitespace validation", () => { + expect(decodeKnowledgeEntry({ ...fixture.entries[0], body: "\uFEFF" }).body).toBe("\uFEFF"); + expect(() => decodeKnowledgeEntry({ ...fixture.entries[0], body: "\u0085" })).toThrow(); + }); + + it("rejects a cursor that cannot advance and repeated rows", () => { + expect(() => decodeKnowledgePage({ entries: [], nextCursor: fixture.entries[0].id })).toThrow(); + expect(() => decodeKnowledgePage({ entries: [fixture.entries[0], fixture.entries[0]], nextCursor: null })).toThrow(); + }); +}); diff --git a/apps/desktop/src/ipc/knowledge-schema.ts b/apps/desktop/src/ipc/knowledge-schema.ts new file mode 100644 index 0000000..5b3d890 --- /dev/null +++ b/apps/desktop/src/ipc/knowledge-schema.ts @@ -0,0 +1,64 @@ +import { IpcContractError, requireArray, requireRecord, requireString } from "./schema"; +import type { KnowledgeEntry, KnowledgeListResponse } from "./domain"; + +/** Keep constants aligned with core::knowledge validation. */ +export const KNOWLEDGE_TITLE_BYTES = 256; +export const KNOWLEDGE_BODY_BYTES = 65_536; + +function uuid(value: unknown, version7 = false): string { + const text = requireString(value, "knowledge identifier"); + const pattern = version7 + ? /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + : /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + if (!pattern.test(text)) throw new IpcContractError("Invalid knowledge identifier"); + return text; +} + +function integer(value: unknown, minimum: number): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum) { + throw new IpcContractError("Invalid knowledge revision or timestamp"); + } + return value; +} + +function content(value: unknown, maximum: number): string { + const text = requireString(value, "knowledge text"); + if (!/[^\p{White_Space}]/u.test(text) || text.includes("\0") || new TextEncoder().encode(text).length > maximum) { + throw new IpcContractError("Invalid knowledge text length or encoding"); + } + return text; +} + +/** Validate content without including its text in contract failures. */ +export function decodeKnowledgeEntry(value: unknown): KnowledgeEntry { + const row = requireRecord(value, "knowledge entry"); + if (row.kind !== "prompt" && row.kind !== "context") { + throw new IpcContractError("Invalid knowledge kind"); + } + const createdAtMs = integer(row.createdAtMs, 0); + const updatedAtMs = integer(row.updatedAtMs, createdAtMs); + return { + id: uuid(row.id, true), + kind: row.kind, + projectId: row.projectId === null ? null : uuid(row.projectId), + title: content(row.title, KNOWLEDGE_TITLE_BYTES), + body: content(row.body, KNOWLEDGE_BODY_BYTES), + revision: integer(row.revision, 1), + createdAtMs, + updatedAtMs, + }; +} + +/** Enforce page shape, stable ordering and a usable continuation cursor. */ +export function decodeKnowledgePage(value: unknown): KnowledgeListResponse { + const page = requireRecord(value, "knowledge page"); + const entries = requireArray(page.entries, "knowledge entries").map(decodeKnowledgeEntry); + if (entries.length > 50 || entries.some((entry, index) => index > 0 && entry.id <= entries[index - 1].id)) { + throw new IpcContractError("Invalid knowledge page ordering or size"); + } + const nextCursor = page.nextCursor === null ? null : uuid(page.nextCursor, true); + if (nextCursor !== null && nextCursor !== entries[entries.length - 1]?.id) { + throw new IpcContractError("Invalid knowledge page cursor"); + } + return { entries, nextCursor }; +} diff --git a/apps/desktop/src/ipc/methods.ts b/apps/desktop/src/ipc/methods.ts index 7b00a06..530b06e 100644 --- a/apps/desktop/src/ipc/methods.ts +++ b/apps/desktop/src/ipc/methods.ts @@ -28,7 +28,10 @@ export const IPC_METHODS = [ "worktree.list", "worktree.prepare_remove", "worktree.remove", - "diagnostics.get" + "diagnostics.get", + "knowledge.list", + "knowledge.save", + "knowledge.delete" ] as const; export type IpcMethod = (typeof IPC_METHODS)[number]; diff --git a/apps/desktop/src/test/mockIpc.ts b/apps/desktop/src/test/mockIpc.ts index 0b13c8d..c5bdb6a 100644 --- a/apps/desktop/src/test/mockIpc.ts +++ b/apps/desktop/src/test/mockIpc.ts @@ -24,6 +24,9 @@ export interface MockIpcClientOptions { /** An injected IPC fake whose unconfigured application calls fail loudly. */ export interface MockIpcClient extends IpcClient { + readonly listKnowledge: Mock; + readonly saveKnowledge: Mock; + readonly deleteKnowledge: Mock; readonly initialize: Mock; readonly subscribe: Mock; readonly subscribeTerminal: Mock; @@ -146,6 +149,9 @@ export function createMockIpcClient( getDiagnostics: vi.fn( handlers.getDiagnostics ?? (() => rejectUnhandled("getDiagnostics")), ), + listKnowledge: vi.fn(handlers.listKnowledge ?? (() => rejectUnhandled("listKnowledge"))), + saveKnowledge: vi.fn(handlers.saveKnowledge ?? (() => rejectUnhandled("saveKnowledge"))), + deleteKnowledge: vi.fn(handlers.deleteKnowledge ?? (() => rejectUnhandled("deleteKnowledge"))), openPath: vi.fn( handlers.openPath ?? (() => rejectUnhandled("openPath")), ), diff --git a/crates/core/src/knowledge/mod.rs b/crates/core/src/knowledge/mod.rs new file mode 100644 index 0000000..e093754 --- /dev/null +++ b/crates/core/src/knowledge/mod.rs @@ -0,0 +1,530 @@ +//! Pure contracts for saved local prompts and reusable context. +//! +//! Text crosses the local IPC boundary intentionally, but is excluded from +//! diagnostic formatting. This module performs no filesystem or process I/O. + +use std::{error::Error, fmt, str::FromStr}; + +use serde::{Deserialize, Deserializer, Serialize, de}; +use uuid::{Uuid, Variant}; + +use crate::ProjectId; + +/// Maximum saved title length in UTF-8 bytes. +pub const MAX_KNOWLEDGE_TITLE_BYTES: usize = 256; +/// Maximum saved prompt or context length in UTF-8 bytes. +pub const MAX_KNOWLEDGE_BODY_BYTES: usize = 64 * 1_024; +/// Highest revision that JavaScript can represent without rounding. +pub const MAX_KNOWLEDGE_REVISION: u64 = 9_007_199_254_740_991; + +/// Safe validation failure containing only static labels and explanations. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct KnowledgeValidationError { + field: &'static str, + message: &'static str, +} + +impl KnowledgeValidationError { + const fn new(field: &'static str, message: &'static str) -> Self { + Self { field, message } + } + + /// Returns the invalid field's stable label. + #[must_use] + pub const fn field(&self) -> &'static str { + self.field + } + + /// Returns an explanation that never includes submitted content. + #[must_use] + pub const fn message(&self) -> &'static str { + self.message + } +} + +impl fmt::Display for KnowledgeValidationError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{} {}", self.field, self.message) + } +} + +impl Error for KnowledgeValidationError {} + +/// UUID version 7 identifier for one saved prompt or context entry. +#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] +#[serde(transparent)] +pub struct KnowledgeId(Uuid); + +impl KnowledgeId { + /// Generates a time-ordered UUID version 7 identifier. + #[must_use] + pub fn new() -> Self { + Self(Uuid::now_v7()) + } + + /// Validates an existing UUID without changing it. + /// + /// # Errors + /// + /// Returns an error unless the UUID uses version 7 and the RFC variant. + pub fn try_from_uuid(value: Uuid) -> Result { + if value.get_version_num() != 7 || value.get_variant() != Variant::RFC4122 { + return Err(KnowledgeValidationError::new( + "id", + "must be a UUID version 7", + )); + } + Ok(Self(value)) + } + + /// Returns the underlying UUID. + #[must_use] + pub const fn as_uuid(&self) -> &Uuid { + &self.0 + } + + /// Consumes the identifier and returns the underlying UUID. + #[must_use] + pub const fn into_uuid(self) -> Uuid { + self.0 + } +} + +impl Default for KnowledgeId { + fn default() -> Self { + Self::new() + } +} + +impl fmt::Display for KnowledgeId { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + self.0.fmt(formatter) + } +} + +impl FromStr for KnowledgeId { + type Err = KnowledgeValidationError; + + fn from_str(value: &str) -> Result { + let uuid = Uuid::parse_str(value) + .map_err(|_| KnowledgeValidationError::new("id", "must be a UUID version 7"))?; + Self::try_from_uuid(uuid) + } +} + +impl<'de> Deserialize<'de> for KnowledgeId { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer) + .map_err(|_| de::Error::custom("id must be a UUID version 7 string"))?; + value.parse().map_err(de::Error::custom) + } +} + +/// The local purpose of a saved knowledge entry. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum KnowledgeKind { + /// Reusable instructions selected explicitly by a user. + Prompt, + /// Reusable local background information selected explicitly by a user. + Context, +} + +macro_rules! knowledge_text { + ($name:ident, $description:literal, $field:literal, $maximum:ident) => { + #[doc = $description] + #[derive(Clone, Eq, PartialEq, Serialize)] + #[serde(transparent)] + pub struct $name(String); + + impl $name { + /// Validates text while preserving its exact whitespace and content. + /// + /// # Errors + /// + /// Returns an error for blank text, a NUL byte, or an exceeded byte limit. + pub fn try_new(value: impl Into) -> Result { + let value = value.into(); + validate_text($field, &value, $maximum)?; + Ok(Self(value)) + } + + /// Returns validated text for explicit use or local persistence. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Consumes the value and returns its text. + #[must_use] + pub fn into_inner(self) -> String { + self.0 + } + } + + impl fmt::Debug for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct(stringify!($name)) + .field("bytes", &self.0.len()) + .finish_non_exhaustive() + } + } + + impl<'de> Deserialize<'de> for $name { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer) + .map_err(|_| de::Error::custom(concat!($field, " must be text")))?; + Self::try_new(value).map_err(de::Error::custom) + } + } + }; +} + +knowledge_text!( + KnowledgeTitle, + "Non-blank saved title bounded to 256 UTF-8 bytes; diagnostic output is redacted.", + "title", + MAX_KNOWLEDGE_TITLE_BYTES +); +knowledge_text!( + KnowledgeBody, + "Non-blank prompt or context bounded to 64 KiB; diagnostic output is redacted.", + "body", + MAX_KNOWLEDGE_BODY_BYTES +); + +/// One persisted local prompt or context entry. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KnowledgeEntry { + /// Stable identifier generated by the local owner. + pub id: KnowledgeId, + /// Prompt or context classification. + pub kind: KnowledgeKind, + /// Owning project, or no project for a globally reusable entry. + pub project_id: Option, + /// User-facing title, excluded from diagnostic formatting. + pub title: KnowledgeTitle, + /// Explicitly saved user content, excluded from diagnostic formatting. + pub body: KnowledgeBody, + /// Positive revision used for optimistic concurrency. + pub revision: u64, + /// Creation time as Unix epoch milliseconds. + pub created_at_ms: i64, + /// Most recent edit time as Unix epoch milliseconds. + pub updated_at_ms: i64, +} + +impl KnowledgeEntry { + /// Checks invariants for a programmatically assembled entry. + /// + /// # Errors + /// + /// Returns an error for invalid revisions or timestamps outside the ordered, + /// non-negative JavaScript-safe epoch millisecond range. + pub const fn validate(&self) -> Result<(), KnowledgeValidationError> { + match validate_revision("revision", self.revision) { + Ok(()) => (), + Err(error) => return Err(error), + } + if self.created_at_ms < 0 + || self.updated_at_ms < self.created_at_ms + || self.updated_at_ms > 9_007_199_254_740_991 + { + return Err(KnowledgeValidationError::new( + "timestamps", + "must satisfy 0 <= createdAtMs <= updatedAtMs <= 9007199254740991", + )); + } + Ok(()) + } +} + +impl<'de> Deserialize<'de> for KnowledgeEntry { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields, rename_all = "camelCase")] + struct Fields { + id: KnowledgeId, + kind: KnowledgeKind, + project_id: Option, + title: KnowledgeTitle, + body: KnowledgeBody, + revision: u64, + created_at_ms: i64, + updated_at_ms: i64, + } + + let fields = Fields::deserialize(deserializer) + .map_err(|_| de::Error::custom("invalid saved knowledge entry"))?; + let entry = Self { + id: fields.id, + kind: fields.kind, + project_id: fields.project_id, + title: fields.title, + body: fields.body, + revision: fields.revision, + created_at_ms: fields.created_at_ms, + updated_at_ms: fields.updated_at_ms, + }; + entry.validate().map_err(de::Error::custom)?; + Ok(entry) + } +} + +/// Selects global entries, optionally including entries owned by one project. +#[derive(Clone, Default, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KnowledgeListRequest { + /// When absent, list global entries only; when present, include this project. + #[serde(skip_serializing_if = "Option::is_none")] + pub project_id: Option, + /// Optional prompt/context filter applied to both scopes. + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, + /// Exclusive cursor in ascending identifier order. + #[serde(skip_serializing_if = "Option::is_none")] + pub cursor: Option, + /// Literal case-insensitive title/body search, bounded to 256 UTF-8 bytes. + /// + /// Empty text means no search filter. The daemon trims surrounding whitespace. + #[serde(skip_serializing_if = "Option::is_none")] + pub query: Option, +} + +impl KnowledgeListRequest { + /// Checks search limits for programmatic callers. + /// + /// # Errors + /// + /// Returns an error for an oversized or NUL-containing search query. + pub fn validate(&self) -> Result<(), KnowledgeValidationError> { + if let Some(query) = &self.query { + if query.len() > MAX_KNOWLEDGE_TITLE_BYTES { + return Err(KnowledgeValidationError::new( + "query", + "exceeds its UTF-8 byte limit", + )); + } + if query.contains('\0') { + return Err(KnowledgeValidationError::new( + "query", + "must not contain a NUL byte", + )); + } + } + Ok(()) + } +} + +impl fmt::Debug for KnowledgeListRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("KnowledgeListRequest") + .field("project_id", &self.project_id) + .field("kind", &self.kind) + .field("cursor", &self.cursor) + .field("query_bytes", &self.query.as_ref().map(String::len)) + .finish() + } +} + +impl<'de> Deserialize<'de> for KnowledgeListRequest { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields, rename_all = "camelCase")] + struct Fields { + project_id: Option, + kind: Option, + cursor: Option, + query: Option, + } + + let fields = Fields::deserialize(deserializer) + .map_err(|_| de::Error::custom("invalid knowledge list request"))?; + let mut request = Self { + project_id: fields.project_id, + kind: fields.kind, + cursor: fields.cursor, + query: fields.query, + }; + request.validate().map_err(de::Error::custom)?; + request.query = request.query.map(|query| query.trim().to_owned()); + Ok(request) + } +} + +/// Bounded page of local knowledge in ascending identifier order. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct KnowledgeListResponse { + /// Entries selected and packed within the daemon's row and byte limits. + pub entries: Vec, + /// Exclusive cursor for the next page, or null when the result is exhausted. + pub next_cursor: Option, +} + +/// Creates a local entry or replaces its text with an optimistic revision check. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KnowledgeSaveRequest { + /// Absent for creation; identifies the existing entry for an update. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + /// Absent for creation; must equal the persisted revision for an update. + #[serde(skip_serializing_if = "Option::is_none")] + pub expected_revision: Option, + /// Owning project, or global scope when absent. + #[serde(skip_serializing_if = "Option::is_none")] + pub project_id: Option, + /// Explicit prompt/context classification. + pub kind: KnowledgeKind, + /// Validated saved title. + pub title: KnowledgeTitle, + /// Validated saved content. + pub body: KnowledgeBody, +} + +impl KnowledgeSaveRequest { + /// Checks mutation shape and revision bounds for programmatic callers. + /// + /// Persistence must check the expected revision atomically with the write. + /// + /// # Errors + /// + /// Returns an error for a partial id/revision pair or an invalid revision. + pub const fn validate(&self) -> Result<(), KnowledgeValidationError> { + match (self.id, self.expected_revision) { + (None, None) => Ok(()), + (Some(_), Some(revision)) => validate_revision("expectedRevision", revision), + _ => Err(KnowledgeValidationError::new( + "id and expectedRevision", + "must either both be absent for creation or both be present for an update", + )), + } + } +} + +impl<'de> Deserialize<'de> for KnowledgeSaveRequest { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields, rename_all = "camelCase")] + struct Fields { + id: Option, + expected_revision: Option, + project_id: Option, + kind: KnowledgeKind, + title: KnowledgeTitle, + body: KnowledgeBody, + } + + let fields = Fields::deserialize(deserializer) + .map_err(|_| de::Error::custom("invalid knowledge save request"))?; + let request = Self { + id: fields.id, + expected_revision: fields.expected_revision, + project_id: fields.project_id, + kind: fields.kind, + title: fields.title, + body: fields.body, + }; + request.validate().map_err(de::Error::custom)?; + Ok(request) + } +} + +/// Removes one local entry only if its revision still matches the user's copy. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct KnowledgeDeleteRequest { + /// Stable identifier of the entry to remove. + pub id: KnowledgeId, + /// Revision observed when removal was requested. + pub expected_revision: u64, +} + +impl KnowledgeDeleteRequest { + /// Checks revision bounds for programmatic callers. + /// + /// # Errors + /// + /// Returns an error for a zero or JavaScript-unsafe revision. + pub const fn validate(&self) -> Result<(), KnowledgeValidationError> { + validate_revision("expectedRevision", self.expected_revision) + } +} + +impl<'de> Deserialize<'de> for KnowledgeDeleteRequest { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(deny_unknown_fields, rename_all = "camelCase")] + struct Fields { + id: KnowledgeId, + expected_revision: u64, + } + + let fields = Fields::deserialize(deserializer) + .map_err(|_| de::Error::custom("invalid knowledge delete request"))?; + let request = Self { + id: fields.id, + expected_revision: fields.expected_revision, + }; + request.validate().map_err(de::Error::custom)?; + Ok(request) + } +} + +fn validate_text( + field: &'static str, + value: &str, + maximum_bytes: usize, +) -> Result<(), KnowledgeValidationError> { + if value.len() > maximum_bytes { + return Err(KnowledgeValidationError::new( + field, + "exceeds its UTF-8 byte limit", + )); + } + if value.trim().is_empty() { + return Err(KnowledgeValidationError::new(field, "must not be blank")); + } + if value.contains('\0') { + return Err(KnowledgeValidationError::new( + field, + "must not contain a NUL byte", + )); + } + Ok(()) +} + +const fn validate_revision( + field: &'static str, + revision: u64, +) -> Result<(), KnowledgeValidationError> { + if revision == 0 || revision > MAX_KNOWLEDGE_REVISION { + return Err(KnowledgeValidationError::new( + field, + "must be an integer between 1 and 9007199254740991", + )); + } + Ok(()) +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 2fcd66d..2cb3f64 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -9,6 +9,7 @@ mod catalog; mod command; mod error; mod ids; +pub mod knowledge; mod model; mod protocol; mod redact; diff --git a/crates/core/src/wire/method.rs b/crates/core/src/wire/method.rs index 96bffd5..b067015 100644 --- a/crates/core/src/wire/method.rs +++ b/crates/core/src/wire/method.rs @@ -65,6 +65,13 @@ pub const WORKTREE_REMOVE: &str = "worktree.remove"; /// Read a sanitized local diagnostic snapshot. pub const DIAGNOSTICS_GET: &str = "diagnostics.get"; +/// List scoped saved prompts/context in bounded pages. +pub const KNOWLEDGE_LIST: &str = "knowledge.list"; +/// Create or revision-check an explicit saved prompt/context. +pub const KNOWLEDGE_SAVE: &str = "knowledge.save"; +/// Delete a saved prompt/context after checking its revision. +pub const KNOWLEDGE_DELETE: &str = "knowledge.delete"; + /// Every method implemented by the Beta v1 contract. pub const ALL: &[&str] = &[ SYSTEM_HELLO, @@ -96,6 +103,9 @@ pub const ALL: &[&str] = &[ WORKTREE_PREPARE_REMOVE, WORKTREE_REMOVE, DIAGNOSTICS_GET, + KNOWLEDGE_LIST, + KNOWLEDGE_SAVE, + KNOWLEDGE_DELETE, ]; /// Returns whether a dotted method belongs to the Beta v1 contract. diff --git a/crates/core/src/wire/mod.rs b/crates/core/src/wire/mod.rs index 71737a2..a5e6657 100644 --- a/crates/core/src/wire/mod.rs +++ b/crates/core/src/wire/mod.rs @@ -13,6 +13,12 @@ mod request; mod response; mod value; +pub use crate::knowledge::{ + KnowledgeBody, KnowledgeDeleteRequest, KnowledgeEntry, KnowledgeId, KnowledgeKind, + KnowledgeListRequest, KnowledgeListResponse, KnowledgeSaveRequest, KnowledgeTitle, + KnowledgeValidationError, MAX_KNOWLEDGE_BODY_BYTES, MAX_KNOWLEDGE_REVISION, + MAX_KNOWLEDGE_TITLE_BYTES, +}; pub use event::{ AgentChangedEvent, AgentRemovedEvent, DaemonShuttingDownEvent, GitStatusChangedEvent, ProjectChangedEvent, ProjectRemovedEvent, SessionChangedEvent, SessionDeletedEvent, diff --git a/crates/core/tests/knowledge_catalog.rs b/crates/core/tests/knowledge_catalog.rs new file mode 100644 index 0000000..b28cd66 --- /dev/null +++ b/crates/core/tests/knowledge_catalog.rs @@ -0,0 +1,19 @@ +use cli_master_core::wire::{event_name, method}; +use serde_json::{Value, json}; + +#[test] +fn knowledge_operations_share_the_authoritative_catalog() { + let catalog: Value = + serde_json::from_str(include_str!("../../../protocol/catalog.json")).unwrap(); + assert_eq!(catalog["methods"], json!(method::ALL)); + assert_eq!(catalog["events"], json!(event_name::ALL)); + for name in [ + method::KNOWLEDGE_LIST, + method::KNOWLEDGE_SAVE, + method::KNOWLEDGE_DELETE, + ] { + assert!(method::is_supported(name)); + } + // The metadata event is deliberately not advertised until its transport exists. + assert!(!event_name::is_supported("knowledge.updated")); +} diff --git a/crates/core/tests/knowledge_contract.rs b/crates/core/tests/knowledge_contract.rs new file mode 100644 index 0000000..5920876 --- /dev/null +++ b/crates/core/tests/knowledge_contract.rs @@ -0,0 +1,271 @@ +use cli_master_core::{ + ProjectId, + knowledge::{ + KnowledgeBody, KnowledgeDeleteRequest, KnowledgeEntry, KnowledgeId, KnowledgeKind, + KnowledgeListRequest, KnowledgeListResponse, KnowledgeSaveRequest, KnowledgeTitle, + MAX_KNOWLEDGE_BODY_BYTES, MAX_KNOWLEDGE_REVISION, MAX_KNOWLEDGE_TITLE_BYTES, + }, +}; +use serde_json::{Value, json}; +use uuid::Uuid; + +fn create_payload() -> Value { + json!({"kind": "prompt", "title": "Review", "body": " Review this change.\n"}) +} + +fn entry() -> KnowledgeEntry { + KnowledgeEntry { + id: KnowledgeId::new(), + kind: KnowledgeKind::Context, + project_id: Some(ProjectId::new()), + title: KnowledgeTitle::try_new("Architecture").unwrap(), + body: KnowledgeBody::try_new("Keep the process owner in the daemon.\n").unwrap(), + revision: 2, + created_at_ms: 1_778_000_000_000, + updated_at_ms: 1_778_000_000_001, + } +} + +#[test] +fn shared_page_fixture_matches_the_typed_contract_exactly() { + let fixture: Value = serde_json::from_str(include_str!( + "../../../protocol/fixtures/knowledge-page.json" + )) + .unwrap(); + let page: KnowledgeListResponse = serde_json::from_value(fixture.clone()).unwrap(); + assert!(page.entries[0].id < page.entries[1].id); + assert_eq!(page.entries[0].project_id, None); + assert!(page.entries[1].project_id.is_some()); + assert_eq!(page.next_cursor, None); + assert_eq!(serde_json::to_value(page).unwrap(), fixture); +} + +#[test] +fn entry_timestamps_reject_negative_reversed_and_unsafe_values() { + for (created, updated) in [(-1, 0), (2, 1), (0, 9_007_199_254_740_992)] { + let mut invalid = entry(); + invalid.created_at_ms = created; + invalid.updated_at_ms = updated; + assert!(invalid.validate().is_err()); + assert!(serde_json::from_value::(json!(invalid)).is_err()); + } +} + +#[test] +fn identifiers_validate_version_and_variant_without_echoing_input() { + let id = KnowledgeId::new(); + assert_eq!(id.as_uuid().get_version_num(), 7); + assert_eq!(id.to_string().parse::().unwrap(), id); + assert_eq!(KnowledgeId::try_from_uuid(id.into_uuid()).unwrap(), id); + assert_eq!( + serde_json::from_value::(json!(id)).unwrap(), + id + ); + assert_eq!(KnowledgeId::default().as_uuid().get_version_num(), 7); + + for invalid in [ + "secret prompt text", + "00000000-0000-0000-0000-000000000000", + "550e8400-e29b-41d4-a716-446655440000", + "01900000-0000-7000-0000-000000000000", + ] { + let error = invalid.parse::().unwrap_err(); + assert_eq!(error.field(), "id"); + assert!(!error.message().contains(invalid)); + assert!(!error.to_string().contains(invalid)); + assert!(serde_json::from_value::(json!(invalid)).is_err()); + } + assert!(KnowledgeId::try_from_uuid(Uuid::nil()).is_err()); + assert!(serde_json::from_value::(json!(5)).is_err()); +} + +#[test] +fn text_validation_counts_utf8_bytes_preserves_whitespace_and_redacts_debug() { + let title = "á".repeat(MAX_KNOWLEDGE_TITLE_BYTES / 2); + assert_eq!( + KnowledgeTitle::try_new(title.clone()).unwrap().into_inner(), + title + ); + assert!(KnowledgeTitle::try_new(format!("{title}a")).is_err()); + let body = "á".repeat(MAX_KNOWLEDGE_BODY_BYTES / 2); + assert_eq!( + KnowledgeBody::try_new(body.clone()).unwrap().into_inner(), + body + ); + assert!(KnowledgeBody::try_new(format!("{body}a")).is_err()); + + for invalid in ["", " \t\n", "private\0body"] { + assert!(KnowledgeTitle::try_new(invalid).is_err()); + assert!(KnowledgeBody::try_new(invalid).is_err()); + } + let text = " private-content\n"; + let title = KnowledgeTitle::try_new(text).unwrap(); + let body = KnowledgeBody::try_new(text).unwrap(); + assert_eq!(title.as_str(), text); + assert_eq!(body.as_str(), text); + assert!(!format!("{title:?} {body:?}").contains("private-content")); +} + +#[test] +fn create_and_update_require_matching_id_revision_presence() { + let created: KnowledgeSaveRequest = serde_json::from_value(create_payload()).unwrap(); + assert_eq!(created.id, None); + assert_eq!(created.expected_revision, None); + assert_eq!(created.body.as_str(), " Review this change.\n"); + assert_eq!(serde_json::to_value(&created).unwrap(), create_payload()); + + let mut updated = create_payload(); + updated["id"] = json!(KnowledgeId::new()); + assert!(serde_json::from_value::(updated.clone()).is_err()); + updated["expectedRevision"] = json!(1); + let request: KnowledgeSaveRequest = serde_json::from_value(updated.clone()).unwrap(); + assert_eq!(request.expected_revision, Some(1)); + assert_eq!(serde_json::to_value(&request).unwrap(), updated); + updated.as_object_mut().unwrap().remove("id"); + assert!(serde_json::from_value::(updated).is_err()); + + let mut invalid_programmatic = created; + invalid_programmatic.expected_revision = Some(1); + assert!(invalid_programmatic.validate().is_err()); +} + +#[test] +fn every_mutation_rejects_zero_fractional_and_unsafe_revisions() { + for revision in [ + json!(0), + json!(-1), + json!(1.5), + json!(MAX_KNOWLEDGE_REVISION + 1), + json!("1"), + ] { + let mut update = create_payload(); + update["id"] = json!(KnowledgeId::new()); + update["expectedRevision"] = revision.clone(); + assert!(serde_json::from_value::(update).is_err()); + assert!( + serde_json::from_value::(json!({ + "id": KnowledgeId::new(), "expectedRevision": revision, + })) + .is_err() + ); + let mut invalid_entry = serde_json::to_value(entry()).unwrap(); + invalid_entry["revision"] = revision; + assert!(serde_json::from_value::(invalid_entry).is_err()); + } + + for revision in [1, MAX_KNOWLEDGE_REVISION] { + let deletion = KnowledgeDeleteRequest { + id: KnowledgeId::new(), + expected_revision: revision, + }; + let round_trip: KnowledgeDeleteRequest = serde_json::from_value(json!(deletion)).unwrap(); + assert_eq!(round_trip, deletion); + let mut valid_entry = entry(); + valid_entry.revision = revision; + assert!(serde_json::from_value::(json!(valid_entry)).is_ok()); + } +} + +#[test] +fn wire_contract_uses_camel_case_epoch_milliseconds_and_optional_scope() { + let entry = entry(); + let encoded = serde_json::to_value(&entry).unwrap(); + assert_eq!(encoded["createdAtMs"], 1_778_000_000_000_i64); + assert_eq!(encoded["updatedAtMs"], 1_778_000_000_001_i64); + assert_eq!(encoded["projectId"], json!(entry.project_id)); + assert_eq!(encoded["kind"], "context"); + assert_eq!( + serde_json::from_value::(encoded).unwrap(), + entry + ); + + let mut global = entry; + global.project_id = None; + assert_eq!( + serde_json::to_value(global).unwrap()["projectId"], + Value::Null + ); + assert_eq!( + serde_json::to_value(KnowledgeListRequest::default()).unwrap(), + json!({}) + ); +} + +#[test] +fn list_contract_validates_pagination_search_and_response() { + let id = KnowledgeId::new(); + let project_id = ProjectId::new(); + let request: KnowledgeListRequest = serde_json::from_value(json!({ + "projectId": project_id, "kind": "prompt", "cursor": id, "query": " secret_%query ", + })) + .unwrap(); + assert_eq!(request.project_id, Some(project_id)); + assert_eq!(request.kind, Some(KnowledgeKind::Prompt)); + assert_eq!(request.cursor, Some(id)); + assert_eq!(request.query.as_deref(), Some("secret_%query")); + assert!(!format!("{request:?}").contains("secret_%query")); + assert!(serde_json::from_value::(json!({"query": " "})).is_ok()); + assert!( + serde_json::from_value::(json!({"query": "a".repeat(256)})).is_ok() + ); + for invalid in ["a".repeat(257), "secret\0query".into()] { + assert!(serde_json::from_value::(json!({"query": invalid})).is_err()); + } + + let response = KnowledgeListResponse { + entries: vec![entry()], + next_cursor: Some(id), + }; + let encoded = serde_json::to_value(&response).unwrap(); + assert_eq!(encoded["nextCursor"], json!(id)); + assert_eq!( + serde_json::from_value::(encoded).unwrap(), + response + ); + assert_eq!( + serde_json::to_value(KnowledgeListResponse { + entries: vec![], + next_cursor: None + }) + .unwrap(), + json!({"entries": [], "nextCursor": null}), + ); +} + +#[test] +fn schemas_reject_unknown_duplicate_and_unsafe_fields_without_text_leaks() { + let mut save = create_payload(); + save["private_unknown_content"] = json!(true); + let error = serde_json::from_value::(save).unwrap_err(); + assert!(!error.to_string().contains("private_unknown_content")); + assert!( + serde_json::from_str::( + r#"{"kind":"prompt","title":"One","title":"Two","body":"Text"}"#, + ) + .is_err() + ); + assert!(serde_json::from_value::(json!({"limit": 99})).is_err()); + assert!( + serde_json::from_value::(json!({ + "id": KnowledgeId::new(), "expectedRevision": 1, "force": true, + })) + .is_err() + ); + let mut saved = serde_json::to_value(entry()).unwrap(); + saved["extra"] = json!(true); + assert!(serde_json::from_value::(saved).is_err()); + + for field in ["title", "body"] { + let mut invalid = create_payload(); + invalid[field] = json!("private-content\0"); + let error = serde_json::from_value::(invalid).unwrap_err(); + assert!(!error.to_string().contains("private-content")); + } + let request: KnowledgeSaveRequest = serde_json::from_value(json!({ + "kind": "prompt", "title": "private-title", "body": "private-body", + })) + .unwrap(); + let debug = format!("{request:?}"); + assert!(!debug.contains("private-title")); + assert!(!debug.contains("private-body")); +} diff --git a/crates/daemon/src/knowledge/mod.rs b/crates/daemon/src/knowledge/mod.rs new file mode 100644 index 0000000..cce9d75 --- /dev/null +++ b/crates/daemon/src/knowledge/mod.rs @@ -0,0 +1,68 @@ +//! Local user-authored knowledge; no session or process side effects. + +use cli_master_core::ApiError; +use cli_master_core::wire::{ + EmptyResponse, KnowledgeDeleteRequest, KnowledgeListRequest, KnowledgeSaveRequest, +}; +use cli_master_storage::Storage; +use serde::de::DeserializeOwned; +use serde_json::Value; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Handle only the registered knowledge methods through the existing database. +pub(crate) fn dispatch(method: &str, payload: Value, storage: &Storage) -> Result { + use cli_master_core::wire::method; + match method { + method::KNOWLEDGE_LIST => { + let request: KnowledgeListRequest = decode(payload)?; + encode( + storage + .list_knowledge(&request) + .map_err(|error| error.to_api_error())?, + ) + } + method::KNOWLEDGE_SAVE => { + let request: KnowledgeSaveRequest = decode(payload)?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|elapsed| i64::try_from(elapsed.as_millis()).ok()) + .ok_or_else(|| { + ApiError::new("clock_unavailable", "The local clock is unavailable.") + })?; + encode( + storage + .save_knowledge(&request, now) + .map_err(|error| error.to_api_error())?, + ) + } + method::KNOWLEDGE_DELETE => { + let request: KnowledgeDeleteRequest = decode(payload)?; + storage + .delete_knowledge(&request) + .map_err(|error| error.to_api_error())?; + encode(EmptyResponse::default()) + } + _ => Err(ApiError::new( + "unsupported_method", + "Unknown knowledge operation.", + )), + } +} + +fn decode(payload: Value) -> Result { + // Serde errors can quote a user value (e.g. an invalid enum variant). Never + // attach them to errors for a text-bearing knowledge request. + serde_json::from_value(payload).map_err(|_| { + ApiError::new( + "invalid_payload", + "The saved prompt or context request is invalid.", + ) + .with_action("Check the title, text, scope and revision, then retry.") + }) +} + +fn encode(value: impl serde::Serialize) -> Result { + serde_json::to_value(value) + .map_err(|_| ApiError::new("internal_error", "The local response could not be encoded.")) +} diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs index 7575f9f..685d628 100644 --- a/crates/daemon/src/lib.rs +++ b/crates/daemon/src/lib.rs @@ -14,6 +14,7 @@ mod diagnostics; mod error; mod events; mod git_inspection; +mod knowledge; mod lock; mod paths; mod preflight; diff --git a/crates/daemon/src/server.rs b/crates/daemon/src/server.rs index 855d60a..49aefc5 100644 --- a/crates/daemon/src/server.rs +++ b/crates/daemon/src/server.rs @@ -557,6 +557,20 @@ async fn dispatch( } let result = match request.method.as_str() { + method::KNOWLEDGE_LIST | method::KNOWLEDGE_SAVE | method::KNOWLEDGE_DELETE => { + let state = Arc::clone(state); + match tokio::task::spawn_blocking(move || { + crate::knowledge::dispatch(&request.method, request.payload, &state.git_storage) + }) + .await + { + Ok(result) => result, + Err(_) => Err(ApiError::new( + "knowledge_operation_failed", + "The local knowledge operation could not complete.", + )), + } + } method::SYSTEM_HELLO => encode_response(&state.hello), method::STATE_SNAPSHOT => state.projects.snapshot().and_then(|projects| { let agents = state.sessions.agents()?; diff --git a/crates/daemon/tests/knowledge_ipc.rs b/crates/daemon/tests/knowledge_ipc.rs new file mode 100644 index 0000000..1417ade --- /dev/null +++ b/crates/daemon/tests/knowledge_ipc.rs @@ -0,0 +1,248 @@ +//! Real socket/SQLite acceptance for local prompts and context. +use std::{path::Path, process::Command, time::Duration}; + +use cli_master_core::{RequestEnvelope, ResponseEnvelope, ResponsePayload}; +use cli_master_daemon::{Daemon, DaemonConfig, MAX_FRAME_LENGTH}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::{Value, json}; +use tempfile::TempDir; +use tokio::net::UnixStream; +use tokio_util::{ + codec::{Framed, LengthDelimitedCodec}, + sync::CancellationToken, +}; + +type Client = Framed; + +struct Running { + config: DaemonConfig, + cancellation: CancellationToken, + task: tokio::task::JoinHandle>, +} + +impl Running { + fn start(root: &Path) -> Self { + let config = DaemonConfig::from_paths(root.join("data"), root.join("run")); + let daemon = Daemon::bind(config.clone()).unwrap(); + let cancellation = CancellationToken::new(); + let token = cancellation.clone(); + let task = tokio::spawn(async move { daemon.run(token).await }); + Self { + config, + cancellation, + task, + } + } + + async fn connect(&self) -> Client { + LengthDelimitedCodec::builder() + .max_frame_length(MAX_FRAME_LENGTH) + .new_framed( + UnixStream::connect(self.config.socket_path()) + .await + .unwrap(), + ) + } + + async fn stop(self) { + self.cancellation.cancel(); + tokio::time::timeout(Duration::from_secs(5), self.task) + .await + .unwrap() + .unwrap() + .unwrap(); + } +} + +async fn exchange(client: &mut Client, method: &str, payload: Value) -> ResponseEnvelope { + let request = RequestEnvelope::v1(method, payload); + client + .send(serde_json::to_vec(&request).unwrap().into()) + .await + .unwrap(); + let bytes = tokio::time::timeout(Duration::from_secs(5), client.next()) + .await + .unwrap() + .unwrap() + .unwrap(); + assert!(bytes.len() <= MAX_FRAME_LENGTH); + let response: ResponseEnvelope = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(response.request_id, request.request_id); + response +} + +async fn success(client: &mut Client, method: &str, payload: Value) -> Value { + match exchange(client, method, payload).await.payload { + ResponsePayload::Success { data } => data, + ResponsePayload::Error { error } => panic!("{method}: {}", error.code), + } +} + +fn error_code(response: ResponseEnvelope) -> String { + match response.payload { + ResponsePayload::Error { error } => error.code, + ResponsePayload::Success { .. } => panic!("expected request failure"), + } +} + +async fn project(client: &mut Client, root: &Path, name: &str) -> Value { + let path = root.join(name); + std::fs::create_dir(&path).unwrap(); + assert!( + Command::new("git") + .args(["init", "--initial-branch=main"]) + .current_dir(&path) + .output() + .unwrap() + .status + .success() + ); + success(client, "project.add", json!({"path":path, "name":name})).await["id"].clone() +} + +#[tokio::test] +async fn knowledge_scopes_conflicts_restart_and_no_session_effects() { + let root = TempDir::new().unwrap(); + let daemon = Running::start(root.path()); + let mut client = daemon.connect().await; + let mut second = daemon.connect().await; + let first_project = project(&mut client, root.path(), "first").await; + let other_project = project(&mut client, root.path(), "other").await; + let before = success(&mut client, "state.snapshot", json!({})).await; + let global = success(&mut client, "knowledge.save", json!({ + "kind":"prompt", "projectId":null, "title":"Review % literal", "body":"Review this change without sending anything." + })).await; + assert!(global["projectId"].is_null()); + let local = success(&mut client, "knowledge.save", json!({ + "kind":"context", "projectId":first_project, "title":"Decision", "body":"Keep the original process owner." + })).await; + let page = success( + &mut second, + "knowledge.list", + json!({"projectId":first_project}), + ) + .await; + assert_eq!(page["entries"].as_array().unwrap().len(), 2); + assert!(page["nextCursor"].is_null()); + let other = success( + &mut second, + "knowledge.list", + json!({"projectId":other_project}), + ) + .await; + assert_eq!(other["entries"], json!([global])); + let literal = success(&mut second, "knowledge.list", json!({"query":"%"})).await; + assert_eq!(literal["entries"], json!([global])); + let updated = success( + &mut client, + "knowledge.save", + json!({ + "id":global["id"], "expectedRevision":1, "kind":"prompt", "projectId":null, + "title":"Reviewed", "body":"Updated prompt" + }), + ) + .await; + assert_eq!(updated["revision"], 2); + assert_eq!(updated["createdAtMs"], global["createdAtMs"]); + for method in ["knowledge.save", "knowledge.delete"] { + let payload = if method == "knowledge.save" { + json!({ + "id":global["id"], "expectedRevision":1, "kind":"prompt", "projectId":null, + "title":"stale", "body":"must not replace updated text" + }) + } else { + json!({"id":global["id"], "expectedRevision":1}) + }; + assert_eq!( + error_code(exchange(&mut second, method, payload).await), + "knowledge_conflict" + ); + } + let after = success(&mut client, "state.snapshot", json!({})).await; + assert_eq!(after["sessions"], before["sessions"]); + assert_eq!(after["worktrees"], before["worktrees"]); + drop(client); + drop(second); + daemon.stop().await; + + let daemon = Running::start(root.path()); + let mut client = daemon.connect().await; + let page = success( + &mut client, + "knowledge.list", + json!({"projectId":first_project}), + ) + .await; + assert!(page["entries"].as_array().unwrap().contains(&updated)); + assert!(page["entries"].as_array().unwrap().contains(&local)); + success( + &mut client, + "knowledge.delete", + json!({"id":updated["id"],"expectedRevision":2}), + ) + .await; + success( + &mut client, + "project.remove", + json!({"projectId":first_project}), + ) + .await; + assert!(root.path().join("first/.git").is_dir()); + let empty = success(&mut client, "knowledge.list", json!({})).await; + assert_eq!(empty, json!({"entries":[],"nextCursor":null})); + daemon.stop().await; +} + +#[tokio::test] +async fn knowledge_worst_case_escaped_pages_and_safe_invalid_payloads() { + let root = TempDir::new().unwrap(); + let daemon = Running::start(root.path()); + let mut client = daemon.connect().await; + let body = "\u{1}".repeat(65_536); + let mut ids = Vec::new(); + for index in 0..3 { + let entry = success( + &mut client, + "knowledge.save", + json!({ + "kind":"prompt", "title":format!("Large {index}"), "body":body, "projectId":null + }), + ) + .await; + ids.push(entry["id"].clone()); + } + let mut seen = Vec::new(); + let mut cursor = Value::Null; + for _ in 0..4 { + let page = success(&mut client, "knowledge.list", json!({"cursor":cursor})).await; + for entry in page["entries"].as_array().unwrap() { + assert_eq!(entry["body"], body); + seen.push(entry["id"].clone()); + } + cursor = page["nextCursor"].clone(); + if cursor.is_null() { + break; + } + } + assert!(cursor.is_null()); + assert_eq!(seen, ids); + for payload in [ + json!({"kind":"private-submitted-content", "title":"text", "body":"private-submitted-content"}), + json!({"kind":"prompt", "title":"text", "body":"private-submitted-content", "expectedRevision":0}), + json!({"kind":"prompt", "title":"text", "body":"x".repeat(65_537)}), + json!({"kind":"prompt", "title":"text", "body":"\0"}), + ] { + let response = exchange(&mut client, "knowledge.save", payload).await; + assert!( + !serde_json::to_string(&response) + .unwrap() + .contains("private-submitted-content") + ); + assert_eq!(error_code(response), "invalid_payload"); + } + assert_eq!( + success(&mut client, "state.snapshot", json!({})).await["sessions"], + json!([]) + ); + daemon.stop().await; +} diff --git a/crates/storage/migrations/0004_knowledge_documents.sql b/crates/storage/migrations/0004_knowledge_documents.sql new file mode 100644 index 0000000..e663cb5 --- /dev/null +++ b/crates/storage/migrations/0004_knowledge_documents.sql @@ -0,0 +1,29 @@ +-- Explicitly saved local content. Project removal deletes scoped metadata only. +CREATE TABLE knowledge_documents ( + id TEXT PRIMARY KEY NOT NULL, + kind TEXT NOT NULL CHECK (kind IN ('prompt', 'context')), + project_id TEXT REFERENCES projects(id) ON DELETE CASCADE, + title TEXT NOT NULL CHECK ( + length(CAST(title AS BLOB)) BETWEEN 1 AND 256 + AND instr(title, char(0)) = 0 + ), + body TEXT NOT NULL CHECK ( + length(CAST(body AS BLOB)) BETWEEN 1 AND 65536 + AND instr(body, char(0)) = 0 + ), + revision INTEGER NOT NULL CHECK ( + typeof(revision) = 'integer' + AND revision BETWEEN 1 AND 9007199254740991 + ), + created_at_ms INTEGER NOT NULL CHECK ( + typeof(created_at_ms) = 'integer' + AND created_at_ms BETWEEN 0 AND 9007199254740991 + ), + updated_at_ms INTEGER NOT NULL CHECK ( + typeof(updated_at_ms) = 'integer' + AND updated_at_ms BETWEEN created_at_ms AND 9007199254740991 + ) +); + +CREATE INDEX knowledge_by_scope_id ON knowledge_documents(project_id, id); +CREATE INDEX knowledge_by_scope_kind_id ON knowledge_documents(project_id, kind, id); diff --git a/crates/storage/src/durability_tests.rs b/crates/storage/src/durability_tests.rs index 004168a..f29fc29 100644 --- a/crates/storage/src/durability_tests.rs +++ b/crates/storage/src/durability_tests.rs @@ -35,6 +35,7 @@ fn configured_file_database_uses_full_wal_and_verified_schema() { (1, "initial"), (2, "worktree_dirty_state"), (3, "recovery_metadata"), + (4, "knowledge_documents"), ] ); diff --git a/crates/storage/src/knowledge/mod.rs b/crates/storage/src/knowledge/mod.rs new file mode 100644 index 0000000..917514f --- /dev/null +++ b/crates/storage/src/knowledge/mod.rs @@ -0,0 +1,402 @@ +//! Durable, explicitly saved local prompts and context with optimistic writes. + +use std::{error::Error, fmt}; + +use cli_master_core::{ + ApiError, ProjectId, + knowledge::{ + KnowledgeBody, KnowledgeDeleteRequest, KnowledgeEntry, KnowledgeId, KnowledgeKind, + KnowledgeListRequest, KnowledgeListResponse, KnowledgeSaveRequest, KnowledgeTitle, + KnowledgeValidationError, MAX_KNOWLEDGE_REVISION, + }, +}; +use rusqlite::{Connection, OptionalExtension, Row, TransactionBehavior, params}; + +use crate::{Storage, StorageError}; + +/// Maximum rows returned in one knowledge page. +pub const MAX_KNOWLEDGE_PAGE_ENTRIES: usize = 50; +/// Maximum serialized page size, leaving space for the socket response envelope. +pub const MAX_KNOWLEDGE_PAGE_BYTES: usize = 512 * 1_024; + +const ENTRY_COLUMNS: &str = + "id, kind, project_id, title, body, revision, created_at_ms, updated_at_ms"; +// Reserve more than the fixed JSON wrapper plus a UUID next-page cursor. +const PAGE_OVERHEAD_BYTES: usize = 128; + +/// Safe knowledge repository failure. Diagnostic formatting never includes text. +pub enum KnowledgeStorageError { + /// Caller supplied a malformed request. + InvalidInput(KnowledgeValidationError), + /// The supplied clock value cannot be represented safely on the wire. + InvalidTimestamp, + /// The requested saved entry no longer exists. + NotFound, + /// The selected project does not exist. + ProjectNotFound, + /// Another writer changed the entry after the caller loaded it. + Conflict, + /// The entry exhausted the revision range and cannot be incremented. + RevisionExhausted, + /// Persisted content violates the typed contract. + CorruptData, + /// The underlying metadata store could not complete the operation. + Storage(StorageError), +} + +impl KnowledgeStorageError { + /// Projects a failure into a stable error without SQL or saved content. + #[must_use] + pub fn to_api_error(&self) -> ApiError { + let (code, action) = match self { + Self::InvalidInput(_) | Self::InvalidTimestamp => { + ("invalid_request", "Correct the request and retry") + } + Self::NotFound => ("knowledge_not_found", "Reload the saved library"), + Self::ProjectNotFound => ("project_not_found", "Select an existing project"), + Self::Conflict => ( + "knowledge_conflict", + "Reload the entry before saving or deleting", + ), + Self::RevisionExhausted => ( + "knowledge_revision_exhausted", + "Save the content as a new entry", + ), + Self::CorruptData => ( + "knowledge_corrupt_data", + "Restore a known-good database backup", + ), + Self::Storage(error) => return error.to_api_error(), + }; + ApiError::new(code, self.to_string()).with_action(action) + } +} + +impl fmt::Display for KnowledgeStorageError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidInput(error) => error.fmt(formatter), + Self::InvalidTimestamp => { + formatter.write_str("Knowledge timestamp is outside the supported range") + } + Self::NotFound => formatter.write_str("The saved entry no longer exists"), + Self::ProjectNotFound => formatter.write_str("The selected project no longer exists"), + Self::Conflict => { + formatter.write_str("The saved entry has changed since it was loaded") + } + Self::RevisionExhausted => { + formatter.write_str("The saved entry cannot advance its revision") + } + Self::CorruptData => { + formatter.write_str("Stored knowledge does not match the supported contract") + } + Self::Storage(error) => fmt::Display::fmt(error, formatter), + } + } +} + +impl fmt::Debug for KnowledgeStorageError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, formatter) + } +} + +// Deliberately avoid exposing underlying SQLite errors through a source chain. +impl Error for KnowledgeStorageError {} + +impl From for KnowledgeStorageError { + fn from(value: StorageError) -> Self { + Self::Storage(value) + } +} + +impl From for KnowledgeStorageError { + fn from(value: rusqlite::Error) -> Self { + Self::Storage(StorageError::Database(value)) + } +} + +impl From for KnowledgeStorageError { + fn from(value: KnowledgeValidationError) -> Self { + Self::InvalidInput(value) + } +} + +impl Storage { + /// Lists globals or globals plus one existing project's entries in ID order. + /// + /// The page is bounded by row count and actual JSON size. Literal search + /// uses `SQLite`'s built-in case folding, which is case-insensitive for ASCII. + /// Project validation and the page read share one database snapshot. + /// + /// # Errors + /// + /// Returns an error for invalid input, a missing project, corrupt rows, or database failure. + pub fn list_knowledge( + &self, + request: &KnowledgeListRequest, + ) -> Result { + request.validate()?; + self.with_connection_mut("list knowledge", |connection| { + Ok(list_knowledge(connection, request)) + })? + } + + /// Creates an entry or atomically replaces the caller's expected revision. + /// + /// An update may explicitly change kind or scope. Saved text is never + /// executed or sent to an agent by this operation. Clock rollback does not + /// decrease the entry's update timestamp. + /// + /// # Errors + /// + /// Returns an error for invalid input, a missing row/project, a stale or + /// exhausted revision, or database failure. Failed writes roll back fully. + pub fn save_knowledge( + &self, + request: &KnowledgeSaveRequest, + now: i64, + ) -> Result { + request.validate()?; + if !(0..=9_007_199_254_740_991).contains(&now) { + return Err(KnowledgeStorageError::InvalidTimestamp); + } + self.with_connection_mut("save knowledge", |connection| { + Ok(save_knowledge(connection, request, now)) + })? + } + + /// Atomically deletes only the entry revision observed by the caller. + /// + /// # Errors + /// + /// Returns an error for invalid input, a missing entry, a stale revision, or database failure. + pub fn delete_knowledge( + &self, + request: &KnowledgeDeleteRequest, + ) -> Result<(), KnowledgeStorageError> { + request.validate()?; + self.with_connection_mut("delete knowledge", |connection| { + Ok(delete_knowledge(connection, request)) + })? + } +} + +fn list_knowledge( + connection: &mut Connection, + request: &KnowledgeListRequest, +) -> Result { + let transaction = connection.transaction()?; + require_project(&transaction, request.project_id)?; + let sql = format!( + "SELECT {ENTRY_COLUMNS} FROM knowledge_documents + WHERE (project_id IS NULL OR project_id = ?1) + AND (?2 IS NULL OR kind = ?2) + AND (?3 IS NULL OR id > ?3) + AND (?4 = '' OR instr(lower(title), lower(?4)) > 0 + OR instr(lower(body), lower(?4)) > 0) + ORDER BY id ASC LIMIT 51" + ); + let mut statement = transaction.prepare(&sql)?; + let mut rows = statement.query(params![ + request.project_id.map(|id| id.to_string()), + request.kind.map(kind_name), + request.cursor.map(|id| id.to_string()), + request.query.as_deref().unwrap_or("").trim(), + ])?; + let mut page = KnowledgeListResponse { + entries: Vec::new(), + next_cursor: None, + }; + let mut page_bytes = PAGE_OVERHEAD_BYTES; + while let Some(row) = rows.next()? { + if page.entries.len() == MAX_KNOWLEDGE_PAGE_ENTRIES { + page.next_cursor = page.entries.last().map(|entry| entry.id); + break; + } + let entry = decode_entry(row)?; + let serialized = + serde_json::to_vec(&entry).map_err(|_| KnowledgeStorageError::CorruptData)?; + if page_bytes + serialized.len() + 1 > MAX_KNOWLEDGE_PAGE_BYTES { + let Some(last) = page.entries.last() else { + return Err(KnowledgeStorageError::CorruptData); + }; + page.next_cursor = Some(last.id); + break; + } + page_bytes += serialized.len() + 1; + page.entries.push(entry); + } + drop(rows); + drop(statement); + transaction.commit()?; + Ok(page) +} + +fn save_knowledge( + connection: &mut Connection, + request: &KnowledgeSaveRequest, + now: i64, +) -> Result { + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + require_project(&transaction, request.project_id)?; + let (id, revision, created_at_ms, updated_at_ms) = match (request.id, request.expected_revision) + { + (Some(id), Some(expected_revision)) => { + let current = load_entry(&transaction, id)?.ok_or(KnowledgeStorageError::NotFound)?; + if current.revision != expected_revision { + return Err(KnowledgeStorageError::Conflict); + } + if current.revision == MAX_KNOWLEDGE_REVISION { + return Err(KnowledgeStorageError::RevisionExhausted); + } + ( + id, + current.revision + 1, + current.created_at_ms, + now.max(current.updated_at_ms), + ) + } + (None, None) => (KnowledgeId::new(), 1, now, now), + _ => return Err(KnowledgeStorageError::CorruptData), + }; + let entry = KnowledgeEntry { + id, + kind: request.kind, + project_id: request.project_id, + title: request.title.clone(), + body: request.body.clone(), + revision, + created_at_ms, + updated_at_ms, + }; + entry.validate()?; + let revision_sql = + i64::try_from(entry.revision).map_err(|_| KnowledgeStorageError::RevisionExhausted)?; + let changed = if let Some(expected_revision) = request.expected_revision { + let expected_sql = i64::try_from(expected_revision) + .map_err(|_| KnowledgeStorageError::RevisionExhausted)?; + transaction.execute( + "UPDATE knowledge_documents SET kind = ?2, project_id = ?3, title = ?4, + body = ?5, revision = ?6, updated_at_ms = ?7 WHERE id = ?1 AND revision = ?8", + params![ + id.to_string(), + kind_name(entry.kind), + entry.project_id.map(|id| id.to_string()), + entry.title.as_str(), + entry.body.as_str(), + revision_sql, + updated_at_ms, + expected_sql + ], + )? + } else { + transaction.execute( + "INSERT INTO knowledge_documents + (id, kind, project_id, title, body, revision, created_at_ms, updated_at_ms) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + params![ + id.to_string(), + kind_name(entry.kind), + entry.project_id.map(|id| id.to_string()), + entry.title.as_str(), + entry.body.as_str(), + revision_sql, + created_at_ms, + updated_at_ms + ], + )? + }; + if changed != 1 { + return Err(KnowledgeStorageError::Conflict); + } + transaction.commit()?; + Ok(entry) +} + +fn delete_knowledge( + connection: &mut Connection, + request: &KnowledgeDeleteRequest, +) -> Result<(), KnowledgeStorageError> { + let transaction = connection.transaction_with_behavior(TransactionBehavior::Immediate)?; + let current = load_entry(&transaction, request.id)?.ok_or(KnowledgeStorageError::NotFound)?; + if current.revision != request.expected_revision { + return Err(KnowledgeStorageError::Conflict); + } + let revision = i64::try_from(request.expected_revision) + .map_err(|_| KnowledgeStorageError::RevisionExhausted)?; + let changed = transaction.execute( + "DELETE FROM knowledge_documents WHERE id = ?1 AND revision = ?2", + params![request.id.to_string(), revision], + )?; + if changed != 1 { + return Err(KnowledgeStorageError::Conflict); + } + transaction.commit()?; + Ok(()) +} + +fn require_project( + connection: &Connection, + project: Option, +) -> Result<(), KnowledgeStorageError> { + if let Some(id) = project { + let exists: bool = connection.query_row( + "SELECT EXISTS(SELECT 1 FROM projects WHERE id = ?1)", + [id.to_string()], + |row| row.get(0), + )?; + if !exists { + return Err(KnowledgeStorageError::ProjectNotFound); + } + } + Ok(()) +} + +fn load_entry( + connection: &Connection, + id: KnowledgeId, +) -> Result, KnowledgeStorageError> { + let sql = format!("SELECT {ENTRY_COLUMNS} FROM knowledge_documents WHERE id = ?1"); + connection + .query_row(&sql, [id.to_string()], |row| Ok(decode_entry(row))) + .optional()? + .transpose() +} + +fn decode_entry(row: &Row<'_>) -> Result { + let id: String = row.get(0)?; + let kind: String = row.get(1)?; + let project: Option = row.get(2)?; + let revision: i64 = row.get(5)?; + let entry = KnowledgeEntry { + id: id.parse().map_err(|_| KnowledgeStorageError::CorruptData)?, + kind: match kind.as_str() { + "prompt" => KnowledgeKind::Prompt, + "context" => KnowledgeKind::Context, + _ => return Err(KnowledgeStorageError::CorruptData), + }, + project_id: project + .map(|id| id.parse()) + .transpose() + .map_err(|_| KnowledgeStorageError::CorruptData)?, + title: KnowledgeTitle::try_new(row.get::<_, String>(3)?) + .map_err(|_| KnowledgeStorageError::CorruptData)?, + body: KnowledgeBody::try_new(row.get::<_, String>(4)?) + .map_err(|_| KnowledgeStorageError::CorruptData)?, + revision: u64::try_from(revision).map_err(|_| KnowledgeStorageError::CorruptData)?, + created_at_ms: row.get(6)?, + updated_at_ms: row.get(7)?, + }; + entry + .validate() + .map_err(|_| KnowledgeStorageError::CorruptData)?; + Ok(entry) +} + +const fn kind_name(kind: KnowledgeKind) -> &'static str { + match kind { + KnowledgeKind::Prompt => "prompt", + KnowledgeKind::Context => "context", + } +} diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 97c88f4..63572a3 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -8,6 +8,7 @@ mod agents; mod connection; mod error; +pub mod knowledge; mod migrate; mod models; mod paths; @@ -31,11 +32,12 @@ use crate::connection::{StorageLocation, maybe_backup_before_migrate}; pub use connection::Storage; pub use error::StorageError; +pub use knowledge::KnowledgeStorageError; pub use models::{SessionRuntimeUpdate, StoredAgent, StoredSession, StoredWorktree, WorktreeState}; pub use recovery::{ReconciliationEvent, ReconciliationReason, RecoveryContext}; /// The newest schema version understood by this crate. -pub const LATEST_SCHEMA_VERSION: u32 = 3; +pub const LATEST_SCHEMA_VERSION: u32 = 4; impl Storage { /// Opens and configures a file-backed `SQLite` database. diff --git a/crates/storage/src/migrate.rs b/crates/storage/src/migrate.rs index 1f6cbc3..ce4e2c1 100644 --- a/crates/storage/src/migrate.rs +++ b/crates/storage/src/migrate.rs @@ -33,6 +33,12 @@ const MIGRATIONS: &[Migration] = &[ sql: include_str!("../migrations/0003_recovery_metadata.sql"), destructive: false, }, + Migration { + version: 4, + name: "knowledge_documents", + sql: include_str!("../migrations/0004_knowledge_documents.sql"), + destructive: false, + }, ]; const REQUIRED_TABLES: &[(&str, &[&str])] = &[ @@ -91,6 +97,19 @@ const REQUIRED_TABLES: &[(&str, &[&str])] = &[ ], ), ("settings", &["key", "value_json", "updated_at"]), + ( + "knowledge_documents", + &[ + "id", + "kind", + "project_id", + "title", + "body", + "revision", + "created_at_ms", + "updated_at_ms", + ], + ), ("schema_migrations", &["version", "name", "applied_at"]), ]; @@ -103,6 +122,8 @@ const REQUIRED_INDEXES: &[&str] = &[ "sessions_by_agent", "sessions_by_daemon_status", "custom_agents_by_updated", + "knowledge_by_scope_id", + "knowledge_by_scope_kind_id", ]; const REQUIRED_TRIGGERS: &[&str] = &[ diff --git a/crates/storage/tests/knowledge.rs b/crates/storage/tests/knowledge.rs new file mode 100644 index 0000000..3a960e6 --- /dev/null +++ b/crates/storage/tests/knowledge.rs @@ -0,0 +1,509 @@ +mod common; + +use std::{ + collections::BTreeSet, + sync::{Arc, Barrier}, + thread, +}; + +use cli_master_core::{ + ProjectId, + knowledge::{ + KnowledgeBody, KnowledgeDeleteRequest, KnowledgeEntry, KnowledgeKind, KnowledgeListRequest, + KnowledgeSaveRequest, KnowledgeTitle, MAX_KNOWLEDGE_BODY_BYTES, MAX_KNOWLEDGE_REVISION, + }, +}; +use cli_master_storage::{ + KnowledgeStorageError, LATEST_SCHEMA_VERSION, Storage, + knowledge::{MAX_KNOWLEDGE_PAGE_BYTES, MAX_KNOWLEDGE_PAGE_ENTRIES}, +}; +use rusqlite::{Connection, params}; +use tempfile::TempDir; + +const NOW: i64 = 1_788_600_000_000; + +fn create( + project_id: Option, + kind: KnowledgeKind, + title: &str, + body: &str, +) -> KnowledgeSaveRequest { + KnowledgeSaveRequest { + id: None, + expected_revision: None, + project_id, + kind, + title: KnowledgeTitle::try_new(title).unwrap(), + body: KnowledgeBody::try_new(body).unwrap(), + } +} + +fn update(entry: &KnowledgeEntry, body: &str) -> KnowledgeSaveRequest { + KnowledgeSaveRequest { + id: Some(entry.id), + expected_revision: Some(entry.revision), + project_id: entry.project_id, + kind: entry.kind, + title: entry.title.clone(), + body: KnowledgeBody::try_new(body).unwrap(), + } +} + +fn list(project_id: Option) -> KnowledgeListRequest { + KnowledgeListRequest { + project_id, + ..KnowledgeListRequest::default() + } +} + +#[test] +fn entries_survive_reopen_with_scopes_kind_search_and_monotonic_timestamps() { + let directory = TempDir::new().unwrap(); + let path = directory.path().join("knowledge.db"); + let storage = Storage::open_migrated(&path).unwrap(); + let project = common::project("First", directory.path().join("first")); + let other = common::project("Other", directory.path().join("other")); + storage.insert_project(&project).unwrap(); + storage.insert_project(&other).unwrap(); + let global = storage + .save_knowledge( + &create(None, KnowledgeKind::Prompt, "Review", "Check failures"), + NOW, + ) + .unwrap(); + let scoped = storage + .save_knowledge( + &create( + Some(project.id), + KnowledgeKind::Context, + "Architecture", + "Daemon owns PTYs", + ), + NOW, + ) + .unwrap(); + storage + .save_knowledge( + &create(Some(other.id), KnowledgeKind::Prompt, "Other", "Unrelated"), + NOW, + ) + .unwrap(); + assert_eq!(global.revision, 1); + let changed = storage + .save_knowledge(&update(&scoped, "Daemon owns processes"), NOW - 1) + .unwrap(); + assert_eq!(changed.created_at_ms, NOW); + assert_eq!(changed.updated_at_ms, NOW); + assert_eq!(changed.revision, 2); + storage.close().unwrap(); + drop(storage); + + let reopened = Storage::open_migrated(&path).unwrap(); + assert_eq!( + reopened.list_knowledge(&list(None)).unwrap().entries, + vec![global] + ); + let scoped_page = reopened.list_knowledge(&list(Some(project.id))).unwrap(); + assert_eq!(scoped_page.entries.len(), 2); + assert!(scoped_page.entries.contains(&changed)); + let mut filtered = list(Some(project.id)); + filtered.kind = Some(KnowledgeKind::Context); + assert_eq!( + reopened.list_knowledge(&filtered).unwrap().entries, + vec![changed.clone()] + ); + filtered.query = Some(" PROCESSES ".to_owned()); + assert_eq!( + reopened.list_knowledge(&filtered).unwrap().entries, + vec![changed] + ); +} + +#[test] +fn literal_search_treats_percent_underscore_and_sql_syntax_as_text() { + let directory = TempDir::new().unwrap(); + let storage = Storage::open_migrated(directory.path().join("literal.db")).unwrap(); + let exact = storage + .save_knowledge( + &create( + None, + KnowledgeKind::Prompt, + "Literal %_", + "SELECT ' OR 1=1 --", + ), + NOW, + ) + .unwrap(); + storage + .save_knowledge( + &create(None, KnowledgeKind::Prompt, "Other", "Different"), + NOW, + ) + .unwrap(); + for text in ["%_", "literal", "' OR 1=1 --"] { + let request = KnowledgeListRequest { + query: Some(text.to_owned()), + ..list(None) + }; + assert_eq!( + storage.list_knowledge(&request).unwrap().entries, + vec![exact.clone()] + ); + } + assert_eq!( + storage + .list_knowledge(&KnowledgeListRequest { + query: Some(String::new()), + ..list(None) + }) + .unwrap() + .entries + .len(), + 2 + ); +} + +#[test] +fn missing_projects_fail_and_explicit_scope_move_obeys_cascade() { + let directory = TempDir::new().unwrap(); + let path = directory.path().join("scope.db"); + let storage = Storage::open_migrated(&path).unwrap(); + let project = common::project("Scope", directory.path().join("project")); + std::fs::create_dir(&project.path).unwrap(); + std::fs::write(project.path.join("keep.txt"), "repository content").unwrap(); + storage.insert_project(&project).unwrap(); + let global = storage + .save_knowledge( + &create(None, KnowledgeKind::Prompt, "Global", "Preserved"), + NOW, + ) + .unwrap(); + let scoped = storage + .save_knowledge( + &create( + Some(project.id), + KnowledgeKind::Prompt, + "Scoped", + "Project data", + ), + NOW, + ) + .unwrap(); + let missing_project = ProjectId::new(); + assert!(matches!( + storage.list_knowledge(&list(Some(missing_project))), + Err(KnowledgeStorageError::ProjectNotFound) + )); + assert!(matches!( + storage.save_knowledge( + &create( + Some(missing_project), + KnowledgeKind::Context, + "Missing", + "Should not save" + ), + NOW + ), + Err(KnowledgeStorageError::ProjectNotFound) + )); + let mut move_request = update(&global, "Attempted edit"); + move_request.project_id = Some(missing_project); + assert!(matches!( + storage.save_knowledge(&move_request, NOW + 1), + Err(KnowledgeStorageError::ProjectNotFound) + )); + assert_eq!( + storage.list_knowledge(&list(None)).unwrap().entries, + vec![global] + ); + let mut export = update(&scoped, "Exported to global"); + export.project_id = None; + let exported = storage.save_knowledge(&export, NOW + 1).unwrap(); + storage + .save_knowledge( + &create( + Some(project.id), + KnowledgeKind::Context, + "Disposable", + "Removed with metadata", + ), + NOW, + ) + .unwrap(); + storage.remove_project_metadata(project.id).unwrap(); + let globals = storage.list_knowledge(&list(None)).unwrap().entries; + assert_eq!(globals.len(), 2); + assert!(globals.contains(&exported)); + assert_eq!( + std::fs::read_to_string(project.path.join("keep.txt")).unwrap(), + "repository content" + ); + let raw = Connection::open(path).unwrap(); + let remaining: i64 = raw + .query_row( + "SELECT COUNT(*) FROM knowledge_documents WHERE project_id IS NOT NULL", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(remaining, 0); +} + +#[test] +fn independent_connections_cannot_overwrite_the_same_revision() { + let directory = TempDir::new().unwrap(); + let path = directory.path().join("concurrent.db"); + let original = Storage::open_migrated(&path).unwrap(); + let entry = original + .save_knowledge(&create(None, KnowledgeKind::Prompt, "Race", "Initial"), NOW) + .unwrap(); + let first = Storage::open_migrated(&path).unwrap(); + let second = Storage::open_migrated(&path).unwrap(); + let barrier = Arc::new(Barrier::new(3)); + let mut handles = Vec::new(); + for (storage, text) in [(first, "First writer"), (second, "Second writer")] { + let barrier = Arc::clone(&barrier); + let request = update(&entry, text); + handles.push(thread::spawn(move || { + barrier.wait(); + storage.save_knowledge(&request, NOW + 1) + })); + } + barrier.wait(); + let outcomes: Vec<_> = handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .collect(); + assert_eq!(outcomes.iter().filter(|outcome| outcome.is_ok()).count(), 1); + assert_eq!( + outcomes + .iter() + .filter(|outcome| matches!(outcome, Err(KnowledgeStorageError::Conflict))) + .count(), + 1 + ); + let winner = original + .list_knowledge(&list(None)) + .unwrap() + .entries + .remove(0); + assert_eq!(winner.revision, 2); + let stale_delete = KnowledgeDeleteRequest { + id: entry.id, + expected_revision: entry.revision, + }; + let conflict = original.delete_knowledge(&stale_delete).unwrap_err(); + assert_eq!(conflict.to_api_error().code, "knowledge_conflict"); + original + .delete_knowledge(&KnowledgeDeleteRequest { + id: winner.id, + expected_revision: winner.revision, + }) + .unwrap(); + let absent = original.delete_knowledge(&stale_delete).unwrap_err(); + assert_eq!(absent.to_api_error().code, "knowledge_not_found"); + assert!(matches!( + original.save_knowledge(&update(&winner, "Gone"), NOW), + Err(KnowledgeStorageError::NotFound) + )); +} + +#[test] +fn pagination_is_bounded_ordered_and_exclusive_without_duplicate_or_missing_rows() { + let directory = TempDir::new().unwrap(); + let storage = Storage::open_migrated(directory.path().join("pages.db")).unwrap(); + let mut expected = BTreeSet::new(); + for index in 0..57 { + let entry = storage + .save_knowledge( + &create( + None, + KnowledgeKind::Prompt, + &format!("Entry {index}"), + "A small body", + ), + NOW, + ) + .unwrap(); + expected.insert(entry.id); + } + let first = storage.list_knowledge(&list(None)).unwrap(); + assert_eq!(first.entries.len(), MAX_KNOWLEDGE_PAGE_ENTRIES); + assert_eq!( + first.next_cursor, + first.entries.last().map(|entry| entry.id) + ); + let second = storage + .list_knowledge(&KnowledgeListRequest { + cursor: first.next_cursor, + ..list(None) + }) + .unwrap(); + assert_eq!(second.entries.len(), 7); + assert_eq!(second.next_cursor, None); + let seen: Vec<_> = first + .entries + .into_iter() + .chain(second.entries) + .map(|entry| entry.id) + .collect(); + assert!(seen.windows(2).all(|pair| pair[0] < pair[1])); + assert_eq!(seen.into_iter().collect::>(), expected); +} + +#[test] +fn worst_case_json_escaping_fits_one_entry_and_pages_without_skipping() { + let directory = TempDir::new().unwrap(); + let storage = Storage::open_migrated(directory.path().join("escaped.db")).unwrap(); + let body = "\u{0001}".repeat(MAX_KNOWLEDGE_BODY_BYTES); + let mut expected = BTreeSet::new(); + for index in 0..3 { + expected.insert( + storage + .save_knowledge( + &create( + None, + KnowledgeKind::Context, + &format!("Large {index}"), + &body, + ), + NOW, + ) + .unwrap() + .id, + ); + } + let mut request = list(None); + let mut seen = BTreeSet::new(); + loop { + let page = storage.list_knowledge(&request).unwrap(); + assert_eq!(page.entries.len(), 1); + assert!(serde_json::to_vec(&page).unwrap().len() <= MAX_KNOWLEDGE_PAGE_BYTES); + assert_eq!(page.entries[0].body.as_str(), body); + assert!(seen.insert(page.entries[0].id)); + request.cursor = page.next_cursor; + if request.cursor.is_none() { + break; + } + } + assert_eq!(seen, expected); +} + +#[test] +fn exhausted_revisions_reject_updates_but_allow_checked_deletion() { + let directory = TempDir::new().unwrap(); + let path = directory.path().join("exhausted.db"); + let storage = Storage::open_migrated(&path).unwrap(); + let entry = storage + .save_knowledge( + &create(None, KnowledgeKind::Prompt, "Max revision", "Retained"), + NOW, + ) + .unwrap(); + let raw = Connection::open(path).unwrap(); + raw.execute( + "UPDATE knowledge_documents SET revision = ?1 WHERE id = ?2", + params![ + i64::try_from(MAX_KNOWLEDGE_REVISION).unwrap(), + entry.id.to_string() + ], + ) + .unwrap(); + let mut exhausted = entry; + exhausted.revision = MAX_KNOWLEDGE_REVISION; + assert!(matches!( + storage.save_knowledge(&update(&exhausted, "Never stored"), NOW + 1), + Err(KnowledgeStorageError::RevisionExhausted) + )); + assert_eq!( + storage.list_knowledge(&list(None)).unwrap().entries, + vec![exhausted.clone()] + ); + storage + .delete_knowledge(&KnowledgeDeleteRequest { + id: exhausted.id, + expected_revision: exhausted.revision, + }) + .unwrap(); +} + +#[test] +fn programmatic_input_constraints_and_corrupt_rows_never_echo_content() { + let directory = TempDir::new().unwrap(); + let path = directory.path().join("validation.db"); + let storage = Storage::open_migrated(&path).unwrap(); + let mut invalid = create(None, KnowledgeKind::Prompt, "Private title", "Private body"); + invalid.expected_revision = Some(1); + assert!(storage.save_knowledge(&invalid, NOW).is_err()); + invalid.expected_revision = None; + for timestamp in [-1, 9_007_199_254_740_992] { + assert!(matches!( + storage.save_knowledge(&invalid, timestamp), + Err(KnowledgeStorageError::InvalidTimestamp) + )); + } + let invalid_query = KnowledgeListRequest { + query: Some("private-query\0".into()), + ..list(None) + }; + let error = storage.list_knowledge(&invalid_query).unwrap_err(); + assert!(!format!("{error:?}").contains("private-query")); + let entry = storage.save_knowledge(&invalid, NOW).unwrap(); + let raw = Connection::open(path).unwrap(); + raw.execute_batch("PRAGMA ignore_check_constraints = ON") + .unwrap(); + raw.execute( + "UPDATE knowledge_documents SET body = ?1 WHERE id = ?2", + params!["PRIVATE_CORRUPT_CONTENT\0", entry.id.to_string()], + ) + .unwrap(); + let error = storage.list_knowledge(&list(None)).unwrap_err(); + assert!(matches!(error, KnowledgeStorageError::CorruptData)); + assert!( + !format!("{error:?} {}", error.to_api_error().message).contains("PRIVATE_CORRUPT_CONTENT") + ); +} + +#[test] +fn version_three_upgrade_preserves_metadata_and_checks_new_schema_objects() { + let directory = TempDir::new().unwrap(); + let path = directory.path().join("v3.db"); + let project = common::project("Existing", directory.path().join("existing")); + { + let raw = Connection::open(&path).unwrap(); + raw.execute_batch(include_str!("../migrations/0001_initial.sql")) + .unwrap(); + raw.execute_batch(include_str!("../migrations/0002_worktree_dirty_state.sql")) + .unwrap(); + raw.execute_batch(include_str!("../migrations/0003_recovery_metadata.sql")) + .unwrap(); + raw.execute_batch("CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL DEFAULT '2026-09-05'); + INSERT INTO schema_migrations (version, name) VALUES (1, 'initial'), (2, 'worktree_dirty_state'), (3, 'recovery_metadata');").unwrap(); + raw.execute( + "INSERT INTO projects (id,name,path,created_at,last_opened_at) VALUES (?1,?2,?3,?4,?4)", + params![ + project.id.to_string(), + project.name, + project.path.to_str().unwrap(), + common::CREATED_AT_MS + ], + ) + .unwrap(); + } + let storage = Storage::open_migrated(&path).unwrap(); + assert_eq!(storage.schema_version().unwrap(), LATEST_SCHEMA_VERSION); + assert_eq!(storage.get_project(project.id).unwrap(), Some(project)); + storage.migrate().unwrap(); + storage + .save_knowledge( + &create(None, KnowledgeKind::Prompt, "After upgrade", "New data"), + NOW, + ) + .unwrap(); + let raw = Connection::open(&path).unwrap(); + let indexes: i64 = raw.query_row("SELECT COUNT(*) FROM sqlite_schema WHERE type='index' AND name IN ('knowledge_by_scope_id','knowledge_by_scope_kind_id')", [], |row| row.get(0)).unwrap(); + assert_eq!(indexes, 2); + raw.execute_batch("DROP INDEX knowledge_by_scope_id") + .unwrap(); + assert!(storage.migrate().is_err()); +} diff --git a/docs/adr/0005-local-knowledge.md b/docs/adr/0005-local-knowledge.md index abdea73..d6f2349 100644 --- a/docs/adr/0005-local-knowledge.md +++ b/docs/adr/0005-local-knowledge.md @@ -1,6 +1,7 @@ # ADR 0005: Local prompts, reusable context, and organization -Status: proposed; shared contract and migration registration await S2 agreement. +Status: accepted for the first prompt/context increment after S2 agreement. +Organization and filesystem details remain integration dependencies. ## Context diff --git a/docs/codex/xirp-context-report.md b/docs/codex/xirp-context-report.md index 2fe3b56..4651d7c 100644 --- a/docs/codex/xirp-context-report.md +++ b/docs/codex/xirp-context-report.md @@ -4,34 +4,88 @@ Branch: `feat/xirp-local-workflows`. Worktree: `/Users/eguimacs/cli-master-xirp`. Base: `0ac8dd7`, fetched from `origin/refactor/canvas-only-shell` on 2026-09-05. -## Scope and evidence +The full user request remains open. Source/acceptance inventory: +[xirp-local-parity.md](../xirp-local-parity.md). Architecture: +[ADR 0005](../adr/0005-local-knowledge.md). -The source/acceptance inventory is [xirp-local-parity.md](../xirp-local-parity.md). -The architecture proposal is [ADR 0005](../adr/0005-local-knowledge.md). -The full user request remains open; documentation and isolated components are -intermediate deliverables. +## Coordination and ownership -## Coordination +S2 approved the first knowledge contract and additive shared registrations in +[xirp-coordination-reply.md](xirp-coordination-reply.md), reserving migration +`0004_knowledge_documents.sql`. S2 retains organization/workflow, runtime, +worktrees, file service and conversation capabilities. S1 retains canvas and +composer integration; the request is in [xirp-integration-request.md](xirp-integration-request.md). -A concrete proposal was placed in `docs/codex/xirp-integration-request.md` in -S1, S2 and S3 worktrees. S2 acknowledgment is pending for shared contracts, -module registrations, migration allocation and dispatch. S1 acknowledgment is -pending for canvas mounting and composer insertion. No shared code has been -changed by S3 at this point. +The active daemon dispatch is `server.rs`. Knowledge handlers reuse its +existing storage connection and generic request path, with no session/process +operation. The additive `methods.ts`/`domain.ts` mirrors preserve existing +`client.ts`/`types.ts`/`schema.ts`. Merge S2's `worktree.list` mirror entries by +name, rather than replacing whole files. -Baseline drift reported to owners: active daemon dispatch is `server.rs`; -`client.rs` is uncompiled. Frontend currently has `types.ts`/`schema.ts` rather -than the mirrors named in AGENTS. Runtime registry/adapters need consolidation -by S2; S3 will not introduce another registry or Tauri client. +## Implemented first vertical increment -## Work in progress +- `knowledge.list`: nullable project scope, optional kind, literal title/body + query, exclusive UUIDv7 cursor; `{entries,nextCursor}`. Global query returns + globals; project query returns its entries plus globals. Unknown projects + fail. Pages cap at 50 rows and 512 KiB of serialized JSON. +- `knowledge.save`: UUIDv7, prompt/context kind, title (256 UTF-8 bytes), body + (64 KiB), project scope, revision and epoch-ms timestamps. Create omits + id/expectedRevision; update requires both. Revision conflicts are atomic. +- `knowledge.delete`: id plus expectedRevision. Stale requests preserve data. +- `knowledge_documents` in existing SQLite, additive v3→v4 migration, schema + verification and scoped indexes. Project metadata removal cascades its + knowledge rows; it never deletes project files. Archive remains separate. +- `knowledge_conflict`, `knowledge_not_found`, `project_not_found`, + `knowledge_revision_exhausted`, `knowledge_corrupt_data` are stable errors. + Text/queries are excluded from Debug and request error details. +- IpcClient methods `listKnowledge`, `saveKnowledge`, `deleteKnowledge` use + validated TypeScript mirrors and a shared Rust/TypeScript JSON fixture. -- Pure validated knowledge types and contract tests (new module only). -- Isolated saved prompt/context editor/picker with typed callbacks and tests. -- Persisted handlers and integration follow the agreed shared boundaries. +`knowledge.updated` is deliberately not advertised: compiled baseline supports +session-specific streams, and general metadata event transport remains an S2 +integration dependency. Clients refresh after mutations and can explicitly +refresh; revision checks protect concurrent edits. Search uses SQLite ASCII +case folding; Unicode text is preserved but non-ASCII case folding is not +claimed. Pagination is per-request consistent, not a multi-request snapshot; +refresh observes inserts/edits made during browsing. -## Commits and checks +## Verification executed on macOS -Initial documentation commit: source review completed; `git diff --check`. -Implementation tests and runtime/platform evidence will be recorded here as -executed. No Linux/macOS runtime parity is claimed yet. +Use `CARGO_INCREMENTAL=0 CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0` +for the Rust commands below; debug information was reduced because the host +had limited free disk space. + +- `cargo test -p cli-master-core -p cli-master-storage --locked`: existing + suites and 9 new core contract tests passed. +- `cargo test -p cli-master-storage --test knowledge --locked`: 9 tests passed; + actual file-backed databases, competing connections, CAS races, reopen, + v3 migration, FK cleanup, scope moves, literal search, revision exhaustion, + corrupt content, 57-row pagination and worst-case JSON escaping. +- `cargo test -p cli-master-core --test knowledge_catalog --locked`: passed; + JSON catalog matches Rust methods/events exactly. +- `cargo test -p cli-master-daemon --locked`: 37 tests passed, including 2 new + real Unix-socket acceptance tests. Proves scoped CRUD, cross-client conflicts, + restart persistence, frame limits, safe errors and unchanged session state. +- `cargo clippy -p cli-master-core -p cli-master-storage --all-targets --locked + -- -D warnings`, plus the equivalent daemon command: passed. +- `cargo fmt --all -- --check`: passed. +- IPC TypeScript tests and typecheck passed. Full frontend run with Node 25 + needs `NODE_OPTIONS=--no-experimental-webstorage` to use jsdom storage; + CI uses Node 24. Isolated UI behavior tests are being finalized separately. + +Direct typed serde duplicate-field validation is not a promise that duplicate +keys are rejected after the daemon's intermediate JSON Value decode. +Linux CI, actual Tauri/canvas consumption and full parity remain unverified. + +## Commits / next integration + +- `569449f`: source inventory and integration proposal; pushed. +- Backend first vertical increment follows this report in the current commit. +- Isolated `KnowledgePanel` follows as its own UI increment. Props: + `{client,currentProject,onInsert,insertDisabledReason}`; insertion receives + `{sourceId,kind,title,body}` and must append to an editable composer draft. + Keep panel mounted if unsaved editor drafts must survive closing its surface. +- Next: bounded rules/skills discovery; consume S2 pin/archive/workflow and + initial-objective contracts; mount with S1; verify additional agent features + only against trustworthy native sources. Portal/MCP remain explicit separate + dependencies; no connection is simulated. diff --git a/docs/codex/xirp-coordination-reply.md b/docs/codex/xirp-coordination-reply.md new file mode 100644 index 0000000..9d27ceb --- /dev/null +++ b/docs/codex/xirp-coordination-reply.md @@ -0,0 +1,44 @@ +# S2 runtime coordination — 2026-09-05 + +S2 is active in `/Users/eguimacs/cli-master-runtime`, branch +`feat/maestri-runtime`, baseline `0ac8dd7`. This reply is an agreed boundary, +not a claim that the proposed endpoints are implemented. + +- S3 owns `knowledge/` modules in core/storage/daemon and isolated UI modules. +- Reserve migration **0004_knowledge_documents.sql** for S3. S2 worktree + integration needs no new migration. S2 future migrations begin at 0005 after + integrating 0004; no missing migration may be published in a runnable branch. +- Approved first vertical contract: `knowledge.list/save/delete`, tagged + `kind: prompt|context`, UUIDv7, nullable project scope, title/body, + created/updated epoch-ms, positive integer revision. Updates and deletes + require expectedRevision; stale revisions return `knowledge_conflict`. + Create has no existing id/revision. Define bounded lengths in the Rust + validation types and keep list responses bounded (pagination if needed). +- `knowledge.updated` must be delivered through the existing daemon event + transport before its event path is reported complete. Do not advertise an + unimplemented event. Draft insertion stays explicit and is owned by S1/S3. +- S3 may make the small additive registration patch on its own branch: + core/storage/daemon lib.rs, migration registration, wire exports and + `knowledge.*` dispatch, protocol catalog and TypeScript mirrors. Publish + implementation and tests in the same coherent commit and report its hash; + S2 will review and integrate it. Preserve all runtime handlers and additions. +- S2 currently adds `worktree.list` with `{ projectId?: UUID }` and response + `{ worktrees: Worktree[] }`. Existing `session.create` payload stays intact; + new_worktree creates metadata/Git only, followed by explicit session.start. +- S2 retains process lifecycle, workspace/floor/canvas persistence, and + project/session organization/workflow contracts. S3 owns drafting and context + references; do not overload SessionStatus with workflow or attention. +- Planned S2 file API targets registered project/session/worktree ids, uses + bounded reads and optimistic conflict checks with atomic writes. Its final + path policy and DTOs require an ADR. Until published, S3 can implement + discovery of explicitly known rules/skills with a narrow bounded safe reader; + do not create a second generic editor/file service. + +Current baseline no longer contains the AGENTS-referenced methods.ts/domain.ts +mirrors: S2 will restore those as additive IPC contract artifacts while keeping +the existing client/types/schema files and all React code intact. Merge contract +entries by name, never replace either side's whole catalog. + +Integration evidence and published commits will be kept in +`docs/codex/maestri-runtime-report.md` on the S2 branch. S2 will inspect S3's +`docs/codex/xirp-context-report.md` and this request thread for follow-ups. diff --git a/docs/codex/xirp-integration-request.md b/docs/codex/xirp-integration-request.md index 0522dbf..63a2621 100644 --- a/docs/codex/xirp-integration-request.md +++ b/docs/codex/xirp-integration-request.md @@ -23,3 +23,24 @@ Will deliver isolated components with typed callbacks for saved prompts/context ## Responses Pending owner acknowledgment. + +### S3 acknowledgment and implementation details + +S2 acknowledgment received in xirp-coordination-reply.md. First implementation uses +knowledge.list/save/delete; list accepts optional projectId/kind/query/cursor, +returns {entries,nextCursor}; cursor is exclusive UUIDv7 ID ascending, literal +title/body search. Pages cap at 50 rows and 512 KiB serialized entries. Title +256 UTF-8 bytes, body 64 KiB. Prompt/context errors do not attach serde causes. +Shared additive TypeScript mirrors restore methods.ts/domain.ts; merge S2 +worktree.list entries by name. IpcClient gains listKnowledge/saveKnowledge/ +deleteKnowledge; existing generic Tauri request path is reused. + +The compiled daemon currently has only session-specific event streams; generic +metadata broadcasts are not wired. S3 will not advertise knowledge.updated +until S2's general event transport is available. Initial UI refreshes after +mutations and provides explicit refresh; conflict protection still covers +multiple clients. Please advise the general event transport integration point. + +S1: initial components will expose KnowledgePanel({client,currentProject, +onInsert,insertDisabledReason}); onInsert receives plain draft text and sourceId. +Please mount as canvas contextual panel/palette. No hidden terminal send. diff --git a/docs/xirp-local-parity.md b/docs/xirp-local-parity.md index 4e56b21..87f9bff 100644 --- a/docs/xirp-local-parity.md +++ b/docs/xirp-local-parity.md @@ -8,18 +8,18 @@ added to this implementation's scope. | ID | Requirement and source | Owner / integration | Evidence / remaining work | | --- | --- | --- | --- | -| X01 | Local project pin, rename, remove registration, non-Git folders, discover child repositories. [Projects](https://backstage.spotify.com/docs/xirp/projects) | S2 project base, S3 organization UI, S1 canvas | Baseline registers Git projects; pin/archive metadata, non-Git support and child discovery pending. Project archive is a user-requested extension. | +| X01 | Local project pin, rename, remove registration, non-Git folders, discover child repositories. [Projects](https://backstage.spotify.com/docs/xirp/projects) | S2 project base, S3 organization UI, S1 canvas | Baseline real daemon tests register both Git projects and plain folders; pin/archive metadata and child discovery remain pending. Project archive is a user-requested extension. | | X02 | Initial objective delivered to agent, attachments, checkout/worktree and agent-specific options. [Sessions](https://backstage.spotify.com/docs/xirp/sessions) | S2 execution/contracts, S3 draft/context selection, S1 composer | Goal delivery requires verified runtime path, not metadata only. Attachments/options pending runtime contract. | | X03 | Resume/fork, agent switch preserving supported history, linked shell. [Sessions](https://backstage.spotify.com/docs/xirp/sessions), [Changelog](https://backstage.spotify.com/docs/xirp/changelog) | S2 adapters/runtime, S1 canvas | Current adapter capabilities do not expose verified conversation IDs, fork or usage. Await trusted native contracts and runtime integration. Local context reuse is a separate feature. | | X04 | Working/idle/needs-input attention separate from process lifecycle. [Sessions](https://backstage.spotify.com/docs/xirp/sessions) | S2 event source, S3 indicators, S1 canvas | PTY silence is not proof of approval/input need. Explicit hooks or native signals required. | | X05 | Local preferences, shortcuts, supported native settings, diagnostics. [Settings](https://backstage.spotify.com/docs/xirp/settings) | S2 settings/runtime, S3 rules/skills, S1 UI | Existing diagnostics/custom-agent creation are baseline only; do not expose unsupported options. Credentials excluded from editors. | | X06 | Global/project rules and skills, including symlinked skill directories. [Projects](https://backstage.spotify.com/docs/xirp/projects), [Changelog](https://backstage.spotify.com/docs/xirp/changelog) | S3 discovery, S2 file boundary | Need verified location inventory and safe bounded reads with origins/scopes; docs do not enumerate complete formats or precedence. Symlink policy and editing depend on file service. | -| X07a | Saved global/project prompts, searchable picker and insertion. [Changelog v0.19.1](https://backstage.spotify.com/docs/xirp/changelog) | S3 knowledge, S2 shared registration, S1 canvas insertion | Isolated module implementation underway; persistence, socket, UI integration and cross-platform evidence pending. | +| X07a | Saved global/project prompts, searchable picker and insertion. [Changelog v0.19.1](https://backstage.spotify.com/docs/xirp/changelog) | S3 knowledge, S2 shared registration, S1 canvas insertion | Persisted CRUD and paginated search verified through real SQLite and daemon socket; isolated UI underway; canvas and Linux evidence pending. See S3 report. | | X07b | Session archive and workflow: backlog/in_progress/in_review/blocked/done. [Changelog](https://backstage.spotify.com/docs/xirp/changelog) | S2 organization metadata, S3 controls, S1 filtering | Workflow must not mutate lifecycle. Session pin is a user-requested extension. | | X07c | Tokens/spend per session/day. [Changelog v0.18.0](https://backstage.spotify.com/docs/xirp/changelog) | S2 verified native data, S3 presentation | Source documents feature existence, not a collector/format/pricing algorithm. Currently unavailable, not zero. No proxy or PTY-based estimates. | | X07d | PR review, Doctor, cleanup. [Changelog](https://backstage.spotify.com/docs/xirp/changelog) | S2 runtime/Git, S1 UI | Existing Git status/diagnostics/removal-token safety are baseline; review and expanded cleanup require their real contracts. | | X08 | Agent-specific options, including Cursor. [Changelog](https://backstage.spotify.com/docs/xirp/changelog) | S2 adapter registry, S1 UI | No flags invented from agent display name; support requires installed-version tests and authoritative CLI sources. | -| X09-local | Explicit reusable local context between sessions (user request). | S3 knowledge, S1 composer, S2 delivery | Local text records and explicit draft insertion planned. No transcript capture, autonomous summarization, or Portal connection implied. | +| X09-local | Explicit reusable local context between sessions (user request). | S3 knowledge, S1 composer, S2 delivery | Local text records persist and survive daemon restart; explicit draft insertion component is underway. No transcript capture, autonomous summarization, or Portal connection implied. | | X09-Portal | Shared workspaces, members, catalog links, resources, decisions/wiki. [Workspaces](https://backstage.spotify.com/docs/xirp/workspaces) | Separate authenticated connector | Portal-dependent; no account/API verification supplied. Not implemented or simulated. | | X10 | Workspace context via MCP, manual transcript sharing, configured external integrations. [Workspaces](https://backstage.spotify.com/docs/xirp/workspaces) | Separate authenticated connector + S2 transport | Requires real account, scopes/API and explicit outbound preview. No silent publishing, account linking or telemetry. | diff --git a/protocol/catalog.json b/protocol/catalog.json index 0efc949..2a775f8 100644 --- a/protocol/catalog.json +++ b/protocol/catalog.json @@ -30,7 +30,10 @@ "worktree.list", "worktree.prepare_remove", "worktree.remove", - "diagnostics.get" + "diagnostics.get", + "knowledge.list", + "knowledge.save", + "knowledge.delete" ], "events": [ "project.updated", diff --git a/protocol/fixtures/knowledge-page.json b/protocol/fixtures/knowledge-page.json new file mode 100644 index 0000000..19443bb --- /dev/null +++ b/protocol/fixtures/knowledge-page.json @@ -0,0 +1,25 @@ +{ + "entries": [ + { + "id": "01990000-0000-7000-8000-000000000001", + "kind": "prompt", + "projectId": null, + "title": "Review the change", + "body": "Review correctness and failure paths.\n", + "revision": 1, + "createdAtMs": 1788600000000, + "updatedAtMs": 1788600000000 + }, + { + "id": "01990000-0000-7000-8000-000000000002", + "kind": "context", + "projectId": "01990000-0000-7000-8000-000000000010", + "title": "Project architecture", + "body": "The daemon owns session processes.\n", + "revision": 2, + "createdAtMs": 1788600000000, + "updatedAtMs": 1788600000001 + } + ], + "nextCursor": null +} From b271825c448b90535fc990f44a88e6b467a4ce52 Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sat, 5 Sep 2026 22:01:05 -0300 Subject: [PATCH 09/12] docs(architecture): define local file editor contracts and safe saves --- docs/adr/0006-local-files-and-editor.md | 296 ++++++++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 docs/adr/0006-local-files-and-editor.md diff --git a/docs/adr/0006-local-files-and-editor.md b/docs/adr/0006-local-files-and-editor.md new file mode 100644 index 0000000..681c710 --- /dev/null +++ b/docs/adr/0006-local-files-and-editor.md @@ -0,0 +1,296 @@ +# ADR 0006: Local files and editor I/O + +## Status + +Accepted for the requested local runtime increment, 2026-09-05. + +This decision covers the first functional slice of M29/M30 and the shared +reader needed by M07/X06. It extends the Beta domain-operation inventory with +local file editing. It does not change IPC envelope version 1, session +ownership, worktree deletion, or Linux/macOS support. In particular, the +relative file identifiers below are a scoped extension of ADR 0002; arbitrary +absolute filesystem paths remain invalid domain-operation inputs. + +## Context + +The daemon currently exposes registered project, session and worktree IDs, +but no file-listing or editor-save operation. S1 needs a real file tree and +editor on the canvas. S3 needs a reusable safe reader for explicitly selected +rule/skill files; a second filesystem service would create conflicting access +and overwrite policies. + +The user requested files/editor after worktree integration, including real +disk effects and restart behavior. A generic Tauri filesystem command, shell +command, or Rust type without a daemon handler would not fulfill that request. +Linux filenames are byte sequences and need not be UTF-8. A check using +`canonicalize` followed by an unrelated pathname `open` can follow a replaced +symlink. Atomic replacement prevents partial files, but portable Unix rename +does not provide compare-and-swap against an external editor's last write. + +## Decision + +### Ownership and initial scope + +Implement `file.list`, `file.read` and `file.write` in the daemon, backed by a +single internal `LocalFileService`. Core owns pure validated values and DTOs; +it performs no filesystem, hashing of open files, clock, or process I/O. +Storage resolves existing metadata IDs and remains the only SQLite owner. +No new table or migration is required for these three operations. + +Add each method to Rust wire, JSON catalog and TypeScript mirrors only with +its functional handler. The desktop continues to use `daemon_invoke`; there +is no `file_read` or other domain-specific Tauri command. Operations run in +bounded blocking tasks rather than on the asynchronous socket reader. + +`file.write` replaces an existing regular text file. It never creates a +missing file or parent directory, renames a user file, or deletes one. Those +remain required future file-management increments, tentatively under +`file.create`, `file.rename`, and `file.prepare_delete`/`file.delete`; they +are not advertised or implemented as stubs now. Creation will require +exclusive creation, rename a no-clobber/state policy, and deletion a reviewed +state-bound target. This sequencing does not remove M29's remaining scope. + +### Target and byte-exact path contracts + +Every request carries one registered target: + +```text +FileTarget = { kind: "project", projectId: UUIDv7 } + | { kind: "session", sessionId: UUIDv7 } + | { kind: "worktree", worktreeId: UUIDv7 } +FilePath = canonical padded base64 of repository-independent Unix path bytes +FileRevision = "v1:" + 64 lowercase hexadecimal SHA-256 characters +``` + +`FileTarget` has the same tagged representation as the existing Git target, +but file semantics belong to this service. A project resolves to its +registered selected directory, a session to its persisted cwd, and a worktree +to its managed root. The service must not expand a project subdirectory to +the repository root. Worktree-backed targets require an active association; +creating/orphaned/removal-pending targets are not writable. Reads may explain +recovery state, but must not substitute another directory silently. + +Roots are derived from daemon metadata and validated on every operation; +clients do not echo a root path back as authority. Root opens must reject a +persisted canonical root that now resolves elsewhere. Managed worktree +identity checks reuse the runtime worktree validation before granting access. + +`pathBase64` is limited to 4096 decoded bytes. Empty bytes represent the root +only for `file.list`. Nonempty paths are relative `/`-separated components; +reject leading/trailing `/`, empty components, NUL, `.` and `..`. Names with +spaces, Unicode, non-UTF-8 bytes, leading `-`, literal backslashes or colons +remain valid Unix names. There is no Windows-path normalization, Unicode +normalization, case folding or Git pathspec interpretation. The existing +`GitRelativePath` is deliberately not reused. + +Responses return `pathBase64` as the exact identifier and `displayName` for +presentation. Escape control/undecodable bytes in display text; never rebuild +an operation path from that text. Raw names never become HTML or command +arguments. Non-UTF-8 names are listable and can identify an editable UTF-8 +file on either supported platform. + +### Public operations + +All payloads use camelCase, reject unknown fields and keep existing versioned +response envelopes. Optional fields are omitted when absent. + +| Method | Request | Successful response | +| --- | --- | --- | +| `file.list` | `{ target, pathBase64, limit?, afterNameBase64? }` | `{ entries: FileEntry[], nextAfterNameBase64?, observedAtMs }` | +| `file.read` | `{ target, pathBase64 }` | `{ pathBase64, text, revision, sizeBytes, modifiedAtMs?, observedAtMs }` | +| `file.write` | `{ target, pathBase64, text, expectedRevision }` | `{ pathBase64, revision, sizeBytes, modifiedAtMs?, writtenAtMs }` | + +`FileEntry` is `{ pathBase64, displayName, kind, sizeBytes?, modifiedAtMs? }`. +Kind is `file | directory | symlink | other`; it describes observed type, not +an access grant. Symlinks are visible but not traversed or edited. `other` +includes devices, sockets and FIFOs and cannot be opened through this API. + +List one directory, not a recursive tree. Default page size is 100, maximum +200. Order by raw name bytes; `afterNameBase64` is a validated single filename +and is an exclusive lexicographic cursor. Bound enumeration to 10,000 entries +and the encoded response to 512 KiB, returning a continuation cursor before +exceeding the response budget. A directory exceeding the enumeration limit +returns `file_listing_too_large`, never a success implying an empty tree. +Pages are observations, not a transaction snapshot: concurrent changes before +the cursor may require refresh. Search and persistent indexing are separate +increments. + +Read/write text is limited to **128 KiB of UTF-8 bytes**, including any BOM. +This bound allows worst-case JSON escaping inside the existing 1 MiB frame +with room for the envelope. Preserve bytes represented by the text exactly, +including CRLF, final newline and BOM; do not normalize automatically. +The service reads at most the limit plus one byte and rejects oversized +files, invalid UTF-8 or NUL-containing content. Binary/media previews need a +separate bounded byte/asset operation; this text slice does not claim them. +`file.list` can still show those files. The UI keeps its unsaved buffer after +every error. + +All timestamp fields are Unix epoch milliseconds. File modification time is +optional if it cannot be represented; absence is not zero. Revision checks +use higher-resolution metadata internally and never depend on epoch-ms alone. + +### Descriptor-relative filesystem access + +Use safe APIs from the existing direct daemon dependency `rustix 1.1.4` with +feature `fs`. Its local source provides `openat`, `statat`, `fstat`, `renameat`, +`unlinkat`, directory iteration and `fsync`. No new project `unsafe` block, +shell command, external file utility or platform-specific path syntax is +needed. `renameat` is the portable directory-relative replacement operation. +[rustix reference](https://docs.rs/rustix/1.1.4/rustix/fs/fn.renameat.html) + +Open the validated root as a directory descriptor, then walk each path +component relative to its already-open parent with +`RDONLY | DIRECTORY | NOFOLLOW | CLOEXEC`. Open a leaf with +`RDONLY | NOFOLLOW | NONBLOCK | CLOEXEC`, then `fstat` that descriptor and +require a regular file before reading. `NONBLOCK` prevents a FIFO substituted +at the leaf from hanging the daemon. Use no-follow stat for listing entries; +never open an `other` entry as if it were ordinary text. + +Keep the parent descriptor through validation, temporary-file creation and +rename. Reopening an absolute concatenated pathname after validation is not +an acceptable fallback. Revalidate root/parent device+inode identity before +publication; if metadata registration or directory identity changed, abort +with `file_target_changed` and discard only the service-owned temporary file. +For managed worktrees, hold a short operation lease against worktree removal +for the final write transaction. File reads need no process lifecycle lock. +File I/O must not delay stop of an unrelated session behind enumeration. + +Descriptor-relative traversal prevents symlink substitution from redirecting +access. It does not promise that a directory descriptor's inode remains at +the same pathname if another same-user process renames that directory. The +service acts on the opened object and detects observed namespace changes; +it is not a security sandbox against another process with the same Unix UID. + +### Revision checks and atomic save + +Compute revision in the daemon over file bytes plus identity and change +metadata: device, inode, size, high-resolution mtime/ctime, link count and +permission mode. Encode fields canonically with a version prefix and hash +with SHA-256; the resulting `FileRevision` remains opaque to clients. Make +`sha2 0.10.9`, already resolved in the workspace lockfile, a direct daemon +dependency if used. The revision is content/identity-based, not a database +counter or process-local token, so unchanged files can be checked after a +daemon restart. It is not an authorization credential. + +For read, compare descriptor metadata before and after the bounded read; +return `file_conflict` if a concurrent change was observed. For write: + +1. Acquire a service mutation lock keyed by parent device/inode and raw leaf + name. It must serialize overlapping requests even when target IDs differ. +2. Open/inspect the current leaf by descriptor, reject nonregular files and + multiple hard links, read its bounded bytes, and require the caller's + `expectedRevision` to match the observed revision. +3. Create a unique sibling temporary file with `CREATE | EXCL | NOFOLLOW`, + initially mode 0600. Write the complete new text, apply the supported + permission metadata, flush and `fsync` the temporary descriptor. +4. Reopen/reinspect the current leaf through the same parent, verify identity + and revision again and revalidate the target/parent. On conflict leave the + original untouched and remove only that operation's temporary file. +5. Atomically `renameat` the temporary sibling onto the existing name, then + `fsync` the parent directory. Return a revision for the published inode. + +Ownership/group and ordinary permission bits must be preserved when saving; +if this cannot be done without elevated access, reject the write. The first +slice is not a full metadata-preserving file copier: ACLs, extended attributes, +resource forks and platform flags need explicit preservation or a documented +unsupported result before accepting such a save. They must not be silently +advertised as preserved. Hard-linked files return `file_metadata_unsupported` +because replace would otherwise break the link relationship. + +The lock guarantees revision ordering among daemon writers. External editors +do not honor it. The final comparison followed by POSIX `renameat` leaves a +small external-write race: it is **optimistic conflict detection**, not a +filesystem compare-and-swap or a guarantee of zero lost updates against an +uncooperative writer. This limitation must remain in editor/save documentation +and tests must not claim to prove its absence. A later stronger design may +add recoverable versions or platform primitives under another ADR. + +Failure before rename does not alter the destination. Failure after rename +is materially different: return `file_durability_uncertain` with +`writeApplied: true` and the observed revision when possible, instructing the +client to re-read before retrying. Do not pretend rollback occurred or send a +second implicit write. A daemon crash can leave a uniquely named temporary +file; startup must not sweep unknown files from project directories. + +### Errors, synchronization and S3 reuse + +Use existing `ApiError` envelopes. Stable service error codes are +`file_target_not_found`, `file_target_changed`, `file_not_found`, +`file_not_directory`, `file_not_regular`, `file_symlink_not_allowed`, +`file_permission_denied`, `file_too_large`, `file_not_text`, +`file_listing_too_large`, `file_conflict`, `file_metadata_unsupported`, +`file_durability_uncertain` and `file_io_error`. Invalid encoded path, text +limit or revision shape is `invalid_input` at the wire boundary. Conflict +details can carry `currentRevision`, never full old/new text or env values. +Map OS errors without logging file content, secrets or raw program commands. + +This first slice uses explicit response/re-read synchronization. Do not add a +`file.changed` catalog entry while the public socket only supports terminal +subscriptions and no implemented file event feed. S1 refreshes the saved file +and affected directory after mutation, re-reads on explicit refresh/refocus, +and re-reads after daemon reconnect. A later watcher/feed must provide a real +subscription contract, bounded events, gap/reconnect semantics and the same +epoch-ms timestamps. Polling/refresh is not described as live external edits. + +Expose descriptor-safe bounded read/resolution as internal service methods +for S3. The knowledge layer supplies an explicit allowlist and provenance for +rule/skill discovery; it does not gain arbitrary root access from a path string. +Global skill roots will require registered internal read-only capabilities +separate from project editor-write targets. Credential discovery/exposure is +not part of this increment. S3 must not add a second unsafe reader, database, +shell runner or Tauri client. + +### Acceptance + +Use real temporary directories, SQLite and the production daemon socket: + +- List and edit a file under each registered target, including a project + subdirectory and a session with `relativeDirectory`; observe disk bytes. +- Preserve a non-UTF-8 filename, spaces, leading `-` and literal backslash; + use only the returned identifier for follow-up requests. +- Reject absolute/traversal/NUL paths, symlink components and symlink leaves; + swapped FIFO/device leaves do not block the request or get read as text. +- Refuse a target/root replaced between requests or during observed traversal; + file operations and worktree removal cannot race through the final save. +- Two daemon clients reading one revision cannot both commit different writes; + changing bytes or inode externally before validation produces conflict. +- Reject binary/oversized files and unsupported metadata without changing the + destination. Preserve line endings, mode and complete Unicode text. +- Inject failures before rename and after rename/fsync; verify original bytes + or the explicit applied-but-uncertain outcome respectively. +- Reconnect/restart, read the saved text/revision again, and cover pagination + and encoded frame bounds. S3's reader uses the same safe implementation. +- Execute on Linux and macOS; compile/test wire mirrors. S1 separately proves + open → edit → save → conflict/reload on the real desktop. Types, mocked UI, + or this ADR alone do not mark M29/M30 complete. + +## Consequences + +The first editor slice has a small public surface and a single access policy +for S1/S3. Exact filename identity survives Unix/JSON boundaries. Existing +documents remain ordinary files, with no second copy silently made canonical +in SQLite. Directory and file limits are explicit and can be extended with +streaming/indexing later. + +Base64 identifiers and optimistic revisions add client code; symlinked and +unsupported-metadata files initially remain read-only/unavailable for save. +Atomic replacement changes inode identity. External-writer races and richer +metadata preservation remain explicit engineering limits. No file operation +spawns a process, changes session status, writes custom-agent env values to +logs, changes Git author configuration, or creates coauthor trailers. + +## Alternatives considered + +- Absolute string paths or generic Tauri fs access: rejected because clients + could bypass registered target identity and introduce a second I/O owner. +- UTF-8-only paths or Git pathspec reuse: rejected because valid Unix filenames + would become unaddressable or acquire unrelated Git argument restrictions. +- Canonicalize then normal open: rejected as the sole safety mechanism; + descriptor-relative no-follow traversal is available on both supported OSes. +- Truncate/write in place: rejected because interruption can destroy the + original and partial bytes are visible to agents/editors. +- Unconditional last-writer-wins: rejected because ordinary stale editors + would overwrite external work silently. Optimistic revision detects observed + conflicts while accurately describing the remaining portable rename race. +- Announce all future methods/events immediately: rejected because advertised + names without usable handlers or event delivery are not functionality. From c98cf253e42d80f38653434340177c28d4eee995 Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sat, 5 Sep 2026 22:36:11 -0300 Subject: [PATCH 10/12] fix(agents): retry busy executable probes and retain safe error codes --- crates/agents/src/probe.rs | 15 +++-- crates/agents/src/process.rs | 7 +++ crates/agents/tests/probe.rs | 106 +++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 4 deletions(-) diff --git a/crates/agents/src/probe.rs b/crates/agents/src/probe.rs index 37beb48..6697e2d 100644 --- a/crates/agents/src/probe.rs +++ b/crates/agents/src/probe.rs @@ -93,7 +93,7 @@ pub enum LaunchTestStatus { }, /// The version probe did not exit before the timeout. Timeout, - /// The process could not be started. + /// The probe could not complete because of an I/O or process error. Failed { /// Safe explanation that does not include captured output. message: String, @@ -196,14 +196,21 @@ fn probe_resolved_executable(executable: &Path, options: ProbeOptions) -> Execut warning, } } - Err(_) => ExecutableTestReport { + Err(error) => ExecutableTestReport { installed: true, resolved_path: Some(executable.to_path_buf()), version: None, launch_test: LaunchTestStatus::Failed { - message: "the executable could not be started for a version probe".to_owned(), + // Error display text may include paths or other caller data. + // Retain only the classification and numeric OS error, without + // assuming whether spawn, wait, or output collection failed. + message: format!( + "the version probe failed (kind={:?}, errno={:?})", + error.kind(), + error.raw_os_error() + ), }, - warning: Some("The file is executable but could not be spawned.".to_owned()), + warning: Some("The executable was found, but the version probe failed.".to_owned()), }, } } diff --git a/crates/agents/src/process.rs b/crates/agents/src/process.rs index ffb8786..5effb43 100644 --- a/crates/agents/src/process.rs +++ b/crates/agents/src/process.rs @@ -145,6 +145,10 @@ fn is_transient_spawn_error(error: &io::Error) -> bool { error.kind(), io::ErrorKind::WouldBlock | io::ErrorKind::Interrupted ) || matches!(error.raw_os_error(), Some(11 | 35)) + // A concurrent child can briefly inherit a newly written executable's + // writer until exec closes CLOEXEC descriptors. Keep the same bounded + // retry budget when the kernel reports the executable is still busy. + || error.raw_os_error() == Some(nix::errno::Errno::ETXTBSY as i32) } fn wait_with_timeout(child: &mut Child, deadline: Instant) -> io::Result<(bool, Option)> { @@ -301,6 +305,9 @@ mod tests { assert!(is_transient_spawn_error(&io::Error::from( io::ErrorKind::Interrupted ))); + assert!(is_transient_spawn_error(&io::Error::from_raw_os_error( + nix::errno::Errno::ETXTBSY as i32 + ))); assert!(!is_transient_spawn_error(&io::Error::from( io::ErrorKind::PermissionDenied ))); diff --git a/crates/agents/tests/probe.rs b/crates/agents/tests/probe.rs index b2cef00..01562cd 100644 --- a/crates/agents/tests/probe.rs +++ b/crates/agents/tests/probe.rs @@ -24,6 +24,112 @@ fn version_probe_captures_first_line_with_timeout() { assert_eq!(report.launch_test, LaunchTestStatus::Success); } +#[test] +fn failed_probe_diagnostic_contains_only_error_kind_and_os_code() { + let temp = TempDir::new().expect("temporary directory should be created"); + let path = script(temp.path(), "probe-path-secret", "echo unused"); + std::fs::write( + &path, + b"#!/missing-probe-interpreter-secret/TOKEN=must-not-appear\n", + ) + .expect("invalid interpreter fixture should be written"); + + let report = test_executable(&path, &isolated_env(&temp), ProbeOptions::default()); + let LaunchTestStatus::Failed { message } = report.launch_test else { + panic!("missing interpreter must fail the probe"); + }; + assert!(message.contains("kind=NotFound")); + assert!(message.contains(&format!("errno=Some({})", nix::errno::Errno::ENOENT as i32))); + for sensitive in [ + "probe-path-secret", + "interpreter-secret", + "TOKEN", + "must-not-appear", + ] { + assert!(!message.contains(sensitive)); + } + assert!(!message.contains("spawn")); +} + +#[cfg(target_os = "linux")] +#[test] +fn version_probe_retries_a_busy_executable_until_its_writer_closes() { + use std::{ + fs::OpenOptions, + process::Command, + sync::mpsc::{self, RecvTimeoutError}, + thread, + }; + + let temp = TempDir::new().expect("temporary directory should be created"); + let path = script(temp.path(), "busy-probe", "echo 'fixture-cli 1.0'"); + let writer = OpenOptions::new() + .write(true) + .open(&path) + .expect("fixture writer should remain open"); + let error = Command::new(&path) + .arg("--version") + .spawn() + .expect_err("the held writer must cause real ETXTBSY"); + assert_eq!( + error.raw_os_error(), + Some(nix::errno::Errno::ETXTBSY as i32) + ); + + let environment = isolated_env(&temp); + let (sender, receiver) = mpsc::channel(); + let worker = thread::spawn(move || { + let report = test_executable(&path, &environment, ProbeOptions::default()); + sender + .send(report) + .expect("test receiver should remain open"); + }); + // Keep ETXTBSY in force during multiple bounded retries. An immediate + // Failed report would demonstrate that the busy executable was not retried. + let early_result = receiver.recv_timeout(Duration::from_millis(60)); + drop(writer); + worker.join().expect("probe worker should finish"); + assert!( + matches!(early_result, Err(RecvTimeoutError::Timeout)), + "probe must remain pending while the writer is held: {early_result:?}" + ); + let report = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("probe should complete after the writer closes"); + assert_eq!(report.launch_test, LaunchTestStatus::Success); + assert_eq!(report.version.as_deref(), Some("fixture-cli 1.0")); +} + +#[cfg(target_os = "linux")] +#[test] +fn busy_executable_retries_remain_bounded_by_the_probe_deadline() { + use std::{fs::OpenOptions, time::Instant}; + + let temp = TempDir::new().expect("temporary directory should be created"); + let path = script(temp.path(), "busy-probe", "echo 'fixture-cli 1.0'"); + let writer = OpenOptions::new() + .write(true) + .open(&path) + .expect("fixture writer should remain open"); + let started = Instant::now(); + let report = test_executable( + &path, + &isolated_env(&temp), + ProbeOptions::default().with_timeout(Duration::from_millis(60)), + ); + drop(writer); + + assert!(started.elapsed() < Duration::from_secs(1)); + let LaunchTestStatus::Failed { message } = report.launch_test else { + panic!("an executable still open for writing must fail within the retry budget"); + }; + assert!(message.contains(&format!( + "errno=Some({})", + nix::errno::Errno::ETXTBSY as i32 + ))); + assert!(report.version.is_none()); +} + #[test] fn version_probe_times_out_on_hanging_executable() { let temp = TempDir::new().expect("temporary directory should be created"); From 12259319d37448fa085b256f42a2ae50ea68c36e Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sat, 5 Sep 2026 22:36:54 -0300 Subject: [PATCH 11/12] feat(files): expose scoped file listing and revision-checked atomic saves --- Cargo.lock | 10 + Cargo.toml | 1 + apps/desktop/src/ipc/client.ts | 32 + apps/desktop/src/ipc/domain.ts | 64 +- apps/desktop/src/ipc/file-client.test.ts | 226 +++++ apps/desktop/src/ipc/file-schema.ts | 197 ++++ apps/desktop/src/ipc/methods.ts | 3 + apps/desktop/src/test/mockIpc.ts | 6 + crates/core/Cargo.toml | 1 + crates/core/src/wire/files.rs | 514 +++++++++++ crates/core/src/wire/method.rs | 10 + crates/core/src/wire/mod.rs | 6 + crates/core/tests/file_contract.rs | 339 +++++++ crates/daemon/Cargo.toml | 2 + crates/daemon/src/files/attributes.rs | 82 ++ crates/daemon/src/files/descriptor.rs | 251 +++++ crates/daemon/src/files/error.rs | 60 ++ crates/daemon/src/files/listing.rs | 168 ++++ crates/daemon/src/files/metadata.rs | 79 ++ crates/daemon/src/files/mod.rs | 188 ++++ crates/daemon/src/files/tests.rs | 313 +++++++ crates/daemon/src/files/write.rs | 206 +++++ crates/daemon/src/lib.rs | 1 + crates/daemon/src/server.rs | 40 +- crates/daemon/src/sessions.rs | 1 + crates/daemon/src/sessions/files.rs | 125 +++ crates/daemon/tests/file_ipc.rs | 1005 +++++++++++++++++++++ crates/file-metadata/Cargo.toml | 19 + crates/file-metadata/SAFETY.md | 50 + crates/file-metadata/src/darwin.rs | 68 ++ crates/file-metadata/src/lib.rs | 42 + crates/file-metadata/tests/darwin_acl.rs | 101 +++ crates/file-metadata/tests/unsupported.rs | 14 + docs/adr/0006-local-files-and-editor.md | 48 +- protocol/catalog.json | 3 + 35 files changed, 4267 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/ipc/file-client.test.ts create mode 100644 apps/desktop/src/ipc/file-schema.ts create mode 100644 crates/core/src/wire/files.rs create mode 100644 crates/core/tests/file_contract.rs create mode 100644 crates/daemon/src/files/attributes.rs create mode 100644 crates/daemon/src/files/descriptor.rs create mode 100644 crates/daemon/src/files/error.rs create mode 100644 crates/daemon/src/files/listing.rs create mode 100644 crates/daemon/src/files/metadata.rs create mode 100644 crates/daemon/src/files/mod.rs create mode 100644 crates/daemon/src/files/tests.rs create mode 100644 crates/daemon/src/files/write.rs create mode 100644 crates/daemon/src/sessions/files.rs create mode 100644 crates/daemon/tests/file_ipc.rs create mode 100644 crates/file-metadata/Cargo.toml create mode 100644 crates/file-metadata/SAFETY.md create mode 100644 crates/file-metadata/src/darwin.rs create mode 100644 crates/file-metadata/src/lib.rs create mode 100644 crates/file-metadata/tests/darwin_acl.rs create mode 100644 crates/file-metadata/tests/unsupported.rs diff --git a/Cargo.lock b/Cargo.lock index 618eeae..2658b81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -493,6 +493,7 @@ dependencies = [ name = "cli-master-core" version = "0.2.0" dependencies = [ + "base64 0.22.1", "serde", "serde_json", "uuid", @@ -505,6 +506,7 @@ dependencies = [ "base64 0.22.1", "cli-master-agents", "cli-master-core", + "cli-master-file-metadata", "cli-master-git", "cli-master-session", "cli-master-storage", @@ -512,6 +514,7 @@ dependencies = [ "rustix", "serde", "serde_json", + "sha2", "tempfile", "thiserror 2.0.20", "tokio", @@ -560,6 +563,13 @@ dependencies = [ "signal-hook", ] +[[package]] +name = "cli-master-file-metadata" +version = "0.2.0" +dependencies = [ + "tempfile", +] + [[package]] name = "cli-master-git" version = "0.2.0" diff --git a/Cargo.toml b/Cargo.toml index 145602f..0ce1017 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/daemon", "crates/e2e", "crates/fake-agent", + "crates/file-metadata", "crates/git", "crates/session", "crates/storage", diff --git a/apps/desktop/src/ipc/client.ts b/apps/desktop/src/ipc/client.ts index 62b2cdb..2ad5ff0 100644 --- a/apps/desktop/src/ipc/client.ts +++ b/apps/desktop/src/ipc/client.ts @@ -1,5 +1,14 @@ import { decodeKnowledgeEntry, decodeKnowledgePage } from "./knowledge-schema"; import type { KnowledgeEntry, KnowledgeListRequest, KnowledgeListResponse, KnowledgeSaveRequest, KnowledgeDeleteRequest } from "./domain"; +import type { + FileListRequest, + FileListResponse, + FileReadRequest, + FileReadResponse, + FileWriteRequest, + FileWriteResponse, +} from "./domain"; +import { decodeFileListResponse, decodeFileReadResponse, decodeFileWriteResponse } from "./file-schema"; import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { openPath } from "@tauri-apps/plugin-opener"; @@ -62,6 +71,9 @@ export interface TerminalResizeInput { /** The sole frontend interface to daemon and native desktop capabilities. */ export interface IpcClient { readonly platform: AppPlatform; + listFiles(input: FileListRequest): Promise; + readFile(input: FileReadRequest): Promise; + writeFile(input: FileWriteRequest): Promise; listKnowledge(input: KnowledgeListRequest): Promise; saveKnowledge(input: KnowledgeSaveRequest): Promise; deleteKnowledge(input: KnowledgeDeleteRequest): Promise; @@ -141,6 +153,18 @@ export function toIpcError(error: unknown): IpcError { class TauriIpcClient implements IpcClient { readonly platform = detectPlatform(); + async listFiles(input: FileListRequest): Promise { + return this.fileRequest("file.list", input, (value) => decodeFileListResponse(value, input)); + } + + async readFile(input: FileReadRequest): Promise { + return this.fileRequest("file.read", input, (value) => decodeFileReadResponse(value, input)); + } + + async writeFile(input: FileWriteRequest): Promise { + return this.fileRequest("file.write", input, (value) => decodeFileWriteResponse(value, input)); + } + async listKnowledge(input: KnowledgeListRequest): Promise { return decodeKnowledgePage(await this.request("knowledge.list", input)); } @@ -313,6 +337,14 @@ class TauriIpcClient implements IpcClient { } } + private async fileRequest(method: string, payload: unknown, decode: (value: unknown) => T): Promise { + try { + return decode(await this.request(method, payload)); + } catch (error) { + throw toIpcError(error); + } + } + private async request(method: string, payload: unknown): Promise { const request = createRequestEnvelope(method, payload); try { diff --git a/apps/desktop/src/ipc/domain.ts b/apps/desktop/src/ipc/domain.ts index d53c607..75ddef9 100644 --- a/apps/desktop/src/ipc/domain.ts +++ b/apps/desktop/src/ipc/domain.ts @@ -1,6 +1,6 @@ /** Additive daemon contracts. Existing UI DTOs remain owned by types.ts. */ export type * from "./types"; -import type { Worktree } from "./types"; +import type { GitTarget, Worktree } from "./types"; /** List managed worktrees; omission includes every registered project. */ export interface WorktreeListRequest { @@ -55,3 +55,65 @@ export interface KnowledgeDeleteRequest { readonly id: string; readonly expectedRevision: number; } + +/** Registered project directory, session cwd, or managed worktree root. */ +export type FileTarget = GitTarget | { + readonly kind: "worktree"; + readonly worktreeId: string; +}; +/** Canonical padded base64 of relative Unix path bytes; empty means list root. */ +export type FilePath = string; +/** Opaque v1 SHA-256 revision; preserve exactly as returned by the daemon. */ +export type FileRevision = string; + +export interface FileEntry { + readonly pathBase64: FilePath; + readonly displayName: string; + readonly kind: "file" | "directory" | "symlink" | "other"; + readonly sizeBytes?: number; + readonly modifiedAtMs?: number; +} + +export interface FileListRequest { + readonly target: FileTarget; + readonly pathBase64: FilePath; + readonly limit?: number; + readonly afterNameBase64?: string; +} + +export interface FileListResponse { + readonly entries: readonly FileEntry[]; + readonly nextAfterNameBase64?: string; + readonly observedAtMs: number; +} + +export interface FileReadRequest { + readonly target: FileTarget; + readonly pathBase64: FilePath; +} + +/** Text is bounded to 128 KiB UTF-8 including any BOM; line endings are preserved. */ +export interface FileReadResponse { + readonly pathBase64: FilePath; + readonly text: string; + readonly revision: FileRevision; + readonly sizeBytes: number; + readonly modifiedAtMs?: number; + readonly observedAtMs: number; +} + +/** Existing regular text files only; stale revisions leave the buffer unsaved. */ +export interface FileWriteRequest { + readonly target: FileTarget; + readonly pathBase64: FilePath; + readonly text: string; + readonly expectedRevision: FileRevision; +} + +export interface FileWriteResponse { + readonly pathBase64: FilePath; + readonly revision: FileRevision; + readonly sizeBytes: number; + readonly modifiedAtMs?: number; + readonly writtenAtMs: number; +} diff --git a/apps/desktop/src/ipc/file-client.test.ts b/apps/desktop/src/ipc/file-client.test.ts new file mode 100644 index 0000000..e90800f --- /dev/null +++ b/apps/desktop/src/ipc/file-client.test.ts @@ -0,0 +1,226 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const transport = vi.hoisted(() => ({ invoke: vi.fn() })); +vi.mock("@tauri-apps/api/core", () => ({ invoke: transport.invoke })); +vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn() })); +vi.mock("@tauri-apps/plugin-opener", () => ({ openPath: vi.fn() })); + +import { createMockIpcClient } from "../test/mockIpc"; +import { createTauriIpcClient, IpcError } from "./client"; +import type { FileListRequest, FileReadRequest, FileWriteRequest } from "./domain"; +import type { RequestEnvelope } from "./types"; + +const REVISION = `v1:${"a".repeat(64)}`; +const NEXT_REVISION = `v1:${"b".repeat(64)}`; +const BYTES = "name-\xff\\file:part.txt"; +const PATH = globalThis.btoa(BYTES); +const TEXT = "\u{feff}olá 🦀\r\nlast line without newline"; +const NOW = 1_787_941_200_000; +const READ: FileReadRequest = { + target: { kind: "session", sessionId: "0198f000-0000-7000-8000-000000000001" }, + pathBase64: PATH, +}; +const WRITE: FileWriteRequest = { + target: { kind: "worktree", worktreeId: "0198f000-0000-7000-8000-000000000002" }, + pathBase64: PATH, + text: TEXT, + expectedRevision: REVISION, +}; +const LIST: FileListRequest = { + target: { kind: "project", projectId: "0198f000-0000-7000-8000-000000000003" }, + pathBase64: "", +}; +const READ_RESPONSE = { + pathBase64: PATH, + text: TEXT, + revision: REVISION, + sizeBytes: new TextEncoder().encode(TEXT).byteLength, + observedAtMs: NOW, +}; +const WRITE_RESPONSE = { + pathBase64: PATH, + revision: NEXT_REVISION, + sizeBytes: READ_RESPONSE.sizeBytes, + writtenAtMs: NOW, +}; +const ENTRY = { + pathBase64: PATH, + displayName: "name-\\xff\\file:part.txt", + kind: "file", +}; + +/** Use the production envelope code and mock only the native transport boundary. */ +function respond(data: unknown): void { + transport.invoke.mockImplementation(async (command: string, args: { request: RequestEnvelope }) => { + expect(command).toBe("daemon_request"); + return { + kind: "response", version: 1, requestId: args.request.requestId, + status: "success", data, + }; + }); +} + +describe("file IPC transport and decoding", () => { + beforeEach(() => { + transport.invoke.mockReset(); + }); + + it("preserves registered targets, byte-exact identifiers, revisions and UTF-8 text across the generic transport", async () => { + const client = createTauriIpcClient(); + const page = { entries: [ENTRY], nextAfterNameBase64: PATH, observedAtMs: NOW }; + respond(page); + const listInput = { ...LIST, limit: 25, afterNameBase64: globalThis.btoa("earlier") }; + expect(await client.listFiles(listInput)).toEqual(page); + + respond({ ...READ_RESPONSE, modifiedAtMs: -1_000 }); + expect(await client.readFile(READ)).toEqual({ ...READ_RESPONSE, modifiedAtMs: -1_000 }); + + respond(WRITE_RESPONSE); + const written = await client.writeFile(WRITE); + expect(written).toEqual(WRITE_RESPONSE); + expect(written).not.toHaveProperty("modifiedAtMs"); + expect(transport.invoke.mock.calls.map(([command, args]) => ({ + command, + method: args.request.method, + payload: args.request.payload, + }))).toEqual([ + { command: "daemon_request", method: "file.list", payload: listInput }, + { command: "daemon_request", method: "file.read", payload: READ }, + { command: "daemon_request", method: "file.write", payload: WRITE }, + ]); + expect(globalThis.atob(written.pathBase64)).toBe(BYTES); + expect(transport.invoke.mock.calls[2][1].request.payload.text).toBe(TEXT); + }); + + it("preserves daemon error messages and conflict/durability metadata without adding file contents", async () => { + const client = createTauriIpcClient(); + for (const code of ["file_conflict", "file_durability_uncertain"]) { + const error = { + code, + message: "Refresh this file before retrying.", + action: "Read the current revision.", + details: { currentRevision: NEXT_REVISION, writeApplied: code === "file_durability_uncertain" }, + }; + transport.invoke.mockImplementation(async (_command: string, args: { request: RequestEnvelope }) => ({ + kind: "response", version: 1, requestId: args.request.requestId, status: "error", error, + })); + try { + await client.writeFile(WRITE); + expect.fail("a failed save must not become an acknowledgement"); + } catch (caught) { + expect(caught).toBeInstanceOf(IpcError); + expect(caught).toMatchObject(error); + expect(JSON.stringify(caught)).not.toContain(TEXT); + } + } + expect(transport.invoke).toHaveBeenCalledTimes(2); + }); + + it.each([ + ["noncanonical base64", { pathBase64: "YQ" }], + ["nonzero padding bits", { pathBase64: "YR==" }], + ["traversal", { pathBase64: globalThis.btoa("../private") }], + ["absolute path", { pathBase64: globalThis.btoa("/private") }], + ["empty path", { pathBase64: "" }], + ["different file", { pathBase64: globalThis.btoa("another.txt") }], + ["oversized path", { pathBase64: globalThis.btoa("a".repeat(4_097)) }], + ["revision", { revision: `v1:${"A".repeat(64)}` }], + ["NUL text", { text: "private-file-body\0", sizeBytes: 18 }], + ["unpaired surrogate", { text: "\ud800", sizeBytes: 3 }], + ["UTF-8 byte bound", { text: "é".repeat(65_537), sizeBytes: 131_074 }], + ["byte count mismatch", { sizeBytes: 1 }], + ["fractional byte count", { sizeBytes: 1.5 }], + ["null modification time", { modifiedAtMs: null }], + ["unsafe timestamp", { observedAtMs: Number.MAX_SAFE_INTEGER + 1 }], + ])("rejects a malformed read response: %s", async (_label, change) => { + respond({ ...READ_RESPONSE, ...change }); + await expect(createTauriIpcClient().readFile(READ)).rejects.toMatchObject({ code: "invalid_ipc_payload" }); + }); + + it("contract errors never quote malformed field values or file text", async () => { + const secret = "private-file-body-not-for-diagnostics"; + respond({ ...READ_RESPONSE, text: `${secret}\0`, sizeBytes: secret.length + 1 }); + try { + await createTauriIpcClient().readFile(READ); + expect.fail("NUL text must be rejected"); + } catch (error) { + expect(error).toBeInstanceOf(IpcError); + expect(String(error)).not.toContain(secret); + expect(JSON.stringify(error)).not.toContain(secret); + } + }); + + it("accepts the exact 128 KiB UTF-8 boundary including worst-case JSON escapes", async () => { + const text = "\u0001".repeat(128 * 1_024); + respond({ ...READ_RESPONSE, text, sizeBytes: 128 * 1_024 }); + const response = await createTauriIpcClient().readFile(READ); + expect(response.text).toBe(text); + expect(response.sizeBytes).toBe(128 * 1_024); + }); + + it.each([ + ["another path", { pathBase64: globalThis.btoa("different.txt") }], + ["different byte count", { sizeBytes: WRITE_RESPONSE.sizeBytes + 1 }], + ["unbounded byte count", { sizeBytes: 131_073 }], + ["invalid revision", { revision: "invalid" }], + ["negative publication time", { writtenAtMs: -1 }], + ])("refuses an inconsistent save acknowledgement: %s", async (_label, change) => { + respond({ ...WRITE_RESPONSE, ...change }); + await expect(createTauriIpcClient().writeFile(WRITE)).rejects.toMatchObject({ code: "invalid_ipc_payload" }); + }); + + it("ignores future entry kinds conservatively and keeps byte ordering independent of display names", async () => { + const entries = [ + { pathBase64: globalThis.btoa("\x80"), displayName: "z", kind: "future_mount" }, + { pathBase64: globalThis.btoa("\xff"), displayName: "a", kind: "symlink" }, + ]; + respond({ entries, observedAtMs: NOW }); + const result = await createTauriIpcClient().listFiles(LIST); + expect(result.entries).toEqual([{ ...entries[0], kind: "other" }, entries[1]]); + expect(result).not.toHaveProperty("nextAfterNameBase64"); + expect(result.entries[0]).not.toHaveProperty("sizeBytes"); + }); + + it.each([ + ["duplicate identifier", { entries: [ENTRY, ENTRY], observedAtMs: NOW }], + ["nonchild path", { entries: [{ ...ENTRY, pathBase64: globalThis.btoa("nested/file.txt") }], observedAtMs: NOW }], + ["empty page with cursor", { entries: [], nextAfterNameBase64: PATH, observedAtMs: NOW }], + ["cursor on another name", { entries: [ENTRY], nextAfterNameBase64: globalThis.btoa("other"), observedAtMs: NOW }], + ["directory cursor", { entries: [ENTRY], nextAfterNameBase64: globalThis.btoa("nested/file"), observedAtMs: NOW }], + ["null cursor", { entries: [ENTRY], nextAfterNameBase64: null, observedAtMs: NOW }], + ["unsafe entry size", { entries: [{ ...ENTRY, sizeBytes: Number.MAX_SAFE_INTEGER + 1 }], observedAtMs: NOW }], + ["raw display control", { entries: [{ ...ENTRY, displayName: "line\nname" }], observedAtMs: NOW }], + ["unrecognized nonstring kind", { entries: [{ ...ENTRY, kind: null }], observedAtMs: NOW }], + ])("rejects a malformed file listing: %s", async (_label, page) => { + respond(page); + await expect(createTauriIpcClient().listFiles(LIST)).rejects.toMatchObject({ code: "invalid_ipc_payload" }); + }); + + it("rejects a page before its exclusive cursor and checks both entry and encoded-byte limits", async () => { + const client = createTauriIpcClient(); + respond({ entries: [ENTRY], observedAtMs: NOW }); + await expect(client.listFiles({ ...LIST, afterNameBase64: PATH })).rejects.toMatchObject({ code: "invalid_ipc_payload" }); + + const entries = Array.from({ length: 201 }, (_, index) => ({ + pathBase64: globalThis.btoa(index.toString().padStart(3, "0")), displayName: "file", kind: "file", + })); + respond({ entries, observedAtMs: NOW }); + await expect(client.listFiles({ ...LIST, limit: 200 })).rejects.toMatchObject({ code: "invalid_ipc_payload" }); + + const directory = "d".repeat(4_000); + respond({ entries: entries.slice(0, 100).map((entry) => ({ + ...entry, pathBase64: globalThis.btoa(`${directory}/${globalThis.atob(entry.pathBase64)}`), + })), observedAtMs: NOW }); + await expect(client.listFiles({ ...LIST, pathBase64: globalThis.btoa(directory) })).rejects.toMatchObject({ code: "invalid_ipc_payload" }); + }); + + it("the injected mock rejects unconfigured editor calls and permits explicit handlers", async () => { + const unhandled = createMockIpcClient(); + await expect(unhandled.listFiles(LIST)).rejects.toThrow("Unexpected IPC call in test: listFiles"); + await expect(unhandled.readFile(READ)).rejects.toThrow("Unexpected IPC call in test: readFile"); + await expect(unhandled.writeFile(WRITE)).rejects.toThrow("Unexpected IPC call in test: writeFile"); + const configured = createMockIpcClient({ handlers: { readFile: async () => READ_RESPONSE } }); + await expect(configured.readFile(READ)).resolves.toEqual(READ_RESPONSE); + expect(configured.readFile).toHaveBeenCalledWith(READ); + }); +}); diff --git a/apps/desktop/src/ipc/file-schema.ts b/apps/desktop/src/ipc/file-schema.ts new file mode 100644 index 0000000..f6a0b1b --- /dev/null +++ b/apps/desktop/src/ipc/file-schema.ts @@ -0,0 +1,197 @@ +import type { + FileEntry, + FileListRequest, + FileListResponse, + FileReadRequest, + FileReadResponse, + FileWriteRequest, + FileWriteResponse, +} from "./domain"; +import { + IpcContractError, + requireArray, + requireRecord, + requireString, +} from "./schema"; + +const MAX_PATH_BYTES = 4_096; +const MAX_TEXT_BYTES = 128 * 1_024; +const MAX_PAGE_BYTES = 512 * 1_024; +const MAX_PAGE_ENTRIES = 200; +const encoder = new TextEncoder(); + +/** Decode exact Unix bytes as a binary string, never as a display filename. */ +function pathBytes(value: unknown, allowRoot = false, singleName = false): string { + const encoded = requireString(value, "file path identifier"); + if (encoded.length > Math.ceil(MAX_PATH_BYTES / 3) * 4) { + throw new IpcContractError("File path identifier exceeds its byte limit"); + } + let bytes: string; + try { + bytes = globalThis.atob(encoded); + } catch { + throw new IpcContractError("File path identifier is not canonical base64"); + } + if (globalThis.btoa(bytes) !== encoded || bytes.length > MAX_PATH_BYTES) { + throw new IpcContractError("File path identifier is not canonical base64"); + } + if (allowRoot && bytes === "") return bytes; + if ( + bytes.includes("\0") || + (singleName && bytes.includes("/")) || + bytes.split("/").some((component) => + component === "" || component === "." || component === ".." + ) + ) { + throw new IpcContractError("File path identifier must name a relative child"); + } + return bytes; +} + +/** Reject values that cannot be represented exactly by the JavaScript client. */ +function integer(value: unknown, label: string, minimum = 0, maximum = Number.MAX_SAFE_INTEGER): number { + if (typeof value !== "number" || !Number.isSafeInteger(value) || value < minimum || value > maximum) { + throw new IpcContractError(`${label} must be a bounded safe integer`); + } + return value; +} + +/** File modification times may precede 1970; absent times stay omitted. */ +function modifiedTime(row: Record): { readonly modifiedAtMs?: number } { + return row.modifiedAtMs === undefined ? {} : { + modifiedAtMs: integer(row.modifiedAtMs, "file modification timestamp", Number.MIN_SAFE_INTEGER), + }; +} + +/** Keep Rust UTF-8 strings exact rather than repairing lone UTF-16 surrogates. */ +function isUnicodeScalarText(text: string): boolean { + for (let index = 0; index < text.length; index += 1) { + const unit = text.charCodeAt(index); + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = text.charCodeAt(index + 1); + if (!(next >= 0xdc00 && next <= 0xdfff)) return false; + index += 1; + } else if (unit >= 0xdc00 && unit <= 0xdfff) { + return false; + } + } + return true; +} + +/** Validate text size and encoding without ever echoing the file contents. */ +function textBytes(value: unknown): { readonly text: string; readonly sizeBytes: number } { + const text = requireString(value, "file text"); + if (text.length > MAX_TEXT_BYTES || text.includes("\0") || !isUnicodeScalarText(text)) { + throw new IpcContractError("File text exceeds its limit or is not valid UTF-8 text"); + } + const sizeBytes = encoder.encode(text).byteLength; + if (sizeBytes > MAX_TEXT_BYTES) { + throw new IpcContractError("File text exceeds its UTF-8 byte limit"); + } + return { text, sizeBytes }; +} + +/** Revisions are opaque; the frontend validates their versioned wire shape. */ +function revision(value: unknown): string { + const revision = requireString(value, "file revision"); + if (!/^v1:[0-9a-f]{64}$/.test(revision)) { + throw new IpcContractError("File revision has an invalid format"); + } + return revision; +} + +/** Decode an observed entry without inferring access rights from its kind. */ +function entry(value: unknown): FileEntry { + const row = requireRecord(value, "file entry"); + const pathBase64 = requireString(row.pathBase64, "file entry path"); + pathBytes(pathBase64); + const displayName = requireString(row.displayName, "file display name"); + if ( + displayName.length === 0 || displayName.length > MAX_PATH_BYTES * 4 || + /\p{Cc}/u.test(displayName) || !isUnicodeScalarText(displayName) || + encoder.encode(displayName).byteLength > MAX_PATH_BYTES * 4 + ) { + throw new IpcContractError("File display name is not bounded escaped text"); + } + const observedKind = requireString(row.kind, "file entry kind"); + const kind = observedKind === "file" || observedKind === "directory" || observedKind === "symlink" + ? observedKind : "other"; + return { + pathBase64, + displayName, + kind, + ...(row.sizeBytes === undefined ? {} : { sizeBytes: integer(row.sizeBytes, "file entry size") }), + ...modifiedTime(row), + }; +} + +/** Validate byte ordering, direct children, continuation and the encoded page limit. */ +export function decodeFileListResponse(value: unknown, input: FileListRequest): FileListResponse { + const row = requireRecord(value, "file list response"); + const rawEntries = requireArray(row.entries, "file entries"); + const limit = integer(input.limit ?? 100, "file page limit", 1, MAX_PAGE_ENTRIES); + if (rawEntries.length > limit) throw new IpcContractError("File page exceeds its entry limit"); + const directory = pathBytes(input.pathBase64, true); + let previous = input.afterNameBase64 === undefined ? undefined : pathBytes(input.afterNameBase64, false, true); + const entries = rawEntries.map((value) => { + const decoded = entry(value); + const bytes = pathBytes(decoded.pathBase64); + const slash = bytes.lastIndexOf("/"); + const parent = slash < 0 ? "" : bytes.slice(0, slash); + const name = bytes.slice(slash + 1); + if (parent !== directory || (previous !== undefined && name <= previous)) { + throw new IpcContractError("File page contains an unrelated, duplicate or unordered entry"); + } + previous = name; + return decoded; + }); + const next = row.nextAfterNameBase64; + if (next !== undefined && (entries.length === 0 || pathBytes(next, false, true) !== previous)) { + throw new IpcContractError("File page continuation does not identify its final entry"); + } + const response: FileListResponse = { + entries, + ...(next === undefined ? {} : { nextAfterNameBase64: requireString(next, "file continuation") }), + observedAtMs: integer(row.observedAtMs, "file observation timestamp"), + }; + if (encoder.encode(JSON.stringify(response)).byteLength > MAX_PAGE_BYTES) { + throw new IpcContractError("File page exceeds its encoded response limit"); + } + return response; +} + +/** Validate the exact content and identity returned for the requested file. */ +export function decodeFileReadResponse(value: unknown, input: FileReadRequest): FileReadResponse { + const row = requireRecord(value, "file read response"); + const pathBase64 = requireString(row.pathBase64, "file read path"); + pathBytes(pathBase64); + if (pathBase64 !== input.pathBase64) throw new IpcContractError("File read returned another identifier"); + const content = textBytes(row.text); + const sizeBytes = integer(row.sizeBytes, "file text size", 0, MAX_TEXT_BYTES); + if (sizeBytes !== content.sizeBytes) throw new IpcContractError("File size does not match its UTF-8 text"); + return { + pathBase64, + text: content.text, + revision: revision(row.revision), + sizeBytes, + ...modifiedTime(row), + observedAtMs: integer(row.observedAtMs, "file observation timestamp"), + }; +} + +/** A save acknowledgement must identify the exact path and submitted byte count. */ +export function decodeFileWriteResponse(value: unknown, input: FileWriteRequest): FileWriteResponse { + const row = requireRecord(value, "file write response"); + const pathBase64 = requireString(row.pathBase64, "file write path"); + pathBytes(pathBase64); + if (pathBase64 !== input.pathBase64) throw new IpcContractError("File save returned another identifier"); + const sizeBytes = integer(row.sizeBytes, "saved file size", 0, MAX_TEXT_BYTES); + if (sizeBytes !== textBytes(input.text).sizeBytes) throw new IpcContractError("File save size differs from the submitted text"); + return { + pathBase64, + revision: revision(row.revision), + sizeBytes, + ...modifiedTime(row), + writtenAtMs: integer(row.writtenAtMs, "file publication timestamp"), + }; +} diff --git a/apps/desktop/src/ipc/methods.ts b/apps/desktop/src/ipc/methods.ts index 530b06e..81a2d7b 100644 --- a/apps/desktop/src/ipc/methods.ts +++ b/apps/desktop/src/ipc/methods.ts @@ -28,6 +28,9 @@ export const IPC_METHODS = [ "worktree.list", "worktree.prepare_remove", "worktree.remove", + "file.list", + "file.read", + "file.write", "diagnostics.get", "knowledge.list", "knowledge.save", diff --git a/apps/desktop/src/test/mockIpc.ts b/apps/desktop/src/test/mockIpc.ts index c5bdb6a..6a650cd 100644 --- a/apps/desktop/src/test/mockIpc.ts +++ b/apps/desktop/src/test/mockIpc.ts @@ -24,6 +24,9 @@ export interface MockIpcClientOptions { /** An injected IPC fake whose unconfigured application calls fail loudly. */ export interface MockIpcClient extends IpcClient { + readonly listFiles: Mock; + readonly readFile: Mock; + readonly writeFile: Mock; readonly listKnowledge: Mock; readonly saveKnowledge: Mock; readonly deleteKnowledge: Mock; @@ -90,6 +93,9 @@ export function createMockIpcClient( return { platform: options.platform ?? "linux", + listFiles: vi.fn(handlers.listFiles ?? (() => rejectUnhandled("listFiles"))), + readFile: vi.fn(handlers.readFile ?? (() => rejectUnhandled("readFile"))), + writeFile: vi.fn(handlers.writeFile ?? (() => rejectUnhandled("writeFile"))), initialize, subscribe, subscribeTerminal: vi.fn( diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index bea1173..cff9c6b 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true rust-version.workspace = true [dependencies] +base64 = "0.22.1" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" uuid = { version = "1.26.0", features = ["serde", "v7"] } diff --git a/crates/core/src/wire/files.rs b/crates/core/src/wire/files.rs new file mode 100644 index 0000000..2d04977 --- /dev/null +++ b/crates/core/src/wire/files.rs @@ -0,0 +1,514 @@ +//! Pure local file identifiers and editor request/response contracts. + +use std::fmt; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use serde::{Deserialize, Deserializer, Serialize, de}; + +use super::{GitTarget, WireValidationError}; + +/// Maximum decoded length of a relative Unix file identifier. +pub const MAX_FILE_PATH_BYTES: usize = 4_096; +/// Maximum UTF-8 byte length accepted by the first text-editor slice. +pub const MAX_FILE_TEXT_BYTES: usize = 128 * 1_024; +/// Default number of entries requested in one directory page. +pub const DEFAULT_FILE_LIST_LIMIT: u16 = 100; +/// Maximum number of entries requested in one directory page. +pub const MAX_FILE_LIST_LIMIT: u16 = 200; + +/// A registered project, session or worktree whose root the daemon resolves. +pub type FileTarget = GitTarget; + +/// Byte-exact relative Unix path represented as canonical padded base64. +/// +/// Empty bytes identify the target root for directory listing only. This type +/// does not interpret Unix filenames as Git pathspecs or Windows paths. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(transparent)] +pub struct FilePath { + encoded: String, + #[serde(skip)] + bytes: Vec, +} + +impl FilePath { + /// Validates an encoded relative path without accessing the filesystem. + /// + /// # Errors + /// + /// Rejects noncanonical base64, oversized paths, NUL, absolute paths, empty + /// components and the traversal components `.` or `..`. + pub fn try_new(encoded: impl Into) -> Result { + let encoded = encoded.into(); + if encoded.len() > MAX_FILE_PATH_BYTES.div_ceil(3) * 4 { + return Err(path_error("must decode to at most 4096 bytes")); + } + let bytes = STANDARD + .decode(&encoded) + .map_err(|_| path_error("must use canonical padded base64"))?; + if STANDARD.encode(&bytes) != encoded { + return Err(path_error("must use canonical padded base64")); + } + validate_path_bytes(&bytes)?; + Ok(Self { encoded, bytes }) + } + + /// Validates exact Unix path bytes and encodes their wire identifier. + /// + /// # Errors + /// + /// Returns an error for a path violating the relative path invariants. + pub fn try_from_bytes(bytes: impl AsRef<[u8]>) -> Result { + let bytes = bytes.as_ref(); + validate_path_bytes(bytes)?; + Ok(Self { + encoded: STANDARD.encode(bytes), + bytes: bytes.to_vec(), + }) + } + + /// Returns the root identifier accepted by directory listing. + #[must_use] + pub fn root() -> Self { + Self { + encoded: String::new(), + bytes: Vec::new(), + } + } + + /// Returns the exact decoded bytes; display text must not replace them. + #[must_use] + pub fn as_bytes(&self) -> &[u8] { + &self.bytes + } + + /// Returns the canonical base64 wire value. + #[must_use] + pub fn as_str(&self) -> &str { + &self.encoded + } + + /// Returns whether this path refers to the registered root. + #[must_use] + pub fn is_root(&self) -> bool { + self.bytes.is_empty() + } +} + +impl<'de> Deserialize<'de> for FilePath { + fn deserialize>(deserializer: D) -> Result { + Self::try_new(String::deserialize(deserializer)?).map_err(de::Error::custom) + } +} + +/// A nonempty single Unix filename used as a directory pagination cursor. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(transparent)] +pub struct FileName(FilePath); + +impl FileName { + /// Validates a canonical base64 filename without directory components. + /// + /// # Errors + /// + /// Rejects invalid paths, the root, and names containing a slash. + pub fn try_new(encoded: impl Into) -> Result { + Self::from_path(FilePath::try_new(encoded)?) + } + + /// Encodes and validates an exact Unix filename. + /// + /// # Errors + /// + /// Rejects invalid paths, the root, and names containing a slash. + pub fn try_from_bytes(bytes: impl AsRef<[u8]>) -> Result { + Self::from_path(FilePath::try_from_bytes(bytes)?) + } + + fn from_path(path: FilePath) -> Result { + if path.is_root() || path.as_bytes().contains(&b'/') { + return Err(WireValidationError::new( + "afterNameBase64", + "must identify one nonempty filename", + )); + } + Ok(Self(path)) + } + + /// Returns exact filename bytes for lexicographic cursor comparisons. + #[must_use] + pub fn as_bytes(&self) -> &[u8] { + self.0.as_bytes() + } + + /// Returns the canonical base64 cursor. + #[must_use] + pub fn as_str(&self) -> &str { + self.0.as_str() + } +} + +impl<'de> Deserialize<'de> for FileName { + fn deserialize>(deserializer: D) -> Result { + Self::try_new(String::deserialize(deserializer)?).map_err(de::Error::custom) + } +} + +/// Opaque versioned content/identity digest produced by the daemon. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(transparent)] +pub struct FileRevision(String); + +impl FileRevision { + /// Validates the wire shape without inspecting or hashing a file. + /// + /// # Errors + /// + /// Requires the exact prefix `v1:` followed by 64 lowercase hex digits. + pub fn try_new(value: impl Into) -> Result { + let value = value.into(); + if value.len() != 67 + || !value.starts_with("v1:") + || !value.as_bytes()[3..] + .iter() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)) + { + return Err(WireValidationError::new( + "revision", + "must use v1: followed by 64 lowercase hexadecimal digits", + )); + } + Ok(Self(value)) + } + + /// Returns the opaque revision string. + #[must_use] + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl<'de> Deserialize<'de> for FileRevision { + fn deserialize>(deserializer: D) -> Result { + Self::try_new(String::deserialize(deserializer)?).map_err(de::Error::custom) + } +} + +/// Request to list one registered target directory with a bounded page size. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileListRequest { + /// Registered target whose authoritative root the daemon resolves. + pub target: FileTarget, + /// Relative directory; an empty identifier means the target root. + pub path_base64: FilePath, + /// Requested page size, between one and 200 inclusive. + pub limit: u16, + /// Exclusive cursor compared using exact Unix filename bytes. + #[serde(skip_serializing_if = "Option::is_none")] + pub after_name_base64: Option, +} + +impl FileListRequest { + /// Builds a bounded directory request, using 100 when the limit is absent. + /// + /// # Errors + /// + /// Rejects a zero page size or a size above 200 entries. + pub fn try_new( + target: FileTarget, + path_base64: FilePath, + limit: Option, + after_name_base64: Option, + ) -> Result { + let limit = limit.unwrap_or(DEFAULT_FILE_LIST_LIMIT); + if !(1..=MAX_FILE_LIST_LIMIT).contains(&limit) { + return Err(WireValidationError::new( + "limit", + "must be between 1 and 200", + )); + } + Ok(Self { + target, + path_base64, + limit, + after_name_base64, + }) + } +} + +impl<'de> Deserialize<'de> for FileListRequest { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields, rename_all = "camelCase")] + struct Payload { + target: FileTarget, + path_base64: FilePath, + #[serde(default)] + limit: Option, + #[serde(default)] + after_name_base64: Option, + } + let value = Payload::deserialize(deserializer)?; + Self::try_new( + value.target, + value.path_base64, + value.limit, + value.after_name_base64, + ) + .map_err(de::Error::custom) + } +} + +/// Request to read a bounded existing text file. +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileReadRequest { + /// Registered target whose authoritative root the daemon resolves. + pub target: FileTarget, + /// Nonempty relative file identifier. + pub path_base64: FilePath, +} + +impl FileReadRequest { + /// Builds a read request for a non-root identifier. + /// + /// # Errors + /// + /// Rejects the empty root identifier. + pub fn try_new(target: FileTarget, path_base64: FilePath) -> Result { + validate_leaf_path(&path_base64)?; + Ok(Self { + target, + path_base64, + }) + } +} + +impl<'de> Deserialize<'de> for FileReadRequest { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields, rename_all = "camelCase")] + struct Payload { + target: FileTarget, + path_base64: FilePath, + } + let value = Payload::deserialize(deserializer)?; + Self::try_new(value.target, value.path_base64).map_err(de::Error::custom) + } +} + +/// Request to replace an existing text file after an optimistic revision check. +#[derive(Clone, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FileWriteRequest { + /// Registered target whose authoritative root the daemon resolves. + pub target: FileTarget, + /// Nonempty relative file identifier; this operation never creates a file. + pub path_base64: FilePath, + /// Exact replacement UTF-8 text, without implicit newline or BOM changes. + pub text: String, + /// Revision that the caller read before editing. + pub expected_revision: FileRevision, +} + +impl FileWriteRequest { + /// Builds a bounded request without accessing the file or interpreting text. + /// + /// # Errors + /// + /// Rejects the root, NUL-containing text or more than 128 KiB of UTF-8 bytes. + pub fn try_new( + target: FileTarget, + path_base64: FilePath, + text: impl Into, + expected_revision: FileRevision, + ) -> Result { + validate_leaf_path(&path_base64)?; + let text = text.into(); + validate_text(&text)?; + Ok(Self { + target, + path_base64, + text, + expected_revision, + }) + } +} + +impl fmt::Debug for FileWriteRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("FileWriteRequest") + .field("target", &self.target) + .field("path_base64", &self.path_base64) + .field("text_bytes", &self.text.len()) + .field("expected_revision", &self.expected_revision) + .finish() + } +} + +impl<'de> Deserialize<'de> for FileWriteRequest { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(deny_unknown_fields, rename_all = "camelCase")] + struct Payload { + target: FileTarget, + path_base64: FilePath, + text: String, + expected_revision: FileRevision, + } + let value = Payload::deserialize(deserializer)?; + Self::try_new( + value.target, + value.path_base64, + value.text, + value.expected_revision, + ) + .map_err(de::Error::custom) + } +} + +/// Observed entry type; visibility does not grant permission to open an entry. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum FileEntryKind { + /// A regular file, which can still be oversized, binary or inaccessible. + File, + /// A directory that may be listed through descriptor-relative traversal. + Directory, + /// A visible symlink that the file service does not follow. + Symlink, + /// A device, socket, FIFO or future unsupported entry kind. + #[serde(other)] + Other, +} + +/// One observed directory entry with separate identity and display text. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct FileEntry { + /// Exact relative identifier, never reconstructed from the display name. + pub path_base64: FilePath, + /// Escaped display text suitable for rendering as text, not HTML. + pub display_name: String, + /// Observed filesystem kind. + pub kind: FileEntryKind, + /// Observed file length when meaningful for the entry. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub size_bytes: Option, + /// Modification time in Unix epoch milliseconds when representable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub modified_at_ms: Option, +} + +/// A bounded observed directory page, not a filesystem transaction snapshot. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct FileListResponse { + /// Directory entries in exact filename-byte order. + pub entries: Vec, + /// Cursor for the next page; absence means no more entries were observed. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_after_name_base64: Option, + /// Observation time in Unix epoch milliseconds. + pub observed_at_ms: i64, +} + +/// Exact text and revision observed through a descriptor-safe bounded read. +#[derive(Clone, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct FileReadResponse { + /// Exact relative file identifier. + pub path_base64: FilePath, + /// File contents, preserving line endings and any BOM. + pub text: String, + /// Opaque revision derived from content and filesystem identity. + pub revision: FileRevision, + /// Observed UTF-8 byte length. + pub size_bytes: u64, + /// Modification time in Unix epoch milliseconds when representable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub modified_at_ms: Option, + /// Observation time in Unix epoch milliseconds. + pub observed_at_ms: i64, +} + +impl fmt::Debug for FileReadResponse { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("FileReadResponse") + .field("path_base64", &self.path_base64) + .field("text_bytes", &self.text.len()) + .field("revision", &self.revision) + .field("size_bytes", &self.size_bytes) + .field("modified_at_ms", &self.modified_at_ms) + .field("observed_at_ms", &self.observed_at_ms) + .finish() + } +} + +/// Identity and revision of an atomically published text save. +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(deny_unknown_fields, rename_all = "camelCase")] +pub struct FileWriteResponse { + /// Exact relative file identifier. + pub path_base64: FilePath, + /// Revision of the published file. + pub revision: FileRevision, + /// Published UTF-8 byte length. + pub size_bytes: u64, + /// Modification time in Unix epoch milliseconds when representable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub modified_at_ms: Option, + /// Publication time in Unix epoch milliseconds. + pub written_at_ms: i64, +} + +fn path_error(message: &'static str) -> WireValidationError { + WireValidationError::new("pathBase64", message) +} + +fn validate_path_bytes(bytes: &[u8]) -> Result<(), WireValidationError> { + if bytes.len() > MAX_FILE_PATH_BYTES { + return Err(path_error("must decode to at most 4096 bytes")); + } + if bytes.contains(&0) { + return Err(path_error("must not contain a NUL byte")); + } + if !bytes.is_empty() + && bytes + .split(|byte| *byte == b'/') + .any(|component| component.is_empty() || component == b"." || component == b"..") + { + return Err(path_error( + "must use nonempty relative child components without traversal", + )); + } + Ok(()) +} + +fn validate_leaf_path(path: &FilePath) -> Result<(), WireValidationError> { + if path.is_root() { + return Err(path_error( + "must identify a file rather than the target root", + )); + } + Ok(()) +} + +fn validate_text(text: &str) -> Result<(), WireValidationError> { + if text.len() > MAX_FILE_TEXT_BYTES { + return Err(WireValidationError::new( + "text", + "must be at most 131072 UTF-8 bytes", + )); + } + if text.contains('\0') { + return Err(WireValidationError::new( + "text", + "must not contain a NUL byte", + )); + } + Ok(()) +} diff --git a/crates/core/src/wire/method.rs b/crates/core/src/wire/method.rs index b067015..1d83f08 100644 --- a/crates/core/src/wire/method.rs +++ b/crates/core/src/wire/method.rs @@ -62,6 +62,13 @@ pub const WORKTREE_PREPARE_REMOVE: &str = "worktree.prepare_remove"; /// Remove a managed worktree after token-bound state confirmation. pub const WORKTREE_REMOVE: &str = "worktree.remove"; +/// List a bounded directory under a registered file target. +pub const FILE_LIST: &str = "file.list"; +/// Read bounded UTF-8 text with an opaque revision. +pub const FILE_READ: &str = "file.read"; +/// Atomically save an existing text file after checking its revision. +pub const FILE_WRITE: &str = "file.write"; + /// Read a sanitized local diagnostic snapshot. pub const DIAGNOSTICS_GET: &str = "diagnostics.get"; @@ -102,6 +109,9 @@ pub const ALL: &[&str] = &[ WORKTREE_LIST, WORKTREE_PREPARE_REMOVE, WORKTREE_REMOVE, + FILE_LIST, + FILE_READ, + FILE_WRITE, DIAGNOSTICS_GET, KNOWLEDGE_LIST, KNOWLEDGE_SAVE, diff --git a/crates/core/src/wire/mod.rs b/crates/core/src/wire/mod.rs index a5e6657..6f99b2d 100644 --- a/crates/core/src/wire/mod.rs +++ b/crates/core/src/wire/mod.rs @@ -8,6 +8,7 @@ pub mod event_name; pub mod method; mod event; +mod files; mod git_path; mod request; mod response; @@ -25,6 +26,11 @@ pub use event::{ SessionExitedEvent, SessionOutputEvent, SessionOutputGapEvent, SessionReplayCompleteEvent, SessionStatusChangedEvent, WorktreeChangedEvent, WorktreeRemovedEvent, }; +pub use files::{ + DEFAULT_FILE_LIST_LIMIT, FileEntry, FileEntryKind, FileListRequest, FileListResponse, FileName, + FilePath, FileReadRequest, FileReadResponse, FileRevision, FileTarget, FileWriteRequest, + FileWriteResponse, MAX_FILE_LIST_LIMIT, MAX_FILE_PATH_BYTES, MAX_FILE_TEXT_BYTES, +}; pub use git_path::GitRelativePath; pub use request::{ AgentCommand, AgentCustomCreateRequest, AgentCustomRemoveRequest, AgentCustomUpdateRequest, diff --git a/crates/core/tests/file_contract.rs b/crates/core/tests/file_contract.rs new file mode 100644 index 0000000..8b7ba8d --- /dev/null +++ b/crates/core/tests/file_contract.rs @@ -0,0 +1,339 @@ +//! File IPC boundary tests: exact Unix identifiers, bounds and optimistic saves. + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use cli_master_core::wire::{ + DEFAULT_FILE_LIST_LIMIT, FileEntry, FileEntryKind, FileListRequest, FileListResponse, FileName, + FilePath, FileReadRequest, FileReadResponse, FileRevision, FileTarget, FileWriteRequest, + FileWriteResponse, MAX_FILE_LIST_LIMIT, MAX_FILE_PATH_BYTES, MAX_FILE_TEXT_BYTES, +}; +use cli_master_core::{ProjectId, SessionId, WorktreeId}; +use serde_json::{Value, json}; + +fn target() -> FileTarget { + FileTarget::Project { + project_id: ProjectId::new(), + } +} + +fn revision() -> FileRevision { + FileRevision::try_new(format!("v1:{}", "0123456789abcdef".repeat(4))).unwrap() +} + +fn path() -> FilePath { + FilePath::try_from_bytes(b"src/editor.rs").unwrap() +} + +fn write_payload(text: &str) -> Value { + json!({ + "target": target(), + "pathBase64": path(), + "text": text, + "expectedRevision": revision(), + }) +} + +#[test] +fn paths_round_trip_exact_unix_bytes_without_display_normalization() { + let paths: &[&[u8]] = &[ + b"src/ordinary.txt", + b"space in name.txt", + "açúcar/日本語.md".as_bytes(), + b"binary-name-\xff\xfe.txt", + b"-option-looking", + b"--", + b"literal\\backslash:colon", + b"C:/still-a-relative-unix-name", + b"line\nbreak\tname", + ]; + for bytes in paths { + let path = FilePath::try_from_bytes(bytes).unwrap(); + let value = serde_json::to_value(&path).unwrap(); + assert_eq!(value, json!(STANDARD.encode(bytes))); + assert_eq!(FilePath::try_new(path.as_str()).unwrap().as_bytes(), *bytes); + assert_eq!(serde_json::from_value::(value).unwrap(), path); + } +} + +#[test] +fn path_boundary_rejects_traversal_and_nul_without_echoing_path_contents() { + let invalid: &[&[u8]] = &[ + b"/absolute", + b"trailing/", + b"empty//component", + b".", + b"..", + b"./child", + b"parent/../child", + b"parent/./child", + b"private-value\0suffix", + ]; + for bytes in invalid { + assert!(FilePath::try_from_bytes(bytes).is_err()); + let error = FilePath::try_new(STANDARD.encode(bytes)).unwrap_err(); + assert!(!error.to_string().contains("private-value")); + assert!(serde_json::from_value::(json!(STANDARD.encode(bytes))).is_err()); + } +} + +#[test] +fn path_limit_counts_decoded_bytes_and_canonical_padding_is_required() { + let maximum = vec![b'x'; MAX_FILE_PATH_BYTES]; + assert_eq!( + FilePath::try_from_bytes(&maximum).unwrap().as_bytes(), + maximum + ); + assert!(FilePath::try_new(STANDARD.encode(&maximum)).is_ok()); + let oversized = vec![b'x'; MAX_FILE_PATH_BYTES + 1]; + assert!(FilePath::try_from_bytes(&oversized).is_err()); + assert!(FilePath::try_new(STANDARD.encode(&oversized)).is_err()); + + for invalid in ["YQ", "YQ=", "YR==", "YWJ=", "YQ==\n", "_w==", "YQ===="] { + assert!(FilePath::try_new(invalid).is_err(), "accepted {invalid:?}"); + } + assert_eq!(FilePath::try_new("/w==").unwrap().as_bytes(), &[0xff]); +} + +#[test] +fn root_is_listable_but_never_a_read_or_write_target() { + let root = FilePath::root(); + assert!(root.is_root()); + assert_eq!(serde_json::to_value(&root).unwrap(), json!("")); + assert_eq!(FilePath::try_new("").unwrap(), root); + assert!(FileListRequest::try_new(target(), root.clone(), None, None).is_ok()); + assert!(FileReadRequest::try_new(target(), root.clone()).is_err()); + assert!(FileWriteRequest::try_new(target(), root, "", revision()).is_err()); + + let payload = json!({ "target": target(), "pathBase64": "" }); + assert!(serde_json::from_value::(payload.clone()).is_ok()); + assert!(serde_json::from_value::(payload).is_err()); + let mut payload = write_payload(""); + payload["pathBase64"] = json!(""); + assert!(serde_json::from_value::(payload).is_err()); +} + +#[test] +fn directory_cursors_are_exact_single_names() { + let cursor = FileName::try_from_bytes(b"-next-\xff.txt").unwrap(); + assert_eq!(cursor.as_bytes(), b"-next-\xff.txt"); + assert_eq!(FileName::try_new(cursor.as_str()).unwrap(), cursor); + assert_eq!( + serde_json::from_value::(json!(cursor)).unwrap(), + cursor + ); + for invalid in [&b""[..], b".", b"..", b"dir/leaf", b"a\0b"] { + assert!(FileName::try_from_bytes(invalid).is_err()); + assert!(FileName::try_new(STANDARD.encode(invalid)).is_err()); + } +} + +#[test] +fn list_defaults_and_limits_are_enforced_at_the_wire_boundary() { + let payload = json!({ "target": target(), "pathBase64": "" }); + let request: FileListRequest = serde_json::from_value(payload.clone()).unwrap(); + assert_eq!(request.limit, DEFAULT_FILE_LIST_LIMIT); + assert!(request.after_name_base64.is_none()); + + for limit in [1, MAX_FILE_LIST_LIMIT] { + let mut value = payload.clone(); + value["limit"] = json!(limit); + assert_eq!( + serde_json::from_value::(value) + .unwrap() + .limit, + limit + ); + } + for invalid in [json!(0), json!(201), json!(-1), json!(1.5), json!(65536)] { + let mut value = payload.clone(); + value["limit"] = invalid; + assert!(serde_json::from_value::(value).is_err()); + } + assert!(FileListRequest::try_new(target(), FilePath::root(), Some(0), None).is_err()); + assert!(FileListRequest::try_new(target(), FilePath::root(), Some(201), None).is_err()); + let mut value = payload; + value["afterNameBase64"] = json!(STANDARD.encode(b"dir/leaf")); + assert!(serde_json::from_value::(value).is_err()); +} + +#[test] +fn targets_are_registered_ids_and_reject_arbitrary_root_overrides() { + for target in [ + target(), + FileTarget::Session { + session_id: SessionId::new(), + }, + FileTarget::Worktree { + worktree_id: WorktreeId::new(), + }, + ] { + let request = FileReadRequest::try_new(target, path()).unwrap(); + let payload = serde_json::to_value(&request).unwrap(); + assert_eq!( + serde_json::from_value::(payload.clone()).unwrap(), + request + ); + assert!(payload.get("pathBase64").unwrap().is_string()); + assert!(payload.get("cwd").is_none()); + let mut overridden = payload; + overridden["target"]["path"] = json!("/tmp/unregistered"); + assert!(serde_json::from_value::(overridden).is_err()); + } + for invalid in [ + json!({ "kind": "path", "path": "/tmp/unregistered" }), + json!({ "kind": "project", "projectId": "codex" }), + json!({ "kind": "worktree", "worktreeId": "invalid-uuid" }), + ] { + assert!(serde_json::from_value::(invalid).is_err()); + } +} + +#[test] +fn request_payloads_reject_unknown_fields_and_missing_revision() { + let mut read = json!({ "target": target(), "pathBase64": path() }); + read["root"] = json!("/tmp"); + assert!(serde_json::from_value::(read).is_err()); + let mut list = json!({ "target": target(), "pathBase64": "" }); + list["recursive"] = json!(true); + assert!(serde_json::from_value::(list).is_err()); + let mut write = write_payload("content"); + write["force"] = json!(true); + assert!(serde_json::from_value::(write).is_err()); + let mut write = write_payload("content"); + write.as_object_mut().unwrap().remove("expectedRevision"); + assert!(serde_json::from_value::(write).is_err()); +} + +#[test] +fn revision_requires_the_versioned_lowercase_digest_shape() { + let valid = revision(); + assert_eq!( + serde_json::from_value::(json!(valid.as_str())).unwrap(), + valid + ); + for invalid in [ + String::new(), + "v1:".to_owned(), + format!("v1:{}", "a".repeat(63)), + format!("v1:{}", "a".repeat(65)), + format!("v2:{}", "a".repeat(64)), + format!("v1:{}", "A".repeat(64)), + format!("v1:{}", "g".repeat(64)), + format!("v1:{}", "é".repeat(32)), + ] { + assert!(FileRevision::try_new(&invalid).is_err()); + assert!(serde_json::from_value::(json!(invalid)).is_err()); + } +} + +#[test] +fn writes_count_utf8_bytes_and_preserve_line_endings_bom_and_empty_text() { + for content in [ + String::new(), + "\u{feff}first\r\nsecond\r\n".to_owned(), + "é".repeat(MAX_FILE_TEXT_BYTES / 2), + ] { + let request = FileWriteRequest::try_new(target(), path(), &content, revision()).unwrap(); + assert_eq!(request.text, content); + assert_eq!( + serde_json::from_value::(write_payload(&content)) + .unwrap() + .text, + content + ); + } + let oversized = format!("{}x", "é".repeat(MAX_FILE_TEXT_BYTES / 2)); + for invalid in [oversized, "before\0after".to_owned()] { + assert!(FileWriteRequest::try_new(target(), path(), &invalid, revision()).is_err()); + assert!(serde_json::from_value::(write_payload(&invalid)).is_err()); + } +} + +#[test] +fn maximum_valid_text_fits_the_existing_ipc_frame_even_with_json_escaping() { + let request = FileWriteRequest::try_new( + target(), + FilePath::try_from_bytes(vec![b'x'; MAX_FILE_PATH_BYTES]).unwrap(), + "\u{1}".repeat(MAX_FILE_TEXT_BYTES), + revision(), + ) + .unwrap(); + let envelope = cli_master_core::RequestEnvelope::v1("file.write", request); + let encoded = serde_json::to_vec(&envelope).unwrap(); + assert!(encoded.len() < 1024 * 1024); +} + +#[test] +fn response_contracts_preserve_byte_identity_and_epoch_ms_fields() { + let entry = FileEntry { + path_base64: FilePath::try_from_bytes(b"raw-\xff.txt").unwrap(), + display_name: "raw-\\xFF.txt".to_owned(), + kind: FileEntryKind::File, + size_bytes: Some(12), + modified_at_ms: None, + }; + let list = FileListResponse { + entries: vec![entry], + next_after_name_base64: Some(FileName::try_from_bytes(b"raw-\xff.txt").unwrap()), + observed_at_ms: 1_788_566_400_123, + }; + let list_json = serde_json::to_value(&list).unwrap(); + assert_eq!(list_json["observedAtMs"], json!(1_788_566_400_123_i64)); + assert!(list_json["entries"][0].get("modifiedAtMs").is_none()); + assert_eq!( + list_json["entries"][0]["pathBase64"], + json!(STANDARD.encode(b"raw-\xff.txt")) + ); + assert_eq!( + serde_json::from_value::(list_json).unwrap(), + list + ); + + let read = FileReadResponse { + path_base64: path(), + text: "é\r\n".to_owned(), + revision: revision(), + size_bytes: 4, + modified_at_ms: Some(1_788_566_400_100), + observed_at_ms: 1_788_566_400_123, + }; + let value = serde_json::to_value(&read).unwrap(); + assert_eq!(value["text"], json!("é\r\n")); + assert_eq!( + serde_json::from_value::(value).unwrap(), + read + ); + + let write = FileWriteResponse { + path_base64: path(), + revision: revision(), + size_bytes: 4, + modified_at_ms: Some(1_788_566_400_200), + written_at_ms: 1_788_566_400_201, + }; + let value = serde_json::to_value(&write).unwrap(); + assert_eq!(value["writtenAtMs"], json!(1_788_566_400_201_i64)); + assert_eq!( + serde_json::from_value::(value).unwrap(), + write + ); +} + +#[test] +fn unsupported_entry_kinds_stay_noneditable_and_text_is_redacted_from_debug() { + assert_eq!( + serde_json::from_value::(json!("future_device")).unwrap(), + FileEntryKind::Other + ); + let text = "sensitive document contents"; + let write = FileWriteRequest::try_new(target(), path(), text, revision()).unwrap(); + assert!(!format!("{write:?}").contains(text)); + let read = FileReadResponse { + path_base64: path(), + text: text.to_owned(), + revision: revision(), + size_bytes: u64::try_from(text.len()).unwrap(), + modified_at_ms: None, + observed_at_ms: 1_788_566_400_123, + }; + assert!(!format!("{read:?}").contains(text)); +} diff --git a/crates/daemon/Cargo.toml b/crates/daemon/Cargo.toml index 475d9d4..13aa856 100644 --- a/crates/daemon/Cargo.toml +++ b/crates/daemon/Cargo.toml @@ -18,6 +18,7 @@ path = "src/main.rs" [dependencies] base64 = "0.22.1" cli-master-core = { path = "../core" } +cli-master-file-metadata = { path = "../file-metadata" } cli-master-git = { path = "../git" } cli-master-session = { path = "../session" } cli-master-storage = { path = "../storage" } @@ -26,6 +27,7 @@ futures-util = { version = "0.3.31", features = ["sink"] } rustix = { version = "1.1.4", features = ["fs", "net", "process"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +sha2 = "0.10.9" thiserror = "2" tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "signal", "sync", "time"] } tokio-util = { version = "0.7", features = ["codec", "rt"] } diff --git a/crates/daemon/src/files/attributes.rs b/crates/daemon/src/files/attributes.rs new file mode 100644 index 0000000..6619878 --- /dev/null +++ b/crates/daemon/src/files/attributes.rs @@ -0,0 +1,82 @@ +//! Only explicitly supported, bounded attributes may survive atomic replacement. + +use std::fs::File; + +use cli_master_core::ApiError; +use rustix::fs::flistxattr; + +use super::error::metadata_unsupported; + +#[derive(Eq, PartialEq)] +pub(super) struct SupportedAttributes { + #[cfg(target_os = "macos")] + provenance: Option>, +} + +#[cfg(target_os = "linux")] +pub(super) fn read_supported(file: &File) -> Result { + let mut names = [0_u8; 1]; + match flistxattr(file, &mut names[..]) { + Ok(0) => Ok(SupportedAttributes {}), + Ok(_) | Err(_) => Err(metadata_unsupported()), + } +} + +#[cfg(target_os = "macos")] +const PROVENANCE_NAME: &str = "com.apple.provenance"; +#[cfg(target_os = "macos")] +const MAX_PROVENANCE_BYTES: usize = 4_096; + +#[cfg(target_os = "macos")] +pub(super) fn read_supported(file: &File) -> Result { + let mut names = [0_u8; PROVENANCE_NAME.len() + 1]; + let length = flistxattr(file, &mut names[..]).map_err(|_| metadata_unsupported())?; + if length == 0 { + return Ok(SupportedAttributes { provenance: None }); + } + // The list must contain exactly this one NUL-terminated name. A longer + // list fails at the syscall bound, including resource forks and ACL xattrs. + if length != names.len() + || &names[..PROVENANCE_NAME.len()] != PROVENANCE_NAME.as_bytes() + || names[PROVENANCE_NAME.len()] != 0 + { + return Err(metadata_unsupported()); + } + let mut value = vec![0_u8; MAX_PROVENANCE_BYTES]; + let length = rustix::fs::fgetxattr(file, PROVENANCE_NAME, &mut value[..]) + .map_err(|_| metadata_unsupported())?; + value.truncate(length); + Ok(SupportedAttributes { + provenance: Some(value), + }) +} + +pub(super) fn preserve(source: &File, destination: &File) -> Result<(), ApiError> { + let original = read_supported(source)?; + let staged = read_supported(destination)?; + if original != staged { + #[cfg(target_os = "macos")] + if let Some(value) = original.provenance.as_ref() { + rustix::fs::fsetxattr( + destination, + PROVENANCE_NAME, + value, + rustix::fs::XattrFlags::empty(), + ) + .map_err(|_| metadata_unsupported())?; + } + // Darwin may report success while retaining an OS-owned provenance + // value. Never infer preservation from the write syscall alone. + if original != read_supported(destination)? { + return Err(metadata_unsupported()); + } + } + verify_preserved(source, destination) +} + +pub(super) fn verify_preserved(source: &File, destination: &File) -> Result<(), ApiError> { + if read_supported(source)? != read_supported(destination)? { + return Err(metadata_unsupported()); + } + Ok(()) +} diff --git a/crates/daemon/src/files/descriptor.rs b/crates/daemon/src/files/descriptor.rs new file mode 100644 index 0000000..012763e --- /dev/null +++ b/crates/daemon/src/files/descriptor.rs @@ -0,0 +1,251 @@ +use std::ffi::{OsStr, OsString}; +use std::fs::File; +use std::io::Read; +use std::os::fd::{AsFd, OwnedFd}; +use std::os::unix::ffi::{OsStrExt, OsStringExt}; +use std::path::{Component, Path}; + +use cli_master_core::ApiError; +use cli_master_core::wire::{FilePath, FileRevision, MAX_FILE_TEXT_BYTES}; +use rustix::fs::{AtFlags, FileType, Mode, OFlags, Stat, fstat, open, openat, statat}; +use sha2::{Digest, Sha256}; + +use super::error::{api, conflict, io_error, target_changed}; + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +pub(super) struct Identity { + pub device: i128, + pub inode: u128, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct Fingerprint { + pub identity: Identity, + pub size: i128, + pub modified_seconds: i128, + pub modified_nanos: i128, + pub changed_seconds: i128, + pub changed_nanos: i128, + pub links: u128, + pub mode: u32, + pub uid: u32, + pub gid: u32, + pub flags: u32, +} + +impl Fingerprint { + #[allow( + clippy::useless_conversion, + reason = "Unix stat field widths differ between Linux and macOS" + )] + pub(super) fn from_stat(stat: &Stat) -> Self { + Self { + identity: identity(stat), + size: stat.st_size.into(), + modified_seconds: stat.st_mtime.into(), + modified_nanos: stat.st_mtime_nsec.into(), + changed_seconds: stat.st_ctime.into(), + changed_nanos: stat.st_ctime_nsec.into(), + links: stat.st_nlink.into(), + mode: stat.st_mode.into(), + uid: stat.st_uid, + gid: stat.st_gid, + #[cfg(target_os = "macos")] + flags: stat.st_flags, + #[cfg(not(target_os = "macos"))] + flags: 0, + } + } + + pub(super) fn modified_at_ms(&self) -> Option { + self.modified_seconds + .checked_mul(1_000)? + .checked_add(self.modified_nanos.checked_div(1_000_000)?)? + .try_into() + .ok() + } + + pub(super) fn revision(&self, bytes: &[u8]) -> Result { + let mut hash = Sha256::new(); + hash.update(b"cli-master-file-revision-v1\0"); + hash.update(self.identity.device.to_be_bytes()); + hash.update(self.identity.inode.to_be_bytes()); + for value in [ + self.size, + self.modified_seconds, + self.modified_nanos, + self.changed_seconds, + self.changed_nanos, + ] { + hash.update(value.to_be_bytes()); + } + hash.update(self.links.to_be_bytes()); + for value in [self.mode, self.uid, self.gid, self.flags] { + hash.update(value.to_be_bytes()); + } + hash.update(bytes); + FileRevision::try_new(format!("v1:{:x}", hash.finalize())) + .map_err(|_| api("file_io_error", "The file revision could not be encoded.")) + } +} + +pub(super) fn identity(stat: &Stat) -> Identity { + Identity { + device: stat.st_dev.into(), + inode: stat.st_ino.into(), + } +} + +pub(super) fn directory_flags() -> OFlags { + OFlags::RDONLY | OFlags::DIRECTORY | OFlags::NOFOLLOW | OFlags::CLOEXEC +} + +/// Walk even the registered absolute root by descriptor; an intermediate +/// symlink cannot redirect a root open after canonical-path validation. +pub(super) fn open_root(path: &Path) -> Result { + if !path.is_absolute() || path.canonicalize().map_err(|_| target_changed())? != path { + return Err(target_changed()); + } + let mut directory = open("/", directory_flags(), Mode::empty()).map_err(io_error)?; + for component in path.components() { + match component { + Component::RootDir => {} + Component::Normal(name) => { + directory = openat(&directory, name, directory_flags(), Mode::empty()) + .map_err(|_| target_changed())?; + } + _ => return Err(target_changed()), + } + } + Ok(directory) +} + +pub(super) fn open_directory(root: &impl AsFd, bytes: &[u8]) -> Result { + let mut directory = openat(root, ".", directory_flags(), Mode::empty()).map_err(io_error)?; + if !bytes.is_empty() { + for component in bytes.split(|byte| *byte == b'/') { + let name = OsStr::from_bytes(component); + let observed = statat(&directory, name, AtFlags::SYMLINK_NOFOLLOW).map_err(io_error)?; + if FileType::from_raw_mode(observed.st_mode) == FileType::Symlink { + return Err(api( + "file_symlink_not_allowed", + "Symbolic links cannot be followed by the editor.", + )); + } + directory = + openat(&directory, name, directory_flags(), Mode::empty()).map_err(io_error)?; + } + } + Ok(directory) +} + +pub(super) fn split_file_path(path: &FilePath) -> Result<(&[u8], OsString), ApiError> { + let bytes = path.as_bytes(); + if bytes.is_empty() { + return Err(super::error::invalid_input()); + } + match bytes.iter().rposition(|byte| *byte == b'/') { + Some(index) => Ok(( + &bytes[..index], + OsString::from_vec(bytes[index + 1..].to_vec()), + )), + None => Ok((&[], OsString::from_vec(bytes.to_vec()))), + } +} + +pub(super) struct TextFile { + pub file: File, + pub fingerprint: Fingerprint, + pub text: String, + pub revision: FileRevision, +} + +pub(super) fn read_leaf(parent: &impl AsFd, name: &OsStr) -> Result { + let observed = statat(parent, name, AtFlags::SYMLINK_NOFOLLOW).map_err(io_error)?; + require_regular(&observed)?; + let fd = openat( + parent, + name, + OFlags::RDONLY | OFlags::NOFOLLOW | OFlags::NONBLOCK | OFlags::CLOEXEC, + Mode::empty(), + ) + .map_err(io_error)?; + let before = fstat(&fd).map_err(io_error)?; + require_regular(&before)?; + let fingerprint = Fingerprint::from_stat(&before); + if fingerprint.size + > i128::try_from(MAX_FILE_TEXT_BYTES).map_err(|_| super::error::invalid_input())? + { + return Err(api( + "file_too_large", + "The selected file exceeds the 128 KiB text limit.", + )); + } + let mut file = File::from(fd); + let mut bytes = Vec::new(); + (&mut file) + .take(u64::try_from(MAX_FILE_TEXT_BYTES + 1).map_err(|_| super::error::invalid_input())?) + .read_to_end(&mut bytes) + .map_err(io_error)?; + if bytes.len() > MAX_FILE_TEXT_BYTES { + return Err(api( + "file_too_large", + "The selected file exceeds the 128 KiB text limit.", + )); + } + let after = Fingerprint::from_stat(&fstat(&file).map_err(io_error)?); + if before.st_ino != observed.st_ino || before.st_dev != observed.st_dev || fingerprint != after + { + return Err(conflict(None)); + } + if bytes.contains(&0) { + return Err(api( + "file_not_text", + "The selected file contains binary data.", + )); + } + let text = String::from_utf8(bytes).map_err(|_| { + api( + "file_not_text", + "The selected file is not valid UTF-8 text.", + ) + })?; + let revision = fingerprint.revision(text.as_bytes())?; + Ok(TextFile { + file, + fingerprint, + text, + revision, + }) +} + +pub(super) fn require_regular(stat: &Stat) -> Result<(), ApiError> { + match FileType::from_raw_mode(stat.st_mode) { + FileType::RegularFile => Ok(()), + FileType::Symlink => Err(api( + "file_symlink_not_allowed", + "Symbolic links cannot be edited.", + )), + _ => Err(api( + "file_not_regular", + "The selected object is not a regular file.", + )), + } +} + +pub(super) fn revalidate_namespace( + root_path: &Path, + root_identity: Identity, + parent_bytes: &[u8], + parent_identity: Identity, +) -> Result<(), ApiError> { + let root = open_root(root_path)?; + if identity(&fstat(&root).map_err(io_error)?) != root_identity { + return Err(target_changed()); + } + let parent = open_directory(&root, parent_bytes).map_err(|_| target_changed())?; + if identity(&fstat(&parent).map_err(io_error)?) != parent_identity { + return Err(target_changed()); + } + Ok(()) +} diff --git a/crates/daemon/src/files/error.rs b/crates/daemon/src/files/error.rs new file mode 100644 index 0000000..3b03d16 --- /dev/null +++ b/crates/daemon/src/files/error.rs @@ -0,0 +1,60 @@ +use cli_master_core::ApiError; +use rustix::io::Errno; + +pub(super) fn io_error(error: impl Into) -> ApiError { + let error = error.into(); + match error.raw_os_error().map(Errno::from_raw_os_error) { + Some(Errno::NOENT) => api("file_not_found", "The selected file no longer exists."), + Some(Errno::NOTDIR) => api( + "file_not_directory", + "A selected path component is not a directory.", + ), + Some(Errno::LOOP) => api( + "file_symlink_not_allowed", + "Symbolic links cannot be followed by the editor.", + ), + Some(Errno::ACCESS | Errno::PERM) => api( + "file_permission_denied", + "The file operation is not permitted.", + ), + _ => api("file_io_error", "The file operation could not complete."), + } +} + +pub(super) fn api(code: &str, message: &str) -> ApiError { + ApiError::new(code, message).with_action( + "Keep the editor draft, refresh the selected file, and retry after resolving the error.", + ) +} + +pub(super) fn target_changed() -> ApiError { + api( + "file_target_changed", + "The registered directory changed or is no longer available for this operation.", + ) +} + +pub(super) fn metadata_unsupported() -> ApiError { + api( + "file_metadata_unsupported", + "This file has metadata that cannot be safely preserved by the editor.", + ) +} + +pub(super) fn invalid_input() -> ApiError { + api( + "invalid_input", + "The file request contains an invalid target, path, text, revision, or limit.", + ) +} + +pub(super) fn conflict(revision: Option<&str>) -> ApiError { + let error = api( + "file_conflict", + "The file changed after it was read. Reload before saving.", + ); + match revision { + Some(revision) => error.with_detail("currentRevision", revision), + None => error, + } +} diff --git a/crates/daemon/src/files/listing.rs b/crates/daemon/src/files/listing.rs new file mode 100644 index 0000000..a39a6b7 --- /dev/null +++ b/crates/daemon/src/files/listing.rs @@ -0,0 +1,168 @@ +use std::ffi::OsStr; +use std::os::fd::OwnedFd; +use std::os::unix::ffi::OsStrExt; + +use cli_master_core::ApiError; +use cli_master_core::wire::{ + FileEntry, FileEntryKind, FileListRequest, FileListResponse, FileName, FilePath, +}; +use rustix::fs::{AtFlags, Dir, FileType, fstat, statat}; +use rustix::io::Errno; + +use super::descriptor::{Fingerprint, identity, open_directory, revalidate_namespace}; +use super::error::{api, io_error, target_changed}; +use super::{FileTargetAccess, LocalFileService, now_ms}; + +const MAX_ENUMERATED_ENTRIES: usize = 10_000; +const MAX_PAGE_BYTES: usize = 512 * 1_024; +const PAGE_OVERHEAD_BYTES: usize = 16 * 1_024; + +pub(super) fn list( + service: &LocalFileService, + request: &FileListRequest, + access: &impl FileTargetAccess, +) -> Result { + let resolved = access.resolve_target(&request.target, false)?; + let (root, root_identity) = service.open_target(&request.target, &resolved)?; + let directory = open_directory(&root, request.path_base64.as_bytes())?; + let directory_identity = identity(&fstat(&directory).map_err(io_error)?); + let names = enumerate_names(&directory)?; + let mut entries = Vec::new(); + let mut last_name = None; + let mut next_after_name_base64 = None; + let mut page_bytes = PAGE_OVERHEAD_BYTES; + for name in names { + if request + .after_name_base64 + .as_ref() + .is_some_and(|cursor| name.as_slice() <= cursor.as_bytes()) + { + continue; + } + let stat = match statat( + &directory, + OsStr::from_bytes(&name), + AtFlags::SYMLINK_NOFOLLOW, + ) { + Ok(stat) => stat, + Err(Errno::NOENT) => continue, + Err(error) => return Err(io_error(error)), + }; + let mut path = request.path_base64.as_bytes().to_vec(); + if !path.is_empty() { + path.push(b'/'); + } + path.extend_from_slice(&name); + let kind = match FileType::from_raw_mode(stat.st_mode) { + FileType::RegularFile => FileEntryKind::File, + FileType::Directory => FileEntryKind::Directory, + FileType::Symlink => FileEntryKind::Symlink, + _ => FileEntryKind::Other, + }; + let entry = FileEntry { + path_base64: FilePath::try_from_bytes(path).map_err(|_| { + api( + "file_listing_too_large", + "A directory entry exceeds the supported relative-path limit.", + ) + })?, + display_name: display_name(&name), + kind, + size_bytes: if kind == FileEntryKind::File { + u64::try_from(stat.st_size).ok() + } else { + None + }, + modified_at_ms: Fingerprint::from_stat(&stat).modified_at_ms(), + }; + let entry_bytes = serde_json::to_vec(&entry) + .map_err(|_| api("file_io_error", "The directory entry could not be encoded."))? + .len() + + 1; + if entries.len() >= usize::from(request.limit) || page_bytes + entry_bytes > MAX_PAGE_BYTES + { + next_after_name_base64 = last_name; + break; + } + page_bytes += entry_bytes; + entries.push(entry); + last_name = Some(FileName::try_from_bytes(name).map_err(|_| { + api( + "file_io_error", + "The directory cursor could not be encoded.", + ) + })?); + } + if access.resolve_target(&request.target, false)?.root != resolved.root { + return Err(target_changed()); + } + revalidate_namespace( + &resolved.root, + root_identity, + request.path_base64.as_bytes(), + directory_identity, + )?; + Ok(FileListResponse { + entries, + next_after_name_base64, + observed_at_ms: now_ms()?, + }) +} + +fn enumerate_names(directory: &OwnedFd) -> Result>, ApiError> { + let mut names = Vec::new(); + for entry in Dir::read_from(directory).map_err(io_error)? { + let entry = entry.map_err(io_error)?; + let name = entry.file_name().to_bytes(); + if matches!(name, b"." | b"..") { + continue; + } + if names.len() == MAX_ENUMERATED_ENTRIES { + return Err(api( + "file_listing_too_large", + "This directory exceeds the 10,000-entry enumeration limit.", + )); + } + names.push(name.to_vec()); + } + names.sort_unstable(); + names.dedup(); + Ok(names) +} + +/// Valid Unicode stays readable; undecodable/control bytes are explicit escapes. +fn display_name(bytes: &[u8]) -> String { + let mut display = String::new(); + let mut remaining = bytes; + while !remaining.is_empty() { + match std::str::from_utf8(remaining) { + Ok(text) => { + append_text(&mut display, text); + break; + } + Err(error) => { + let (valid, invalid) = remaining.split_at(error.valid_up_to()); + if let Ok(text) = std::str::from_utf8(valid) { + append_text(&mut display, text); + } + let invalid_length = error.error_len().unwrap_or(invalid.len()); + for byte in &invalid[..invalid_length] { + use std::fmt::Write; + let _ = write!(display, "\\x{byte:02x}"); + } + remaining = &invalid[invalid_length..]; + } + } + } + display +} + +fn append_text(display: &mut String, text: &str) { + for character in text.chars() { + if character.is_control() { + display.extend(character.escape_default()); + } else { + display.push(character); + } + } +} diff --git a/crates/daemon/src/files/metadata.rs b/crates/daemon/src/files/metadata.rs new file mode 100644 index 0000000..e85e6f9 --- /dev/null +++ b/crates/daemon/src/files/metadata.rs @@ -0,0 +1,79 @@ +//! Metadata that this atomic-replacement slice can preserve without elevation. + +use std::fs::File; + +use cli_master_core::ApiError; +use rustix::fs::{Gid, Mode, Uid, fchmod, fchown, fstat}; + +use super::attributes; +use super::descriptor::Fingerprint; +use super::error::metadata_unsupported; + +pub(super) fn inspect(file: &File, fingerprint: &Fingerprint) -> Result<(), ApiError> { + // Replacing a multiply-linked inode would silently disconnect its siblings. + // Special mode bits and platform flags are outside this plain-text slice. + if fingerprint.links != 1 || fingerprint.mode & 0o7000 != 0 || fingerprint.flags != 0 { + return Err(metadata_unsupported()); + } + attributes::read_supported(file)?; + inspect_platform(file) +} + +pub(super) fn apply( + source: &File, + destination: &File, + fingerprint: &Fingerprint, +) -> Result<(), ApiError> { + // Source metadata is checked again to observe ACL/xattr changes during staging. + inspect(source, fingerprint)?; + let current = fstat(destination).map_err(|_| metadata_unsupported())?; + if current.st_uid != fingerprint.uid || current.st_gid != fingerprint.gid { + fchown( + destination, + Some(Uid::from_raw(fingerprint.uid)), + Some(Gid::from_raw(fingerprint.gid)), + ) + .map_err(|_| metadata_unsupported())?; + } + #[allow( + clippy::useless_conversion, + reason = "RawMode is u16 on macOS and u32 on Linux" + )] + let permissions = rustix::fs::RawMode::try_from(fingerprint.mode & 0o777) + .map_err(|_| metadata_unsupported())?; + fchmod(destination, Mode::from_raw_mode(permissions)).map_err(|_| metadata_unsupported())?; + let copied = Fingerprint::from_stat(&fstat(destination).map_err(|_| metadata_unsupported())?); + if copied.uid != fingerprint.uid + || copied.gid != fingerprint.gid + || copied.mode & 0o7777 != fingerprint.mode & 0o7777 + { + return Err(metadata_unsupported()); + } + attributes::preserve(source, destination)?; + // A parent may have supplied inherited ACLs or attributes to the new inode. + // Never publish an inode whose access policy differs silently from the old file. + inspect(destination, &copied) +} + +#[cfg(target_os = "macos")] +fn inspect_platform(file: &File) -> Result<(), ApiError> { + if cli_master_file_metadata::has_extended_acl(file).map_err(|_| metadata_unsupported())? { + return Err(metadata_unsupported()); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn inspect_platform(file: &File) -> Result<(), ApiError> { + use rustix::fs::ioctl_getflags; + use rustix::io::Errno; + + // EXTENTS describes the regular ext4 allocation representation, not an + // authored inode policy. Every other flag requires explicit preservation. + const EXTENTS: u32 = 0x0008_0000; + match ioctl_getflags(file) { + Ok(flags) if flags.bits() & !EXTENTS == 0 => Ok(()), + Err(Errno::NOTTY | Errno::OPNOTSUPP) => Ok(()), + _ => Err(metadata_unsupported()), + } +} diff --git a/crates/daemon/src/files/mod.rs b/crates/daemon/src/files/mod.rs new file mode 100644 index 0000000..6e021c9 --- /dev/null +++ b/crates/daemon/src/files/mod.rs @@ -0,0 +1,188 @@ +//! Descriptor-relative local text files; all roots come from daemon metadata. + +mod attributes; +mod descriptor; +mod error; +mod listing; +mod metadata; +mod write; + +#[cfg(test)] +mod tests; + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, Weak}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use cli_master_core::ApiError; +use cli_master_core::wire::{ + FileListRequest, FileReadRequest, FileReadResponse, FileTarget, FileWriteRequest, + FileWriteResponse, method, +}; +use rustix::fs::fstat; +use serde::{Serialize, de::DeserializeOwned}; +use serde_json::Value; + +use descriptor::{ + Identity, identity, open_directory, open_root, read_leaf, revalidate_namespace, split_file_path, +}; +use error::{api, invalid_input, io_error, target_changed}; + +/// A root obtained from a currently registered project, session or worktree. +pub(crate) struct ResolvedFileTarget { + pub(crate) root: PathBuf, +} + +/// Runtime authority supplies metadata resolution and its worktree-removal lease. +pub(crate) trait FileTargetAccess { + fn resolve_target( + &self, + target: &FileTarget, + write: bool, + ) -> Result; + + fn with_write_lease( + &self, + target: &FileTarget, + expected_root: &Path, + operation: impl FnOnce() -> Result, + ) -> Result; +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct MutationKey { + parent: Identity, + name: Vec, +} + +#[derive(Default)] +pub(crate) struct LocalFileService { + roots: Mutex>, + mutations: Mutex>>>, +} + +impl LocalFileService { + pub(crate) fn dispatch( + &self, + method: &str, + payload: Value, + access: &impl FileTargetAccess, + ) -> Result { + match method { + method::FILE_LIST => { + let request: FileListRequest = decode(payload)?; + encode(listing::list(self, &request, access)?) + } + method::FILE_READ => { + let request: FileReadRequest = decode(payload)?; + encode(self.read(&request, access)?) + } + method::FILE_WRITE => { + let request: FileWriteRequest = decode(payload)?; + encode(self.write(&request, access)?) + } + _ => Err(api( + "method_not_found", + "The requested file operation is unknown.", + )), + } + } + + /// Shared safe reader for explicitly selected, registered S3 capabilities. + pub(crate) fn read( + &self, + request: &FileReadRequest, + access: &impl FileTargetAccess, + ) -> Result { + let resolved = access.resolve_target(&request.target, false)?; + let (root, root_identity) = self.open_target(&request.target, &resolved)?; + let (parent_bytes, name) = split_file_path(&request.path_base64)?; + let parent = open_directory(&root, parent_bytes)?; + let parent_identity = identity(&fstat(&parent).map_err(io_error)?); + let observed = read_leaf(&parent, &name)?; + let current = access.resolve_target(&request.target, false)?; + if current.root != resolved.root { + return Err(target_changed()); + } + revalidate_namespace(&resolved.root, root_identity, parent_bytes, parent_identity)?; + Ok(FileReadResponse { + path_base64: request.path_base64.clone(), + size_bytes: u64::try_from(observed.text.len()).map_err(|_| invalid_input())?, + modified_at_ms: observed.fingerprint.modified_at_ms(), + text: observed.text, + revision: observed.revision, + observed_at_ms: now_ms()?, + }) + } + + pub(crate) fn write( + &self, + request: &FileWriteRequest, + access: &impl FileTargetAccess, + ) -> Result { + write::save(self, request, access, &write::WriteFaults::default()) + } + + fn open_target( + &self, + target: &FileTarget, + resolved: &ResolvedFileTarget, + ) -> Result<(std::os::fd::OwnedFd, Identity), ApiError> { + let root = open_root(&resolved.root)?; + let observed = identity(&fstat(&root).map_err(io_error)?); + let mut roots = self.roots.lock().map_err(|_| { + api( + "file_io_error", + "The file service state could not be locked.", + ) + })?; + let key = format!("{target:?}"); + if let Some((previous_path, previous_identity)) = roots.get(&key) { + if *previous_identity != observed || *previous_path != resolved.root { + return Err(target_changed()); + } + } else { + roots.insert(key, (resolved.root.clone(), observed)); + } + Ok((root, observed)) + } + + fn mutation(&self, key: MutationKey) -> Result>, ApiError> { + let mut mutations = self.mutations.lock().map_err(|_| { + api( + "file_io_error", + "The file mutation registry could not be locked.", + ) + })?; + mutations.retain(|_, lock| lock.strong_count() != 0); + if let Some(lock) = mutations.get(&key).and_then(Weak::upgrade) { + return Ok(lock); + } + let lock = Arc::new(Mutex::new(())); + mutations.insert(key, Arc::downgrade(&lock)); + Ok(lock) + } +} + +fn decode(value: Value) -> Result { + serde_json::from_value(value).map_err(|_| invalid_input()) +} + +fn encode(value: impl Serialize) -> Result { + serde_json::to_value(value) + .map_err(|_| api("file_io_error", "The file response could not be encoded.")) +} + +fn now_ms() -> Result { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .ok() + .and_then(|elapsed| i64::try_from(elapsed.as_millis()).ok()) + .ok_or_else(|| { + api( + "file_io_error", + "The current file-operation timestamp is unavailable.", + ) + }) +} diff --git a/crates/daemon/src/files/tests.rs b/crates/daemon/src/files/tests.rs new file mode 100644 index 0000000..d922758 --- /dev/null +++ b/crates/daemon/src/files/tests.rs @@ -0,0 +1,313 @@ +use std::fs; +use std::os::unix::fs::{PermissionsExt, symlink}; +use std::path::{Path, PathBuf}; + +use cli_master_core::wire::{FilePath, FileReadRequest, FileTarget, FileWriteRequest}; +use cli_master_core::{ApiError, ProjectId}; +use tempfile::TempDir; + +use super::write::{WriteFaults, save}; +use super::{FileTargetAccess, LocalFileService, ResolvedFileTarget}; + +struct Access { + root: PathBuf, +} + +impl FileTargetAccess for Access { + fn resolve_target( + &self, + _target: &FileTarget, + _write: bool, + ) -> Result { + Ok(ResolvedFileTarget { + root: self.root.clone(), + }) + } + + fn with_write_lease( + &self, + _target: &FileTarget, + expected_root: &Path, + operation: impl FnOnce() -> Result, + ) -> Result { + if self.root != expected_root { + return Err(super::error::target_changed()); + } + operation() + } +} + +struct Fixture { + _directory: TempDir, + access: Access, + target: FileTarget, + service: LocalFileService, +} + +impl Fixture { + fn new() -> Self { + let directory = TempDir::new().unwrap(); + let root = directory.path().join("project"); + fs::create_dir(&root).unwrap(); + let root = root.canonicalize().unwrap(); + fs::write(root.join("file.txt"), "original\r\n").unwrap(); + Self { + _directory: directory, + access: Access { root }, + target: FileTarget::Project { + project_id: ProjectId::new(), + }, + service: LocalFileService::default(), + } + } + + fn write_request(&self, path: &[u8], text: &str) -> FileWriteRequest { + let path = FilePath::try_from_bytes(path).unwrap(); + let read = self + .service + .read( + &FileReadRequest::try_new(self.target, path.clone()).unwrap(), + &self.access, + ) + .unwrap(); + FileWriteRequest::try_new(self.target, path, text, read.revision).unwrap() + } + + fn staged_files(&self) -> usize { + fs::read_dir(&self.access.root) + .unwrap() + .filter(|entry| { + entry + .as_ref() + .unwrap() + .file_name() + .to_string_lossy() + .starts_with(".cli-master-save-") + }) + .count() + } +} + +#[test] +fn observed_external_write_before_rename_preserves_external_bytes_and_cleans_temp() { + let fixture = Fixture::new(); + let request = fixture.write_request(b"file.txt", "our draft"); + let changed_path = fixture.access.root.join("file.txt"); + let error = save( + &fixture.service, + &request, + &fixture.access, + &WriteFaults { + after_staging: Some(Box::new(move || { + fs::write(&changed_path, "external edit").unwrap(); + })), + ..WriteFaults::default() + }, + ) + .unwrap_err(); + assert_eq!(error.code, "file_conflict"); + assert_eq!( + fs::read(fixture.access.root.join("file.txt")).unwrap(), + b"external edit" + ); + assert_eq!(fixture.staged_files(), 0); +} + +#[test] +fn applied_write_reports_durability_uncertain_without_a_second_implicit_write() { + let fixture = Fixture::new(); + let request = fixture.write_request(b"file.txt", "applied once\r\n"); + let error = save( + &fixture.service, + &request, + &fixture.access, + &WriteFaults { + fail_after_rename: true, + ..WriteFaults::default() + }, + ) + .unwrap_err(); + assert_eq!(error.code, "file_durability_uncertain"); + let encoded = serde_json::to_value(&error).unwrap(); + assert_eq!(encoded["details"]["writeApplied"], true); + assert!( + encoded["details"]["currentRevision"] + .as_str() + .unwrap() + .starts_with("v1:") + ); + assert_eq!( + fs::read(fixture.access.root.join("file.txt")).unwrap(), + b"applied once\r\n" + ); + assert_eq!(fixture.staged_files(), 0); +} + +#[test] +fn relocated_parent_aborts_before_publication_and_cleans_only_its_temp() { + let fixture = Fixture::new(); + fs::create_dir(fixture.access.root.join("folder")).unwrap(); + fs::write(fixture.access.root.join("folder/file.txt"), "original").unwrap(); + let request = fixture.write_request(b"folder/file.txt", "draft"); + let root = fixture.access.root.clone(); + let error = save( + &fixture.service, + &request, + &fixture.access, + &WriteFaults { + after_staging: Some(Box::new(move || { + fs::rename(root.join("folder"), root.join("relocated")).unwrap(); + fs::create_dir(root.join("folder")).unwrap(); + fs::write(root.join("folder/file.txt"), "replacement root").unwrap(); + })), + ..WriteFaults::default() + }, + ) + .unwrap_err(); + assert_eq!(error.code, "file_target_changed"); + assert_eq!( + fs::read(fixture.access.root.join("relocated/file.txt")).unwrap(), + b"original" + ); + assert_eq!( + fs::read(fixture.access.root.join("folder/file.txt")).unwrap(), + b"replacement root" + ); + assert_eq!( + fs::read_dir(fixture.access.root.join("relocated")) + .unwrap() + .count(), + 1 + ); +} + +#[test] +fn extended_attributes_are_rejected_without_losing_the_original_inode() { + let fixture = Fixture::new(); + let path = fixture.access.root.join("file.txt"); + let file = fs::File::open(&path).unwrap(); + #[cfg(target_os = "linux")] + let attribute = "user.cli_master_test"; + #[cfg(target_os = "macos")] + let attribute = "com.cli-master.test"; + rustix::fs::fsetxattr( + &file, + attribute, + b"retain attribute", + rustix::fs::XattrFlags::empty(), + ) + .unwrap(); + let request = fixture.write_request(b"file.txt", "draft"); + let before = rustix::fs::fstat(&file).unwrap(); + let error = fixture + .service + .write(&request, &fixture.access) + .unwrap_err(); + assert_eq!(error.code, "file_metadata_unsupported"); + let after = rustix::fs::fstat(fs::File::open(path).unwrap()).unwrap(); + assert_eq!(before.st_ino, after.st_ino); + assert_eq!(fixture.staged_files(), 0); +} + +#[test] +fn mode_and_line_endings_are_preserved_and_revision_survives_new_service() { + let fixture = Fixture::new(); + let path = fixture.access.root.join("file.txt"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap(); + let request = fixture.write_request(b"file.txt", "\u{feff}Olá\r\n"); + let written = fixture.service.write(&request, &fixture.access).unwrap(); + let fresh = LocalFileService::default(); + let read = fresh + .read( + &FileReadRequest::try_new(fixture.target, request.path_base64).unwrap(), + &fixture.access, + ) + .unwrap(); + assert_eq!(read.revision, written.revision); + assert_eq!(read.text, request.text); + assert_eq!( + fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o640 + ); +} + +#[cfg(target_os = "macos")] +#[test] +fn macos_provenance_and_complete_attribute_list_survive_replacement() { + fn snapshot(file: &fs::File) -> (Vec, Option>) { + let mut names = vec![0_u8; 1_024]; + let length = rustix::fs::flistxattr(file, &mut names[..]).unwrap(); + names.truncate(length); + let provenance = if names + .split(|byte| *byte == 0) + .any(|name| name == b"com.apple.provenance") + { + let mut value = vec![0_u8; 4_096]; + let length = + rustix::fs::fgetxattr(file, "com.apple.provenance", &mut value[..]).unwrap(); + value.truncate(length); + Some(value) + } else { + None + }; + (names, provenance) + } + + let fixture = Fixture::new(); + let path = fixture.access.root.join("file.txt"); + let original = fs::File::open(&path).unwrap(); + let before = snapshot(&original); + let request = fixture.write_request(b"file.txt", "preserve provenance\r\n"); + fixture.service.write(&request, &fixture.access).unwrap(); + let replacement = fs::File::open(path).unwrap(); + assert_ne!( + rustix::fs::fstat(&original).unwrap().st_ino, + rustix::fs::fstat(&replacement).unwrap().st_ino + ); + assert_eq!(snapshot(&replacement), before); +} + +#[test] +fn symlink_leaf_is_rejected_before_reading_outside() { + let fixture = Fixture::new(); + let outside = TempDir::new().unwrap(); + fs::write(outside.path().join("secret.txt"), "outside").unwrap(); + let request = fixture.write_request(b"file.txt", "draft"); + fs::remove_file(fixture.access.root.join("file.txt")).unwrap(); + symlink( + outside.path().join("secret.txt"), + fixture.access.root.join("file.txt"), + ) + .unwrap(); + let error = fixture + .service + .write(&request, &fixture.access) + .unwrap_err(); + assert_eq!(error.code, "file_symlink_not_allowed"); + assert_eq!( + fs::read(outside.path().join("secret.txt")).unwrap(), + b"outside" + ); +} + +#[test] +fn previously_observed_root_replaced_by_another_directory_is_rejected() { + let fixture = Fixture::new(); + fixture.write_request(b"file.txt", "draft"); + let old = fixture.access.root.with_file_name("original-project"); + fs::rename(&fixture.access.root, &old).unwrap(); + fs::create_dir(&fixture.access.root).unwrap(); + fs::write( + fixture.access.root.join("file.txt"), + "replacement directory", + ) + .unwrap(); + let request = FileReadRequest::try_new( + fixture.target, + FilePath::try_from_bytes(b"file.txt").unwrap(), + ) + .unwrap(); + let error = fixture.service.read(&request, &fixture.access).unwrap_err(); + assert_eq!(error.code, "file_target_changed"); + assert_eq!(fs::read(old.join("file.txt")).unwrap(), b"original\r\n"); +} diff --git a/crates/daemon/src/files/write.rs b/crates/daemon/src/files/write.rs new file mode 100644 index 0000000..9b06f6d --- /dev/null +++ b/crates/daemon/src/files/write.rs @@ -0,0 +1,206 @@ +use std::ffi::OsString; +use std::fs::File; +use std::io::Write; +use std::os::fd::OwnedFd; +use std::os::unix::ffi::OsStrExt; + +use cli_master_core::ApiError; +use cli_master_core::wire::{FileWriteRequest, FileWriteResponse, MAX_FILE_TEXT_BYTES}; +use rustix::fs::{AtFlags, Mode, OFlags, fstat, fsync, openat, renameat, statat, unlinkat}; + +use super::descriptor::{ + Fingerprint, identity, open_directory, read_leaf, revalidate_namespace, split_file_path, +}; +use super::error::{api, conflict, invalid_input, io_error, target_changed}; +use super::{FileTargetAccess, LocalFileService, MutationKey, attributes, metadata, now_ms}; + +#[derive(Default)] +pub(super) struct WriteFaults { + #[cfg(test)] + pub after_staging: Option>, + #[cfg(test)] + pub fail_after_rename: bool, +} + +#[allow( + clippy::unused_self, + reason = "fault hooks are inert outside deterministic tests" +)] +impl WriteFaults { + fn after_staging(&self) { + #[cfg(test)] + if let Some(hook) = &self.after_staging { + hook(); + } + } + + #[allow( + clippy::unnecessary_wraps, + reason = "tests inject an error after publication to verify durability reporting" + )] + fn after_rename(&self) -> Result<(), ApiError> { + #[cfg(test)] + if self.fail_after_rename { + return Err(api( + "file_io_error", + "Injected directory durability failure.", + )); + } + Ok(()) + } +} + +pub(super) fn save( + service: &LocalFileService, + request: &FileWriteRequest, + access: &impl FileTargetAccess, + faults: &WriteFaults, +) -> Result { + if request.text.len() > MAX_FILE_TEXT_BYTES || request.text.contains('\0') { + return Err(invalid_input()); + } + let resolved = access.resolve_target(&request.target, true)?; + let (root, root_identity) = service.open_target(&request.target, &resolved)?; + let (parent_bytes, name) = split_file_path(&request.path_base64)?; + let parent = open_directory(&root, parent_bytes)?; + let parent_identity = identity(&fstat(&parent).map_err(io_error)?); + let lock = service.mutation(MutationKey { + parent: parent_identity, + name: name.as_bytes().to_vec(), + })?; + let _mutation = lock + .lock() + .map_err(|_| api("file_io_error", "The file mutation lock is unavailable."))?; + let original = read_leaf(&parent, &name)?; + if original.revision != request.expected_revision { + return Err(conflict(Some(original.revision.as_str()))); + } + metadata::inspect(&original.file, &original.fingerprint)?; + let mut temporary = TemporaryFile::new(&parent)?; + temporary + .file + .write_all(request.text.as_bytes()) + .map_err(io_error)?; + temporary.file.flush().map_err(io_error)?; + metadata::apply(&original.file, &temporary.file, &original.fingerprint)?; + fsync(&temporary.file).map_err(io_error)?; + let staged_fingerprint = Fingerprint::from_stat(&fstat(&temporary.file).map_err(io_error)?); + faults.after_staging(); + + access.with_write_lease(&request.target, &resolved.root, || { + let current = read_leaf(&parent, &name).map_err(|error| match error.code.as_str() { + "file_not_found" + | "file_not_regular" + | "file_symlink_not_allowed" + | "file_too_large" + | "file_not_text" => conflict(None), + _ => error, + })?; + if current.revision != request.expected_revision { + return Err(conflict(Some(current.revision.as_str()))); + } + metadata::inspect(¤t.file, ¤t.fingerprint)?; + temporary.validate_ownership()?; + let staged_now = Fingerprint::from_stat(&fstat(&temporary.file).map_err(io_error)?); + if staged_now != staged_fingerprint { + return Err(conflict(None)); + } + metadata::inspect(&temporary.file, &staged_now)?; + attributes::verify_preserved(¤t.file, &temporary.file)?; + revalidate_namespace(&resolved.root, root_identity, parent_bytes, parent_identity)?; + renameat(&parent, &temporary.name, &parent, &name).map_err(io_error)?; + temporary.published = true; + + let published = Fingerprint::from_stat( + &fstat(&temporary.file).map_err(|_| durability_uncertain(None))?, + ); + let revision = published + .revision(request.text.as_bytes()) + .map_err(|_| durability_uncertain(None))?; + faults + .after_rename() + .map_err(|_| durability_uncertain(Some(revision.as_str())))?; + fsync(&parent).map_err(|_| durability_uncertain(Some(revision.as_str())))?; + let namespace = statat(&parent, &name, AtFlags::SYMLINK_NOFOLLOW) + .map_err(|_| durability_uncertain(Some(revision.as_str())))?; + let observed = Fingerprint::from_stat(&namespace); + if observed != published { + return Err(durability_uncertain(Some(revision.as_str()))); + } + Ok(FileWriteResponse { + path_base64: request.path_base64.clone(), + revision, + size_bytes: u64::try_from(request.text.len()) + .map_err(|_| durability_uncertain(None))?, + modified_at_ms: published.modified_at_ms(), + written_at_ms: now_ms().map_err(|_| durability_uncertain(None))?, + }) + }) +} + +fn durability_uncertain(revision: Option<&str>) -> ApiError { + let mut error = ApiError::new( + "file_durability_uncertain", + "The replacement was applied, but its final durability could not be confirmed.", + ) + .with_action( + "Keep the draft and re-read the file before retrying; the write may already be present.", + ) + .with_detail("writeApplied", true); + if let Some(revision) = revision { + error = error.with_detail("currentRevision", revision); + } + error +} + +struct TemporaryFile<'a> { + parent: &'a OwnedFd, + name: OsString, + file: File, + identity: super::descriptor::Identity, + published: bool, +} + +impl<'a> TemporaryFile<'a> { + fn new(parent: &'a OwnedFd) -> Result { + let name = OsString::from(format!( + ".cli-master-save-{}", + uuid::Uuid::now_v7().simple() + )); + let fd = openat( + parent, + &name, + OFlags::RDWR | OFlags::CREATE | OFlags::EXCL | OFlags::NOFOLLOW | OFlags::CLOEXEC, + Mode::from_raw_mode(0o600), + ) + .map_err(io_error)?; + // If identity cannot be established, leave the uniquely named temporary + // file for manual inspection rather than unlinking an unproven name. + let identity = identity(&fstat(&fd).map_err(io_error)?); + let file = File::from(fd); + Ok(Self { + parent, + name, + file, + identity, + published: false, + }) + } + + fn validate_ownership(&self) -> Result<(), ApiError> { + let stat = statat(self.parent, &self.name, AtFlags::SYMLINK_NOFOLLOW) + .map_err(|_| target_changed())?; + if identity(&stat) != self.identity || stat.st_nlink != 1 { + return Err(target_changed()); + } + Ok(()) + } +} + +impl Drop for TemporaryFile<'_> { + fn drop(&mut self) { + if !self.published && self.validate_ownership().is_ok() { + let _ = unlinkat(self.parent, &self.name, AtFlags::empty()); + } + } +} diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs index 685d628..2833805 100644 --- a/crates/daemon/src/lib.rs +++ b/crates/daemon/src/lib.rs @@ -13,6 +13,7 @@ mod config; mod diagnostics; mod error; mod events; +mod files; mod git_inspection; mod knowledge; mod lock; diff --git a/crates/daemon/src/server.rs b/crates/daemon/src/server.rs index 49aefc5..7172a32 100644 --- a/crates/daemon/src/server.rs +++ b/crates/daemon/src/server.rs @@ -34,6 +34,7 @@ use tokio_util::sync::CancellationToken; use tracing::{debug, info, warn}; use uuid::Uuid; +use crate::files::LocalFileService; use crate::lock::InstanceLock; use crate::projects::ProjectRegistry; use crate::sessions::{SessionRegistry, encode_base64}; @@ -76,6 +77,7 @@ struct ServerState { diagnostics: DiagnosticsResponse, projects: ProjectRegistry, sessions: SessionRegistry, + files: LocalFileService, git_storage: Storage, git: Option, event_sequence: AtomicU64, @@ -158,6 +160,7 @@ impl Daemon { })?, git_storage, git, + files: LocalFileService::default(), event_sequence: AtomicU64::new(0), }); @@ -557,6 +560,21 @@ async fn dispatch( } let result = match request.method.as_str() { + method::FILE_LIST | method::FILE_READ | method::FILE_WRITE => { + let state = Arc::clone(state); + tokio::task::spawn_blocking(move || { + state + .files + .dispatch(&request.method, request.payload, &state.sessions) + }) + .await + .unwrap_or_else(|_| { + Err(ApiError::new( + "file_io_error", + "The local file operation could not complete.", + )) + }) + } method::KNOWLEDGE_LIST | method::KNOWLEDGE_SAVE | method::KNOWLEDGE_DELETE => { let state = Arc::clone(state); match tokio::task::spawn_blocking(move || { @@ -590,9 +608,25 @@ async fn dispatch( method::PROJECT_RENAME => decode_payload(request.payload) .and_then(|payload: ProjectRenameRequest| state.projects.rename(&payload)) .and_then(encode_response), - method::PROJECT_REMOVE => decode_payload(request.payload) - .and_then(|payload: ProjectRemoveRequest| state.projects.remove(payload)) - .and_then(encode_response), + method::PROJECT_REMOVE => match decode_payload::(request.payload) { + Ok(payload) => { + let state = Arc::clone(state); + tokio::task::spawn_blocking(move || { + state + .sessions + .with_metadata_mutation(|| state.projects.remove(payload)) + .and_then(encode_response) + }) + .await + .unwrap_or_else(|_| { + Err(ApiError::new( + "project_operation_failed", + "The project metadata operation could not complete.", + )) + }) + } + Err(error) => Err(error), + }, method::AGENT_LIST => state.sessions.list_agents().and_then(encode_response), method::AGENT_DETECT => decode_payload(request.payload) .and_then(|payload: AgentDetectRequest| state.sessions.detect_agents(&payload)) diff --git a/crates/daemon/src/sessions.rs b/crates/daemon/src/sessions.rs index 7813266..b86c7a0 100644 --- a/crates/daemon/src/sessions.rs +++ b/crates/daemon/src/sessions.rs @@ -24,6 +24,7 @@ use cli_master_session::{ use cli_master_storage::{SessionRuntimeUpdate, Storage, StorageError, StoredAgent, StoredSession}; use uuid::Uuid; +mod files; mod worktrees; const INITIAL_COLUMNS: u16 = 100; diff --git a/crates/daemon/src/sessions/files.rs b/crates/daemon/src/sessions/files.rs new file mode 100644 index 0000000..28da821 --- /dev/null +++ b/crates/daemon/src/sessions/files.rs @@ -0,0 +1,125 @@ +use std::path::Path; + +use cli_master_core::ApiError; +use cli_master_core::wire::FileTarget; +use cli_master_storage::{StoredWorktree, WorktreeState}; + +use crate::files::{FileTargetAccess, ResolvedFileTarget}; + +use super::{SessionRegistry, storage_error}; + +impl FileTargetAccess for SessionRegistry { + fn resolve_target( + &self, + target: &FileTarget, + write: bool, + ) -> Result { + let (root, worktrees) = { + let storage = self.storage()?; + let root = match *target { + FileTarget::Project { project_id } => storage + .get_project(project_id) + .map_err(storage_error)? + .map(|project| project.path), + FileTarget::Session { session_id } => storage + .get_session(session_id) + .map_err(storage_error)? + .map(|session| session.cwd), + FileTarget::Worktree { worktree_id } => storage + .get_worktree(worktree_id) + .map_err(storage_error)? + .map(|worktree| worktree.path), + } + .ok_or_else(|| { + ApiError::new( + "file_target_not_found", + "The selected file target is not registered.", + ) + .with_action("Refresh projects and sessions before opening files.") + })?; + (root, storage.list_worktrees().map_err(storage_error)?) + }; + if root.canonicalize().map_err(|_| target_changed())? != root || !root.is_dir() { + return Err(target_changed()); + } + // Projects may be registered directly at a managed checkout. Enforce the + // same identity and write policy even through such an alternate target ID. + for worktree in worktrees + .iter() + .filter(|worktree| root.starts_with(&worktree.path)) + { + self.validate_file_worktree(worktree, write)?; + } + Ok(ResolvedFileTarget { root }) + } + + fn with_write_lease( + &self, + target: &FileTarget, + expected_root: &Path, + operation: impl FnOnce() -> Result, + ) -> Result { + let _lifecycle = self.lifecycle()?; + if self.resolve_target(target, true)?.root != expected_root { + return Err(target_changed()); + } + operation() + } +} + +impl SessionRegistry { + fn validate_file_worktree( + &self, + worktree: &StoredWorktree, + write: bool, + ) -> Result<(), ApiError> { + if matches!( + worktree.state, + WorktreeState::Creating | WorktreeState::Orphaned + ) || (write && worktree.state != WorktreeState::Active) + || worktree.path.canonicalize().map_err(|_| target_changed())? != worktree.path + { + return Err(target_changed()); + } + let project = self + .storage()? + .get_project(worktree.project_id) + .map_err(storage_error)? + .ok_or_else(target_changed)?; + let git = self.git.as_ref().ok_or_else(target_changed)?; + let registered = git + .list_worktrees(&project.path) + .map_err(|_| target_changed())?; + let inspection = git + .inspect_repository(&worktree.path) + .map_err(|_| target_changed())?; + if inspection.repository_root.as_ref() != Some(&worktree.path) + || inspection.branch.as_deref() != Some(worktree.branch.as_str()) + || !registered.iter().any(|entry| { + entry.path == worktree.path + && entry.branch.as_deref() == Some(worktree.branch.as_str()) + && !entry.prunable + }) + { + return Err(target_changed()); + } + Ok(()) + } + + /// Metadata removal and the final file publish share the worktree mutation lease. + pub(crate) fn with_metadata_mutation( + &self, + operation: impl FnOnce() -> Result, + ) -> Result { + let _lifecycle = self.lifecycle()?; + operation() + } +} + +fn target_changed() -> ApiError { + ApiError::new( + "file_target_changed", + "The registered file target changed or needs recovery.", + ) + .with_action("Refresh the target and reopen the file before retrying.") +} diff --git a/crates/daemon/tests/file_ipc.rs b/crates/daemon/tests/file_ipc.rs new file mode 100644 index 0000000..18443b6 --- /dev/null +++ b/crates/daemon/tests/file_ipc.rs @@ -0,0 +1,1005 @@ +use std::ffi::OsString; +use std::fs; +use std::os::unix::ffi::OsStringExt; +use std::os::unix::fs::{MetadataExt, PermissionsExt, symlink}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::Duration; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use cli_master_core::{ApiError, RequestEnvelope, ResponseEnvelope, ResponsePayload}; +use cli_master_daemon::{Daemon, DaemonConfig, DaemonError, MAX_FRAME_LENGTH}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::{Value, json}; +use tempfile::TempDir; +use tokio::net::UnixStream; +use tokio::task::JoinHandle; +use tokio_util::codec::{Framed, LengthDelimitedCodec}; +use tokio_util::sync::CancellationToken; + +const MAX_TEXT_BYTES: usize = 128 * 1024; +const MAX_LIST_BYTES: usize = 512 * 1024; +const INITIAL: &str = "\u{feff}first\r\nsecond\r\n"; + +type Client = Framed; + +struct RunningDaemon { + config: DaemonConfig, + cancellation: CancellationToken, + task: JoinHandle>, +} + +impl RunningDaemon { + fn start(root: &Path) -> Self { + let config = DaemonConfig::from_paths(root.join("data"), root.join("run")); + let daemon = Daemon::bind(config.clone()).expect("daemon should bind"); + let cancellation = CancellationToken::new(); + let task_cancellation = cancellation.clone(); + let task = tokio::spawn(async move { daemon.run(task_cancellation).await }); + Self { + config, + cancellation, + task, + } + } + + async fn connect(&self) -> Client { + LengthDelimitedCodec::builder() + .max_frame_length(MAX_FRAME_LENGTH) + .new_framed( + UnixStream::connect(self.config.socket_path()) + .await + .unwrap(), + ) + } + + async fn stop(self) { + self.cancellation.cancel(); + tokio::time::timeout(Duration::from_secs(10), self.task) + .await + .expect("daemon should stop before timeout") + .expect("daemon task should join") + .expect("daemon should stop cleanly"); + } +} + +struct Fixture { + root: TempDir, + repository: PathBuf, + selected: PathBuf, + target: Value, + project_id: Value, + agent_id: Value, +} + +impl Fixture { + async fn new() -> (Self, RunningDaemon, Client) { + let root = TempDir::new().unwrap(); + let repository = root.path().join("repository"); + let selected = repository.join("apps"); + fs::create_dir_all(selected.join("api")).unwrap(); + fs::write(selected.join("api/notes.txt"), INITIAL).unwrap(); + fs::write(repository.join("outside.txt"), "outside selected project\n").unwrap(); + git(&repository, &["init", "-b", "main"]); + git( + &repository, + &["config", "user.email", "tests@example.invalid"], + ); + git(&repository, &["config", "user.name", "CLI Master Tests"]); + git(&repository, &["add", "."]); + git(&repository, &["commit", "-m", "initial"]); + let daemon = RunningDaemon::start(root.path()); + let mut client = daemon.connect().await; + let project = call(&mut client, "project.add", json!({"path": selected})).await; + let agent = call( + &mut client, + "agent.custom.create", + json!({ + "displayName": "File fixture", + "command": {"executable": "/bin/cat", "args": [], "env": {}} + }), + ) + .await; + ( + Self { + root, + repository, + selected, + target: json!({"kind": "project", "projectId": project["id"]}), + project_id: project["id"].clone(), + agent_id: agent["id"].clone(), + }, + daemon, + client, + ) + } + + async fn session(&self, client: &mut Client, isolation: &str) -> Value { + call( + client, + "session.create", + json!({ + "projectId": self.project_id, + "agentId": self.agent_id, + "name": "File session", + "isolation": isolation, + "relativeDirectory": "api" + }), + ) + .await + } +} + +fn git(cwd: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(cwd) + .env("GIT_TERMINAL_PROMPT", "0") + .output() + .unwrap(); + assert!( + output.status.success(), + "git {args:?}: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +fn path(bytes: &[u8]) -> String { + STANDARD.encode(bytes) +} + +fn request(target: &Value, bytes: &[u8]) -> Value { + json!({"target": target, "pathBase64": path(bytes)}) +} + +fn write_request(target: &Value, bytes: &[u8], text: &str, revision: &str) -> Value { + json!({"target": target, "pathBase64": path(bytes), "text": text, "expectedRevision": revision}) +} + +async fn exchange(client: &mut Client, method: &str, payload: Value) -> ResponseEnvelope { + let request = RequestEnvelope::v1(method, payload); + let encoded = serde_json::to_vec(&request).unwrap(); + assert!( + encoded.len() <= MAX_FRAME_LENGTH, + "test request must fit the wire frame" + ); + client.send(encoded.into()).await.unwrap(); + tokio::time::timeout(Duration::from_secs(5), async { + loop { + let frame = client + .next() + .await + .expect("response should arrive") + .unwrap(); + assert!(frame.len() <= MAX_FRAME_LENGTH); + let envelope: Value = serde_json::from_slice(&frame).unwrap(); + if envelope["kind"] == "event" { + continue; + } + let response: ResponseEnvelope = serde_json::from_value(envelope).unwrap(); + assert_eq!(response.request_id, request.request_id); + return response; + } + }) + .await + .unwrap_or_else(|_| panic!("{method} must not hang")) +} + +async fn call(client: &mut Client, method: &str, payload: Value) -> Value { + match exchange(client, method, payload).await.payload { + ResponsePayload::Success { data } => data, + ResponsePayload::Error { error } => panic!("{method} failed: {error:?}"), + } +} + +async fn failure(client: &mut Client, method: &str, payload: Value) -> ApiError { + match exchange(client, method, payload).await.payload { + ResponsePayload::Error { error } => error, + ResponsePayload::Success { data } => panic!("{method} unexpectedly succeeded: {data}"), + } +} + +async fn read(client: &mut Client, target: &Value, bytes: &[u8]) -> Value { + call(client, "file.read", request(target, bytes)).await +} + +fn revision(response: &Value) -> &str { + let revision = response["revision"] + .as_str() + .expect("opaque revision should be present"); + assert_eq!(revision.len(), 67); + assert!(revision.starts_with("v1:")); + assert!( + revision.as_bytes()[3..] + .iter() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)) + ); + revision +} + +fn entry_paths(response: &Value) -> Vec> { + response["entries"] + .as_array() + .expect("directory entries") + .iter() + .map(|entry| { + STANDARD + .decode(entry["pathBase64"].as_str().unwrap()) + .unwrap() + }) + .collect() +} + +#[tokio::test] +async fn registered_project_session_and_worktree_roots_list_read_and_save_real_bytes() { + let (fixture, daemon, mut client) = Fixture::new().await; + let session = fixture.session(&mut client, "current").await; + let isolated = fixture.session(&mut client, "new_worktree").await; + let session_target = json!({"kind": "session", "sessionId": session["id"]}); + let isolated_target = json!({"kind": "worktree", "worktreeId": isolated["worktreeId"]}); + let isolated_root = PathBuf::from(isolated["worktreePath"].as_str().unwrap()); + let cases = [ + ( + fixture.target.clone(), + b"api/notes.txt".as_slice(), + fixture.selected.join("api/notes.txt"), + ), + ( + session_target, + b"notes.txt".as_slice(), + fixture.selected.join("api/notes.txt"), + ), + ( + isolated_target, + b"apps/api/notes.txt".as_slice(), + isolated_root.join("apps/api/notes.txt"), + ), + ]; + for (index, (target, name, disk)) in cases.iter().enumerate() { + let listed = call(&mut client, "file.list", request(target, b"")).await; + assert!(!entry_paths(&listed).is_empty()); + assert!(listed["observedAtMs"].as_i64().unwrap() > 1_700_000_000_000); + let before = read(&mut client, target, name).await; + assert_eq!(before["text"], fs::read_to_string(disk).unwrap()); + let text = format!("\u{feff}olá {index} 🦀\r\nkeep CRLF\r\n"); + let saved = call( + &mut client, + "file.write", + write_request(target, name, &text, revision(&before)), + ) + .await; + assert_eq!(saved["pathBase64"], path(name)); + assert_eq!(saved["sizeBytes"], text.len()); + assert_ne!(revision(&before), revision(&saved)); + assert_eq!(fs::read(disk).unwrap(), text.as_bytes()); + let after = read(&mut client, target, name).await; + assert_eq!(after["text"], text); + assert_eq!(revision(&after), revision(&saved)); + } + let listed = call(&mut client, "file.list", request(&fixture.target, b"")).await; + assert_eq!(entry_paths(&listed), [b"api".to_vec()]); + assert_eq!( + failure( + &mut client, + "file.read", + request(&fixture.target, b"outside.txt") + ) + .await + .code, + "file_not_found" + ); + assert_eq!( + fs::read_to_string(fixture.repository.join("outside.txt")).unwrap(), + "outside selected project\n" + ); + daemon.stop().await; +} + +#[tokio::test] +async fn unix_filename_bytes_round_trip_through_listing_and_followup_identifiers() { + let (fixture, daemon, mut client) = Fixture::new().await; + let mut names: Vec> = vec![ + b"space name.txt".to_vec(), + b"-leading.txt".to_vec(), + b"literal\\name:part".to_vec(), + "ação.txt".as_bytes().to_vec(), + b"line\n\x01.txt".to_vec(), + ]; + let non_utf8_name = b"bad-\xff.txt".to_vec(); + match fs::write( + fixture + .selected + .join(OsString::from_vec(non_utf8_name.clone())), + "before", + ) { + Ok(()) => names.push(non_utf8_name), + // APFS rejects invalid UTF-8 before it reaches the file service. Linux + // must exercise this case; the pure wire contract covers it on both OSes. + Err(error) + if cfg!(target_os = "macos") + && error.raw_os_error() == Some(rustix::io::Errno::ILSEQ.raw_os_error()) => {} + Err(error) => panic!("non-UTF-8 fixture failed unexpectedly: {error}"), + } + for name in &names { + fs::write( + fixture.selected.join(OsString::from_vec(name.clone())), + "before", + ) + .unwrap(); + } + let listing = call(&mut client, "file.list", request(&fixture.target, b"")).await; + let paths = entry_paths(&listing); + let mut sorted = paths.clone(); + sorted.sort(); + assert_eq!(paths, sorted, "sorting must follow raw Unix bytes"); + for name in &names { + let entry = listing["entries"] + .as_array() + .unwrap() + .iter() + .find(|entry| { + STANDARD + .decode(entry["pathBase64"].as_str().unwrap()) + .unwrap() + == *name + }) + .expect("every byte-exact filename should be listable"); + assert!( + !entry["displayName"] + .as_str() + .unwrap() + .chars() + .any(char::is_control) + ); + let payload = json!({"target": fixture.target, "pathBase64": entry["pathBase64"]}); + let before = call(&mut client, "file.read", payload.clone()).await; + let mut write = payload; + write["text"] = json!("after 🦀\r\n"); + write["expectedRevision"] = before["revision"].clone(); + call(&mut client, "file.write", write).await; + assert_eq!( + fs::read(fixture.selected.join(OsString::from_vec(name.clone()))).unwrap(), + "after 🦀\r\n".as_bytes() + ); + } + daemon.stop().await; +} + +#[tokio::test] +async fn invalid_paths_revision_and_unregistered_targets_are_rejected_at_the_wire_boundary() { + let (fixture, daemon, mut client) = Fixture::new().await; + let invalid: &[&[u8]] = &[ + b"", + b"/absolute.txt", + b"..", + b"api/../notes.txt", + b"api//notes.txt", + b"api/./notes.txt", + b"api/", + b"api\0notes.txt", + ]; + for bytes in invalid { + assert_eq!( + failure(&mut client, "file.read", request(&fixture.target, bytes)) + .await + .code, + "invalid_input", + "{bytes:?}" + ); + } + for encoded in ["%%%", "YQ", "YR=="] { + assert_eq!( + failure( + &mut client, + "file.read", + json!({"target": fixture.target, "pathBase64": encoded}) + ) + .await + .code, + "invalid_input" + ); + } + assert_eq!( + failure( + &mut client, + "file.read", + request(&fixture.target, &vec![b'a'; 4097]) + ) + .await + .code, + "invalid_input" + ); + assert_eq!( + failure( + &mut client, + "file.write", + write_request( + &fixture.target, + b"api/notes.txt", + "changed", + "not-a-revision" + ) + ) + .await + .code, + "invalid_input" + ); + let missing_target = json!({"kind": "project", "projectId": cli_master_core::ProjectId::new()}); + assert_eq!( + failure( + &mut client, + "file.read", + request(&missing_target, b"notes.txt") + ) + .await + .code, + "file_target_not_found" + ); + let mut extra_path = request(&fixture.target, b"api/notes.txt"); + extra_path["path"] = json!(fixture.repository.join("outside.txt")); + assert_eq!( + failure(&mut client, "file.read", extra_path).await.code, + "invalid_input" + ); + assert_eq!( + fs::read_to_string(fixture.selected.join("api/notes.txt")).unwrap(), + INITIAL + ); + daemon.stop().await; +} + +#[tokio::test] +async fn symlinks_directories_and_fifo_leaves_are_never_opened_as_regular_text() { + let (fixture, daemon, mut client) = Fixture::new().await; + symlink( + fixture.selected.join("api/notes.txt"), + fixture.selected.join("link.txt"), + ) + .unwrap(); + symlink( + fixture.selected.join("api"), + fixture.selected.join("linked-dir"), + ) + .unwrap(); + let before = read(&mut client, &fixture.target, b"api/notes.txt").await; + for bytes in [b"link.txt".as_slice(), b"linked-dir/notes.txt".as_slice()] { + assert_eq!( + failure(&mut client, "file.read", request(&fixture.target, bytes)) + .await + .code, + "file_symlink_not_allowed" + ); + assert_eq!( + failure( + &mut client, + "file.write", + write_request(&fixture.target, bytes, "changed", revision(&before)) + ) + .await + .code, + "file_symlink_not_allowed" + ); + } + assert_eq!( + failure(&mut client, "file.read", request(&fixture.target, b"api")) + .await + .code, + "file_not_regular" + ); + assert_eq!( + failure( + &mut client, + "file.list", + request(&fixture.target, b"api/notes.txt") + ) + .await + .code, + "file_not_directory" + ); + + let fifo = fixture.selected.join("api/notes.txt"); + fs::remove_file(&fifo).unwrap(); + let output = Command::new("mkfifo").arg(&fifo).output().unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + failure( + &mut client, + "file.read", + request(&fixture.target, b"api/notes.txt") + ) + .await + .code, + "file_not_regular" + ); + assert_eq!( + failure( + &mut client, + "file.write", + write_request( + &fixture.target, + b"api/notes.txt", + "changed", + revision(&before) + ) + ) + .await + .code, + "file_not_regular" + ); + let listing = call(&mut client, "file.list", request(&fixture.target, b"api")).await; + assert_eq!(listing["entries"][0]["kind"], "other"); + let root_listing = call(&mut client, "file.list", request(&fixture.target, b"")).await; + assert_eq!( + root_listing["entries"] + .as_array() + .unwrap() + .iter() + .filter(|entry| entry["kind"] == "symlink") + .count(), + 2 + ); + daemon.stop().await; +} + +#[tokio::test] +async fn a_replaced_registered_root_and_pending_worktree_removal_reject_writes() { + let (fixture, daemon, mut client) = Fixture::new().await; + let before = read(&mut client, &fixture.target, b"api/notes.txt").await; + let moved = fixture.root.path().join("moved-apps"); + fs::rename(&fixture.selected, &moved).unwrap(); + symlink(&moved, &fixture.selected).unwrap(); + let read_error = failure( + &mut client, + "file.read", + request(&fixture.target, b"api/notes.txt"), + ) + .await; + let write_error = failure( + &mut client, + "file.write", + write_request( + &fixture.target, + b"api/notes.txt", + "changed", + revision(&before), + ), + ) + .await; + fs::remove_file(&fixture.selected).unwrap(); + fs::rename(&moved, &fixture.selected).unwrap(); + assert_eq!(read_error.code, "file_target_changed"); + assert_eq!(write_error.code, "file_target_changed"); + assert_eq!( + fs::read_to_string(fixture.selected.join("api/notes.txt")).unwrap(), + INITIAL + ); + + let isolated = fixture.session(&mut client, "new_worktree").await; + let target = json!({"kind": "worktree", "worktreeId": isolated["worktreeId"]}); + let before = read(&mut client, &target, b"apps/api/notes.txt").await; + let prepared = call( + &mut client, + "worktree.prepare_remove", + json!({"worktreeId": isolated["worktreeId"]}), + ) + .await; + assert_eq!(prepared["status"], "ready"); + assert_eq!( + failure( + &mut client, + "file.write", + write_request(&target, b"apps/api/notes.txt", "changed", revision(&before)) + ) + .await + .code, + "file_target_changed" + ); + assert_eq!( + fs::read_to_string( + Path::new(isolated["worktreePath"].as_str().unwrap()).join("apps/api/notes.txt") + ) + .unwrap(), + INITIAL + ); + daemon.stop().await; +} + +#[tokio::test] +async fn binary_oversize_and_unsupported_hardlinks_are_rejected_without_data_loss() { + let (fixture, daemon, mut client) = Fixture::new().await; + for (name, bytes, code) in [ + ("nul.bin", vec![b'a', 0, b'b'], "file_not_text"), + ("invalid.bin", vec![0xff, 0xfe], "file_not_text"), + ( + "large.txt", + vec![b'x'; MAX_TEXT_BYTES + 1], + "file_too_large", + ), + ] { + fs::write(fixture.selected.join(name), &bytes).unwrap(); + assert_eq!( + failure( + &mut client, + "file.read", + request(&fixture.target, name.as_bytes()) + ) + .await + .code, + code + ); + assert_eq!(fs::read(fixture.selected.join(name)).unwrap(), bytes); + } + let before = read(&mut client, &fixture.target, b"api/notes.txt").await; + let too_large = "é".repeat(MAX_TEXT_BYTES / 2 + 1); + assert_eq!( + failure( + &mut client, + "file.write", + write_request( + &fixture.target, + b"api/notes.txt", + &too_large, + revision(&before) + ) + ) + .await + .code, + "invalid_input" + ); + assert_eq!( + failure( + &mut client, + "file.write", + write_request(&fixture.target, b"missing.txt", "new", revision(&before)) + ) + .await + .code, + "file_not_found" + ); + assert!(!fixture.selected.join("missing.txt").exists()); + + let original = fixture.selected.join("api/notes.txt"); + let linked = fixture.root.path().join("hardlink.txt"); + fs::hard_link(&original, &linked).unwrap(); + let linked_read = read(&mut client, &fixture.target, b"api/notes.txt").await; + assert_eq!( + failure( + &mut client, + "file.write", + write_request( + &fixture.target, + b"api/notes.txt", + "changed", + revision(&linked_read) + ) + ) + .await + .code, + "file_metadata_unsupported" + ); + assert_eq!(fs::read_to_string(&original).unwrap(), INITIAL); + assert_eq!(fs::read_to_string(&linked).unwrap(), INITIAL); + assert_eq!( + fs::metadata(original).unwrap().ino(), + fs::metadata(linked).unwrap().ino() + ); + daemon.stop().await; +} + +#[tokio::test] +async fn two_clients_cannot_both_save_one_revision_even_through_different_registered_targets() { + let (fixture, daemon, mut project_client) = Fixture::new().await; + let session = fixture.session(&mut project_client, "current").await; + let session_target = json!({"kind": "session", "sessionId": session["id"]}); + let mut session_client = daemon.connect().await; + let project_read = read(&mut project_client, &fixture.target, b"api/notes.txt").await; + let session_read = read(&mut session_client, &session_target, b"notes.txt").await; + assert_eq!(revision(&project_read), revision(&session_read)); + let writes = ["project wins\n", "session wins\n"]; + let (first, second) = tokio::join!( + exchange( + &mut project_client, + "file.write", + write_request( + &fixture.target, + b"api/notes.txt", + writes[0], + revision(&project_read) + ) + ), + exchange( + &mut session_client, + "file.write", + write_request( + &session_target, + b"notes.txt", + writes[1], + revision(&session_read) + ) + ), + ); + let mut winner = None; + let mut conflicts = 0; + for (response, candidate) in [first, second].into_iter().zip(writes) { + match response.payload { + ResponsePayload::Success { data } => { + assert!( + winner.replace(candidate).is_none(), + "one revision must have exactly one successful writer" + ); + revision(&data); + } + ResponsePayload::Error { error } => { + assert_eq!(error.code, "file_conflict"); + assert!(!format!("{error:?}").contains(INITIAL)); + conflicts += 1; + } + } + } + assert_eq!(conflicts, 1); + assert_eq!( + fs::read_to_string(fixture.selected.join("api/notes.txt")).unwrap(), + winner.unwrap() + ); + daemon.stop().await; +} + +#[tokio::test] +async fn external_content_and_inode_changes_conflict_and_saves_preserve_unix_permissions() { + let (fixture, daemon, mut client) = Fixture::new().await; + let disk = fixture.selected.join("api/notes.txt"); + fs::set_permissions(&disk, fs::Permissions::from_mode(0o640)).unwrap(); + let before = read(&mut client, &fixture.target, b"api/notes.txt").await; + fs::write(&disk, "external content\n").unwrap(); + assert_eq!( + failure( + &mut client, + "file.write", + write_request( + &fixture.target, + b"api/notes.txt", + "stale", + revision(&before) + ) + ) + .await + .code, + "file_conflict" + ); + assert_eq!(fs::read_to_string(&disk).unwrap(), "external content\n"); + let before = read(&mut client, &fixture.target, b"api/notes.txt").await; + let replacement = fixture.selected.join("api/replacement.txt"); + fs::write(&replacement, "external content\n").unwrap(); + fs::set_permissions(&replacement, fs::Permissions::from_mode(0o640)).unwrap(); + fs::rename(&replacement, &disk).unwrap(); + assert_eq!( + failure( + &mut client, + "file.write", + write_request( + &fixture.target, + b"api/notes.txt", + "stale", + revision(&before) + ) + ) + .await + .code, + "file_conflict" + ); + + let metadata = fs::metadata(&disk).unwrap(); + let before = read(&mut client, &fixture.target, b"api/notes.txt").await; + call( + &mut client, + "file.write", + write_request( + &fixture.target, + b"api/notes.txt", + "current\r\n", + revision(&before), + ), + ) + .await; + let saved = fs::metadata(&disk).unwrap(); + assert_eq!(saved.permissions().mode() & 0o777, 0o640); + assert_eq!(saved.uid(), metadata.uid()); + assert_eq!(saved.gid(), metadata.gid()); + assert_eq!(fs::read(&disk).unwrap(), b"current\r\n"); + daemon.stop().await; +} + +#[tokio::test] +async fn saved_text_and_revision_survive_daemon_restart_and_remain_writable() { + let (fixture, first, mut client) = Fixture::new().await; + let before = read(&mut client, &fixture.target, b"api/notes.txt").await; + let saved = call( + &mut client, + "file.write", + write_request( + &fixture.target, + b"api/notes.txt", + "persisted\r\n", + revision(&before), + ), + ) + .await; + drop(client); + first.stop().await; + let second = RunningDaemon::start(fixture.root.path()); + let mut client = second.connect().await; + let recovered = read(&mut client, &fixture.target, b"api/notes.txt").await; + assert_eq!(recovered["text"], "persisted\r\n"); + assert_eq!(revision(&recovered), revision(&saved)); + call( + &mut client, + "file.write", + write_request( + &fixture.target, + b"api/notes.txt", + "saved after restart\n", + revision(&saved), + ), + ) + .await; + assert_eq!( + fs::read_to_string(fixture.selected.join("api/notes.txt")).unwrap(), + "saved after restart\n" + ); + second.stop().await; +} + +#[tokio::test] +async fn pagination_is_byte_ordered_and_rejects_unbounded_enumerations() { + let (fixture, daemon, mut client) = Fixture::new().await; + let pages = fixture.selected.join("pages"); + fs::create_dir(&pages).unwrap(); + for index in 0..205 { + fs::write(pages.join(format!("{index:03}.txt")), []).unwrap(); + } + let first = call(&mut client, "file.list", request(&fixture.target, b"pages")).await; + assert_eq!(first["entries"].as_array().unwrap().len(), 100); + assert_eq!(first["nextAfterNameBase64"], path(b"099.txt")); + let second = call( + &mut client, + "file.list", + json!({ + "target": fixture.target, "pathBase64": path(b"pages"), "limit": 200, + "afterNameBase64": first["nextAfterNameBase64"] + }), + ) + .await; + assert_eq!(second["entries"].as_array().unwrap().len(), 105); + assert!(second.get("nextAfterNameBase64").is_none()); + let combined = [entry_paths(&first), entry_paths(&second)].concat(); + assert_eq!( + combined, + (0..205) + .map(|index| format!("pages/{index:03}.txt").into_bytes()) + .collect::>() + ); + for limit in [0, 201] { + assert_eq!( + failure( + &mut client, + "file.list", + json!({"target": fixture.target, "pathBase64": path(b"pages"), "limit": limit}) + ) + .await + .code, + "invalid_input" + ); + } + let large = fixture.selected.join("many"); + fs::create_dir(&large).unwrap(); + for index in 0..10_001 { + fs::write(large.join(format!("{index:05}")), []).unwrap(); + } + assert_eq!( + failure(&mut client, "file.list", request(&fixture.target, b"many")) + .await + .code, + "file_listing_too_large" + ); + daemon.stop().await; +} + +#[tokio::test] +async fn maximum_text_with_worst_case_json_escaping_round_trips_inside_one_frame() { + let (fixture, daemon, mut client) = Fixture::new().await; + let text = "\u{0001}".repeat(MAX_TEXT_BYTES); + fs::write(fixture.selected.join("escaped.txt"), &text).unwrap(); + let before = read(&mut client, &fixture.target, b"escaped.txt").await; + assert_eq!(before["text"], text); + assert_eq!(before["sizeBytes"], MAX_TEXT_BYTES); + assert!(serde_json::to_vec(&before).unwrap().len() > 6 * MAX_TEXT_BYTES); + let updated = "\u{0002}".repeat(MAX_TEXT_BYTES); + let saved = call( + &mut client, + "file.write", + write_request(&fixture.target, b"escaped.txt", &updated, revision(&before)), + ) + .await; + assert_eq!( + fs::read(fixture.selected.join("escaped.txt")).unwrap(), + updated.as_bytes() + ); + assert_eq!( + revision(&read(&mut client, &fixture.target, b"escaped.txt").await), + revision(&saved) + ); + daemon.stop().await; +} + +#[tokio::test] +async fn directory_pages_respect_the_encoded_response_budget_without_losing_names() { + let (fixture, daemon, mut client) = Fixture::new().await; + let mut directory = fs::File::open(&fixture.selected).unwrap(); + let mut relative = Vec::new(); + for index in 0..20 { + let component = format!("d{index:02}{}", "a".repeat(117)); + rustix::fs::mkdirat(&directory, &component, rustix::fs::Mode::RWXU).unwrap(); + directory = rustix::fs::openat( + &directory, + &component, + rustix::fs::OFlags::RDONLY + | rustix::fs::OFlags::DIRECTORY + | rustix::fs::OFlags::NOFOLLOW, + rustix::fs::Mode::empty(), + ) + .unwrap() + .into(); + if !relative.is_empty() { + relative.push(b'/'); + } + relative.extend_from_slice(component.as_bytes()); + } + for index in 0..200 { + let name = format!("n{index:03}{}", "b".repeat(216)); + rustix::fs::openat( + &directory, + &name, + rustix::fs::OFlags::WRONLY | rustix::fs::OFlags::CREATE | rustix::fs::OFlags::EXCL, + rustix::fs::Mode::RUSR | rustix::fs::Mode::WUSR, + ) + .unwrap(); + } + let mut cursor: Option = None; + let mut all_paths = Vec::new(); + loop { + let mut payload = + json!({"target": fixture.target, "pathBase64": path(&relative), "limit": 200}); + if let Some(after) = &cursor { + payload["afterNameBase64"] = after.clone(); + } + let page = call(&mut client, "file.list", payload).await; + assert!(serde_json::to_vec(&page).unwrap().len() <= MAX_LIST_BYTES); + let names = entry_paths(&page); + assert!(!names.is_empty()); + if cursor.is_none() { + assert!( + names.len() < 200, + "encoded budget must paginate before entry limit" + ); + } + all_paths.extend(names); + assert!( + all_paths.len() <= 200, + "pagination must not repeat previous names" + ); + cursor = page.get("nextAfterNameBase64").cloned(); + if cursor.is_none() { + break; + } + } + assert_eq!(all_paths.len(), 200); + let mut unique = all_paths.clone(); + unique.sort(); + unique.dedup(); + assert_eq!(all_paths, unique); + daemon.stop().await; +} diff --git a/crates/file-metadata/Cargo.toml b/crates/file-metadata/Cargo.toml new file mode 100644 index 0000000..1686f80 --- /dev/null +++ b/crates/file-metadata/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "cli-master-file-metadata" +version.workspace = true +edition.workspace = true +rust-version.workspace = true + +[dev-dependencies] +tempfile = "3" + +# ADR 0006: only the private Darwin module may override this denial. The +# workspace-wide forbid policy remains in force for every other crate. +[lints.rust] +unsafe_code = "deny" +unsafe_op_in_unsafe_fn = "deny" + +[lints.clippy] +all = "warn" +pedantic = "warn" + diff --git a/crates/file-metadata/SAFETY.md b/crates/file-metadata/SAFETY.md new file mode 100644 index 0000000..9f927bd --- /dev/null +++ b/crates/file-metadata/SAFETY.md @@ -0,0 +1,50 @@ +# Darwin ACL boundary + +ADR [0006](../../docs/adr/0006-local-files-and-editor.md) accepts this narrow +exception because the editor must inspect metadata on its already-open file. +All public APIs are safe Rust. Only the private `darwin` module permits unsafe +code; the workspace and other crates retain `unsafe_code = "forbid"`. + +The three bindings match `sys/acl.h` in Apple's installed macOS SDK: +`acl_get_fd_np(int, acl_type_t) -> acl_t`, +`acl_get_entry(acl_t, int, acl_entry_t *) -> int`, and +`acl_free(void *) -> int`. Opaque object and entry pointers are never +dereferenced by Rust. The positive `acl_type_t` enum uses C unsigned-int ABI; +`ACL_TYPE_EXTENDED` is `0x100`, and `ACL_FIRST_ENTRY` is `0`. +`sys/errno.h` defines `ENOENT = 2` and `EINVAL = 22` on Darwin. + +- A borrowed `AsFd` descriptor remains valid for the entire inspection. The + helper neither closes it nor constructs a pathname from it. +- A successful `acl_get_fd_np` allocates an independent ACL. `OwnedAcl` owns + and frees it exactly once, including error returns. Its pointer is private; + it has no clone operation or manual `Send`/`Sync` implementation. +- `acl_get_entry` receives a live ACL and writable pointer-sized output. The + borrowed entry is never read or exported. Darwin returns **0 for success**, + and **-1/EINVAL for no first entry** in a valid allocated ACL. + Both mean an ACL is present: even an allocated empty ACL may carry ACL-level + inheritance flags, so the helper conservatively preserves that policy. +- No-ACL files can instead return NULL/ENOENT from `acl_get_fd_np` because + `FILESEC_ACL` is absent. Only this documented absence maps to `false` at + acquisition; other OS errors propagate. Errno is captured immediately, + before the RAII destructor can call `acl_free`. +- This is an observation, not synchronization with other writers. Atomic save + still requires the daemon's metadata and revision rechecks. This helper + does not copy metadata or assert that a later replacement is safe. + +These semantics follow Apple's primary +[get-ACL documentation](https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/acl_get.3.html), +[entry documentation](https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/acl_get_entry.3.html), +[ACL file implementation](https://github.com/apple-oss-distributions/Libc/blob/main/posix1e/acl_file.c), +[entry implementation](https://github.com/apple-oss-distributions/Libc/blob/main/posix1e/acl_entry.c), and +[security-property implementation](https://github.com/apple-oss-distributions/Libc/blob/main/gen/filesec.c). +The constants and signatures were also checked against +`/Library/Developer/CommandLineTools/SDKs/MacOSX.sdk/usr/include/sys/acl.h` +and `sys/errno.h` on the development host. + +Integration tests use the safe public API on real temporary files, covering +no ACL, explicit ACL, inheritance, ACL removal, and renamed/replaced paths. +An ACL that denies reading security metadata verifies error propagation. +Only test fixture setup invokes `/bin/chmod`, directly with an argv array; +the library has no subprocess or filesystem-path dependency. On Linux the +helper returns `Unsupported`; the daemon's safe rustix xattr checks own Linux +ACL detection. diff --git a/crates/file-metadata/src/darwin.rs b/crates/file-metadata/src/darwin.rs new file mode 100644 index 0000000..4e11d52 --- /dev/null +++ b/crates/file-metadata/src/darwin.rs @@ -0,0 +1,68 @@ +//! Minimal bindings verified against Apple's SDK `sys/acl.h` and `sys/errno.h`. +//! See `../SAFETY.md` for the ownership and return-value audit. + +use std::ffi::{c_int, c_uint, c_void}; +use std::io; +use std::os::fd::{AsRawFd, BorrowedFd}; +use std::ptr::{self, NonNull}; + +// acl_type_t is a C enum whose defined values are nonnegative; its Darwin ABI +// representation is unsigned int. acl_t and acl_entry_t are opaque pointers. +const ACL_TYPE_EXTENDED: c_uint = 0x0000_0100; +const ACL_FIRST_ENTRY: c_int = 0; +const ENOENT: c_int = 2; +const EINVAL: c_int = 22; + +unsafe extern "C" { + fn acl_get_fd_np(fd: c_int, acl_type: c_uint) -> *mut c_void; + fn acl_get_entry(acl: *mut c_void, entry_id: c_int, entry: *mut *mut c_void) -> c_int; + fn acl_free(object: *mut c_void) -> c_int; +} + +/// Owns exactly one allocation returned by `acl_get_fd_np`. +struct OwnedAcl(NonNull); + +impl Drop for OwnedAcl { + fn drop(&mut self) { + // SAFETY: this non-null pointer came from acl_get_fd_np and is freed + // exactly once. No ACL entry pointer escapes or survives this owner. + let _ = unsafe { acl_free(self.0.as_ptr()) }; + } +} + +pub(super) fn has_extended_acl(fd: BorrowedFd<'_>) -> io::Result { + // SAFETY: BorrowedFd keeps the descriptor valid for this call. The supported + // ACL type is fixed, and the returned ACL is a separately owned allocation. + let acl = unsafe { acl_get_fd_np(fd.as_raw_fd(), ACL_TYPE_EXTENDED) }; + let Some(acl) = NonNull::new(acl) else { + let error = io::Error::last_os_error(); + // Darwin's filesec_get_property(FILESEC_ACL) reports ENOENT when the + // descriptor's security metadata has no ACL property. No path is used. + return if error.raw_os_error() == Some(ENOENT) { + Ok(false) + } else { + Err(error) + }; + }; + let acl = OwnedAcl(acl); + let mut entry = ptr::null_mut(); + // SAFETY: the owned ACL remains live, ACL_FIRST_ENTRY is a valid selector, + // and entry is initialized writable storage for the borrowed output pointer. + let result = unsafe { acl_get_entry(acl.0.as_ptr(), ACL_FIRST_ENTRY, &raw mut entry) }; + if result == 0 { + return Ok(true); + } + + // Capture errno before OwnedAcl::drop calls another C function. Darwin + // returns -1/EINVAL when the first entry of a valid ACL does not exist. + // Unlike Linux's ACL API, Darwin does not return zero for end-of-list. + let error = io::Error::last_os_error(); + if result == -1 && error.raw_os_error() == Some(EINVAL) { + // An existing but empty ACL can still have ACL-level inheritance flags. + // Preserve that distinction from a missing ACL property: the editor + // must reject replacement rather than discard uninspected policy. + Ok(true) + } else { + Err(error) + } +} diff --git a/crates/file-metadata/src/lib.rs b/crates/file-metadata/src/lib.rs new file mode 100644 index 0000000..4f1b48e --- /dev/null +++ b/crates/file-metadata/src/lib.rs @@ -0,0 +1,42 @@ +//! Descriptor-based extended ACL inspection for the local file editor. +//! +//! The Darwin implementation is the narrow FFI exception accepted in ADR 0006. +//! This crate never reopens paths, changes metadata, closes the caller's file +//! descriptor, or launches a subprocess. + +#![deny(unsafe_code)] +#![deny(unsafe_op_in_unsafe_fn)] + +use std::io; +use std::os::fd::AsFd; + +#[cfg(target_os = "macos")] +#[allow(unsafe_code)] +mod darwin; + +/// Reports whether the pinned filesystem object has an extended ACL. +/// +/// The result is an observation, not a lock: another process may change metadata +/// immediately afterward. Explicit and inherited entries count as present, as +/// does an allocated empty ACL, which may carry ACL-level inheritance policy. +/// +/// # Errors +/// +/// Returns the operating system's error if the ACL cannot be inspected. On +/// platforms other than macOS, returns [`io::ErrorKind::Unsupported`]; callers +/// must use their platform-specific ACL/xattr inspection instead of treating +/// unavailable inspection as evidence that no ACL exists. +pub fn has_extended_acl(fd: impl AsFd) -> io::Result { + #[cfg(target_os = "macos")] + { + darwin::has_extended_acl(fd.as_fd()) + } + #[cfg(not(target_os = "macos"))] + { + let _ = fd; + Err(io::Error::new( + io::ErrorKind::Unsupported, + "descriptor ACL inspection is implemented for macOS only", + )) + } +} diff --git a/crates/file-metadata/tests/darwin_acl.rs b/crates/file-metadata/tests/darwin_acl.rs new file mode 100644 index 0000000..c303aef --- /dev/null +++ b/crates/file-metadata/tests/darwin_acl.rs @@ -0,0 +1,101 @@ +#![cfg(target_os = "macos")] + +use std::fs::{self, File}; +use std::io; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::process::Command; + +use cli_master_file_metadata::has_extended_acl; +use tempfile::TempDir; + +fn chmod(path: &Path, arguments: &[&str]) { + let output = Command::new("/bin/chmod") + .args(arguments) + .arg(path) + .output() + .expect("run chmod for real temporary-file ACL fixture"); + assert!( + output.status.success(), + "chmod fixture failed: {}", + String::from_utf8_lossy(&output.stderr) + ); +} + +fn clean_directory() -> TempDir { + let directory = tempfile::tempdir().expect("temporary directory"); + chmod(directory.path(), &["-N"]); + directory +} + +#[test] +fn ordinary_file_without_acl_remains_editable() { + let directory = clean_directory(); + let path = directory.path().join("ordinary.txt"); + fs::write(&path, "ordinary text\n").unwrap(); + let file = File::open(path).unwrap(); + + assert!(!has_extended_acl(&file).unwrap()); + assert!(file.metadata().is_ok(), "inspection must not close the fd"); +} + +#[test] +fn explicit_acl_is_observed_without_modifying_content_or_mode() { + let directory = clean_directory(); + let path = directory.path().join("explicit.txt"); + fs::write(&path, "keep this content\n").unwrap(); + chmod(&path, &["+a", "everyone allow read"]); + let file = File::open(&path).unwrap(); + let permissions = file.metadata().unwrap().permissions().mode(); + + assert!(has_extended_acl(&file).unwrap()); + assert_eq!(fs::read(&path).unwrap(), b"keep this content\n"); + assert_eq!(file.metadata().unwrap().permissions().mode(), permissions); + chmod(&path, &["-N"]); + assert!(!has_extended_acl(&file).unwrap()); +} + +#[test] +fn inherited_acl_is_detected_on_new_files() { + let directory = clean_directory(); + chmod( + directory.path(), + &["+a", "everyone allow read,file_inherit,directory_inherit"], + ); + let path = directory.path().join("inherited.txt"); + fs::write(&path, "inherited policy\n").unwrap(); + let file = File::open(&path).unwrap(); + + assert!(has_extended_acl(&file).unwrap()); + chmod(&path, &["-N"]); + assert!(!has_extended_acl(&file).unwrap()); +} + +#[test] +fn inspection_follows_the_open_descriptor_after_path_replacement() { + let directory = clean_directory(); + let path = directory.path().join("document.txt"); + fs::write(&path, "original\n").unwrap(); + chmod(&path, &["+a", "everyone allow read"]); + let original = File::open(&path).unwrap(); + + fs::rename(&path, directory.path().join("moved.txt")).unwrap(); + fs::write(&path, "replacement\n").unwrap(); + let replacement = File::open(&path).unwrap(); + + assert!(has_extended_acl(&original).unwrap()); + assert!(!has_extended_acl(&replacement).unwrap()); +} + +#[test] +fn denied_acl_inspection_is_an_error_instead_of_missing_acl() { + let directory = clean_directory(); + let path = directory.path().join("denied.txt"); + fs::write(&path, "protected security metadata\n").unwrap(); + let file = File::open(&path).unwrap(); + chmod(&path, &["+a", "everyone deny readsecurity"]); + + let result = has_extended_acl(&file); + chmod(&path, &["-N"]); + assert_eq!(result.unwrap_err().kind(), io::ErrorKind::PermissionDenied); +} diff --git a/crates/file-metadata/tests/unsupported.rs b/crates/file-metadata/tests/unsupported.rs new file mode 100644 index 0000000..585e6cf --- /dev/null +++ b/crates/file-metadata/tests/unsupported.rs @@ -0,0 +1,14 @@ +#![cfg(not(target_os = "macos"))] + +use std::io; + +use cli_master_file_metadata::has_extended_acl; + +#[test] +fn unavailable_inspection_is_an_error_instead_of_missing_acl() { + let file = tempfile::tempfile().unwrap(); + assert_eq!( + has_extended_acl(&file).unwrap_err().kind(), + io::ErrorKind::Unsupported + ); +} diff --git a/docs/adr/0006-local-files-and-editor.md b/docs/adr/0006-local-files-and-editor.md index 681c710..52e2680 100644 --- a/docs/adr/0006-local-files-and-editor.md +++ b/docs/adr/0006-local-files-and-editor.md @@ -87,7 +87,10 @@ Responses return `pathBase64` as the exact identifier and `displayName` for presentation. Escape control/undecodable bytes in display text; never rebuild an operation path from that text. Raw names never become HTML or command arguments. Non-UTF-8 names are listable and can identify an editable UTF-8 -file on either supported platform. +file where the underlying filesystem permits such names. APFS can reject +invalid UTF-8 filenames with EILSEQ before the service is called; Linux socket +tests exercise those names and pure contract tests cover their wire identity +on both platforms. ### Public operations @@ -133,9 +136,9 @@ use higher-resolution metadata internally and never depend on epoch-ms alone. Use safe APIs from the existing direct daemon dependency `rustix 1.1.4` with feature `fs`. Its local source provides `openat`, `statat`, `fstat`, `renameat`, -`unlinkat`, directory iteration and `fsync`. No new project `unsafe` block, -shell command, external file utility or platform-specific path syntax is -needed. `renameat` is the portable directory-relative replacement operation. +`unlinkat`, directory iteration and `fsync`. Traversal and publication introduce no project `unsafe` block, shell +command, external file utility or platform-specific path syntax. Darwin ACL +inspection has the narrowly isolated exception defined below. `renameat` is the portable directory-relative replacement operation. [rustix reference](https://docs.rs/rustix/1.1.4/rustix/fs/fn.renameat.html) Open the validated root as a directory descriptor, then walk each path @@ -197,6 +200,17 @@ unsupported result before accepting such a save. They must not be silently advertised as preserved. Hard-linked files return `file_metadata_unsupported` because replace would otherwise break the link relationship. +The initial macOS implementation preserves one explicitly supported extended +attribute, `com.apple.provenance`. Real files created on the validation host +receive this attribute automatically, including the service's temporary files. +Read its bounded value through the pinned descriptors, copy it when needed, +and verify that the source and destination attribute sets and bytes agree +before publication. If the source has no such attribute and the destination +does, reject the save unless exact absence can be established; do not assume +that a successful removal call proves absence. Reject every other extended +attribute, oversized value or inspection/copy mismatch. This is a narrow +preservation exception, not permission to silently discard unknown metadata. + The lock guarantees revision ordering among daemon writers. External editors do not honor it. The final comparison followed by POSIX `renameat` leaves a small external-write race: it is **optimistic conflict detection**, not a @@ -212,6 +226,32 @@ client to re-read before retrying. Do not pretend rollback occurred or send a second implicit write. A daemon crash can leave a uniquely named temporary file; startup must not sweep unknown files from project directories. +### Narrow Darwin ACL metadata boundary + +The current safe `rustix` interface does not provide Darwin ACL inspection +through a file descriptor. The safe public exacl API takes a pathname, which +would lose the pinned-object guarantee; `/dev/fd` is not assumed equivalent. +A normal macOS text file must remain editable while a file with an extended +ACL must not lose that ACL during atomic replacement. + +Add `crates/file-metadata` solely for the safe public function +`has_extended_acl(fd: impl AsFd) -> io::Result`. Its private Darwin +module contains the minimal audited bindings to `acl_get_fd_np`, +`acl_get_entry` and `acl_free`. Constants and signatures are verified against +the installed Apple SDK. The borrowed descriptor is never closed, returned +ACL storage is owned by an RAII guard, errors preserve errno, and callers +never receive raw pointers. No subprocess or path reopening is permitted in +this service. Linux continues to inspect ACL/xattrs through safe rustix APIs. + +This crate explicitly denies unsafe code except within that private module, +and denies unsafe operations within unsafe functions unless individually +marked. It is the sole exception to inheriting the workspace's unsafe-code +forbid lint; workspace/core/daemon/session policy remains unchanged. All +other lints retain the workspace's strictness. The exception enables direct +inspection rather than weakening the file write policy or rejecting every +ordinary file on macOS. Test the public safe API with real regular files and +extended ACLs, and review ownership and each unsafe call before publication. + ### Errors, synchronization and S3 reuse Use existing `ApiError` envelopes. Stable service error codes are diff --git a/protocol/catalog.json b/protocol/catalog.json index 2a775f8..a446995 100644 --- a/protocol/catalog.json +++ b/protocol/catalog.json @@ -30,6 +30,9 @@ "worktree.list", "worktree.prepare_remove", "worktree.remove", + "file.list", + "file.read", + "file.write", "diagnostics.get", "knowledge.list", "knowledge.save", From 2499b0b54f2f29db45a3597e2a7ffe8a5d542cca Mon Sep 17 00:00:00 2001 From: guicybercode Date: Sat, 5 Sep 2026 22:39:15 -0300 Subject: [PATCH 12/12] docs(runtime): publish file integration evidence and organization ownership --- crates/file-metadata/Cargo.toml | 1 - docs/codex/maestri-runtime-report.md | 99 +++++++++++++++++++----- docs/codex/xirp-integration-request.md | 67 ++++++++++++++++ docs/codex/xirp-organization-contract.md | 70 +++++++++++++++++ docs/maestri-runtime-parity.md | 18 +++-- 5 files changed, 231 insertions(+), 24 deletions(-) create mode 100644 docs/codex/xirp-organization-contract.md diff --git a/crates/file-metadata/Cargo.toml b/crates/file-metadata/Cargo.toml index 1686f80..93fcc57 100644 --- a/crates/file-metadata/Cargo.toml +++ b/crates/file-metadata/Cargo.toml @@ -16,4 +16,3 @@ unsafe_op_in_unsafe_fn = "deny" [lints.clippy] all = "warn" pedantic = "warn" - diff --git a/docs/codex/maestri-runtime-report.md b/docs/codex/maestri-runtime-report.md index 5eec583..5d004be 100644 --- a/docs/codex/maestri-runtime-report.md +++ b/docs/codex/maestri-runtime-report.md @@ -6,9 +6,9 @@ Baseline de criação: `0ac8dd7d49eefee16e5efbf389994f551bc584f5`, obtido de `origin/refactor/canvas-only-shell`. Integração final pertence à S1 nessa branch. O objetivo completo permanece na [matriz de runtime](../maestri-runtime-parity.md). -Esta entrega resolve o primeiro caminho backend. Floors, landing, editor, -canvas durável, presets, continuidade nativa, comunicação, rotinas e ambientes -remotos permanecem em desenvolvimento. Integração desktop não foi presumida. +A integração de worktrees está publicada e a primeira fatia de arquivos/editor +está publicada com testes locais aprovados. Floors, landing, canvas durável, presets, continuidade nativa, +comunicação, rotinas e ambientes remotos permanecem em desenvolvimento. Integração desktop não foi presumida. ## Commits disponíveis @@ -16,8 +16,13 @@ remotos permanecem em desenvolvimento. Integração desktop não foi presumida. | --- | --- | --- | | `7693468` | Saga separa preparação de início; associação SQLite transacional; subdiretórios; compensação e cancelamento de tokens. | Suite session/storage executada; após ajustes finais, 29 testes de create/prepare/remove passaram. Clippy session/storage sem warnings. | | `3c25a85` | Daemon liga new_worktree, snapshot/listagem, preparo/remoção e recovery à saga compartilhando SessionManager e Storage. | 118 testes core/daemon passaram, incluindo 9 fluxos novos pelo socket real; Clippy dos quatro pacotes sem warnings. | +| `910ed7d`, `1a74bbb` | Integra documentação e persistência knowledge da S3; dispatch bloqueante sai do leitor assíncrono. | Contratos core, 9 testes storage knowledge, 2 socket knowledge, 9 socket worktree e 15 testes IPC frontend passaram. | +| `b271825` | ADR 0006 define arquivos/editor, revisão e política de salvamento. | Decisão de arquitetura; não representa implementação funcional. | +| `45d817a` | Preserva o canvas e o navegador nativo publicados pela S1 até `1e75081`. | Typecheck frontend e testes daemon lib/IPC/knowledge/worktree passaram; CI e Packaging Linux/macOS aprovados. | +| `c98cf25` | Probe retenta ETXTBSY com o limite existente e preserva classificação/errno sem dados sensíveis. | 56 testes agents e Clippy passaram no macOS; duas regressões específicas de ETXTBSY aguardam Linux CI. | +| `1225931` | Serviço file.list/read/write, salvamento atômico com revisão, metadados preservados e cliente IPC tipado. | 13 contratos file, 8 casos internos de disco/falha, 12 socket file, 5 ACL macOS; 51 testes IPC frontend passaram. | -Ambos usam a identidade Git configurada `guicybercode`, sem trailers +Os commits usam a identidade Git configurada `guicybercode`, sem trailers `Co-authored-by`. Publicados em `origin/feat/maestri-runtime`. ## Contratos prontos para integração @@ -35,7 +40,8 @@ Ambos usam a identidade Git configurada `guicybercode`, sem trailers Rust wire, `protocol/catalog.json`, `ipc/methods.ts` e `ipc/domain.ts` foram sincronizados. Os dois últimos são artefatos aditivos: o baseline havia removido esses caminhos citados em AGENTS. Nenhum componente React, estado de -canvas, estilo ou bridge Tauri foi alterado. +canvas, estilo ou bridge Tauri foi alterado por S2; as mudanças S1 foram +preservadas na integração. O cliente IPC ganhou métodos tipados de arquivo. Erros relevantes: `worktree_confirmation_invalid`, `worktree_in_use`, `worktree_dirty`, `worktree_not_active`, `worktree_identity_changed`, @@ -49,9 +55,13 @@ ou reconexão. A existência dos nomes no catálogo não prova entrega de evento ## Verificação executada e limites PR de integração: [#44](https://github.com/guicybercode/Jig/pull/44), em draft. -CI e Packaging Linux/macOS iniciados; resultados ainda pendentes. +CI e Packaging de `45d817a` passaram em Linux/macOS, nos runs +[CI 34003098853](https://github.com/guicybercode/Jig/actions/runs/34003098853) e +[Packaging 34003098946](https://github.com/guicybercode/Jig/actions/runs/34003098946). +Os commits posteriores de probe/arquivos exigem novas execuções; a aprovação +anterior não comprova esses novos caminhos. -Host local: macOS. Passaram: +Host local: macOS. Na primeira integração de worktrees, passaram: - `CARGO_INCREMENTAL=0 cargo test -p cli-master-core -p cli-master-daemon --locked` — 118 testes, dos quais 9 novos em `worktree_ipc.rs`. @@ -64,8 +74,8 @@ Host local: macOS. Passaram: Os casos socket exercitam start/stop/restart concorrentes, troca de raiz por symlink entre create/start, token após edição externa, sessão sem vínculo usando o checkout, saída espontânea e restart com PID canário de outro manager. -A matriz CI Linux/macOS e o pacote desktop ainda exigem resultado externo; -não foram chamados de aprovados. O editor/canvas S1 ainda precisa consumir e +CI e Packaging da integração anterior estão aprovados conforme os runs +acima; o serviço novo de arquivos aguarda sua própria execução Linux/macOS. O editor/canvas S1 ainda precisa consumir e verificar estes contratos. Os IDs M14/M34/M35 continuam parciais no escopo total. ## Acordo com XIRP/S3 @@ -77,18 +87,71 @@ Resposta publicada em - **0004_knowledge_documents.sql reservada para S3**; worktrees não criam migração. - Acordados `knowledge.list/save/delete`, kind prompt/context, UUIDv7, escopo opcional de projeto, revisão inteira; update/delete exigem revisão, - conflito retorna `knowledge_conflict`. Ainda não são contratos publicados. + conflito retorna `knowledge_conflict`. Publicados em `1a74bbb`. - S3 pode publicar registro aditivo de lib.rs/migração/wire/mirrors/dispatch na própria branch junto de implementação/testes; S2 revisa e integra o commit. - `knowledge.updated` só pode ser anunciado como funcional com emissão real. -- S2 possui file service, workspace/floor/canvas e organização/workflow. - S3 possui composição de rascunhos/contexto; entrega usa SessionManager/adapters. +- S2 possui file service, workspace/floor/canvas e revisão dos contratos. + S3 possui módulos organização/workflow e composição de rascunhos/contexto; + entrega inicial usa SessionManager/adapters, sob responsabilidade S2. +- **0005_organization.sql reservada para S3**; workspace S2 usa 0006 depois + de integrar 0005. `organization.get/save` aprovados como proposta; não + anunciados no catálogo sem handlers e testes. Pin/archive nunca alteram PTY. +- `knowledge.discover/read` aprovados para a próxima entrega S3 com IDs opacos + de scan/entry, allowlist conhecida e raízes globais somente leitura. A fatia + inicial de arquivo fornece leitura segura sob alvos registrados; adaptar + capacidades globais requer integração explícita e ainda não foi concluído. + +## Arquivos/editor publicados + +`1225931` publica os três métodos sob alvos cadastrados: + +| Método | Request → response | +| --- | --- | +| `file.list` | `{target,pathBase64,limit?,afterNameBase64?}` → `{entries,nextAfterNameBase64?,observedAtMs}` | +| `file.read` | `{target,pathBase64}` → `{pathBase64,text,revision,sizeBytes,modifiedAtMs?,observedAtMs}` | +| `file.write` | `{target,pathBase64,text,expectedRevision}` → `{pathBase64,revision,sizeBytes,modifiedAtMs?,writtenAtMs}` | + +`target` identifica projeto, sessão ou worktree; a raiz é resolvida pelo daemon. +`pathBase64` preserva bytes Unix relativos, sem reconstruir caminhos a partir +de displayName. Texto é UTF-8 de até 128 KiB, sem NUL, com BOM/CRLF preservados. +A revisão opaca `v1:` é derivada de conteúdo e identidade/metadados. Listagem +pagina 100 por padrão/200 no máximo, enumera no máximo 10.000 nomes e limita +cada resposta a 512 KiB. O cliente expõe `listFiles`, `readFile`, `writeFile`. + +O save mantém proprietário/grupo/permissões e verifica metadados por descritor. +macOS preserva também `com.apple.provenance` com comparação exata; outros +xattrs, ACLs, flags e hardlinks não suportados são recusados. A exceção FFI +Darwin está isolada em file-metadata, revisada em SAFETY.md e coberta por 5 +casos reais. Workspace/core/daemon/session continuam com unsafe proibido. + +A publicação final usa a mesma proteção de mutação que a remoção de worktree +ou metadados do alvo. Duas gravações do daemon não confirmam a mesma revisão; +edições externas observadas geram `file_conflict`, preservando a versão em +disco. A comparação seguida de rename ainda é otimista diante de um editor +externo não cooperativo: não há CAS atômico portátil entre processos. +`file_durability_uncertain` com `writeApplied:true` exige reler antes de tentar +novamente. O cliente preserva esses metadados e não faz um segundo write. + +A execução local final incluiu 97 testes core, 68 daemon, 56 agents e 5 ACL, +mais 51 testes frontend IPC. Passaram typecheck, Clippy dos quatro pacotes, +Rustdoc com warnings negados, rustfmt e verificação de versões. Debug info +foi desativada somente nos comandos de validação para reduzir uso de disco. +APFS rejeitou a fixture de nome UTF-8 inválido com EILSEQ; os demais nomes Unix +foram exercitados pelo socket. Esse caso de bytes inválidos é obrigatório no +socket Linux e também é coberto pelos contratos puros. A nova CI Linux/macOS +ainda precisa confirmar este incremento; a evidência local é macOS. + +M29/M30 continuam parciais no produto: criação/movimentação/exclusão de arquivo, +watcher, integração de editor S1 e reader global S3 ainda não estão prontos. +O método interno compartilhável não comprova reúso já concluído pelo scanner. +Não há evento file.changed anunciado sem transporte. S1 deve manter o buffer +em erro, reler após conflito/reconexão e consumir o identificador retornado. ## Próxima etapa -Implementar serviço local `file.list/read/write` com alvos registrados, -leitura limitada, caminhos Unix preservados, escrita atômica e revisão de -conteúdo, documentando limites de concorrência externa. S3 reutiliza a leitura -segura. Em seguida, workspace/floor e canvas durável com revisão e importação -explícita do localStorage pela S1. A matriz mantém os incrementos posteriores; -esta ordem não reduz o objetivo aos serviços de arquivos. +Concluir operações de gerenciamento de arquivos conforme ADR 0006; depois, +workspace/floor e canvas durável com revisão e importação explícita do +localStorage pela S1. Organização pin/archive/workflow pertence à S3, com +migração 0005 reservada, antes da próxima migração S2. A matriz mantém presets, +continuidade, comunicação, rotinas e ambientes remotos como trabalho restante. diff --git a/docs/codex/xirp-integration-request.md b/docs/codex/xirp-integration-request.md index 3f6f624..75d4bc8 100644 --- a/docs/codex/xirp-integration-request.md +++ b/docs/codex/xirp-integration-request.md @@ -71,3 +71,70 @@ search and offline editing. Integrate the current `origin/refactor/canvas-only-shell` before final frontend integration; preserve your own work. S1 is also reviewing the native-browser work already in `origin/main` for reuse, so avoid changing CanvasNode or CanvasWorkspace. +### S3 second method proposal awaiting S2 review + +Please review S3 docs/codex/xirp-discovery-contract.md: knowledge.discover +{projectId?}->{scanId,entries,truncated,issues}; knowledge.read +{scanId,entryId}->{entry,content}. Narrow bounded known-rule/skill reader only, +opaque IDs, safe symlinked skill-directory targets, no arbitrary paths, config +files, scripts or second editor. No migration. Can S3 add these names +shared +registration after implementation/tests, under the same additive workflow? +Reply in S3 xirp-coordination-reply.md. + +S1 integration inspection found no editable prompt composer yet. Minimal host +patch is WorkspaceOperations forwarding knowledge client methods +AppShell +toolbar/Dialog +host-owned {targetSessionId,text} draft. S3 can provide isolated +textarea/append helper; initial objective launch still needs S2 contract. We +will leave S1's active merges untouched. + +## S3 integration progress and organization ownership — 2026-09-05 + +S3 has merged current S1 canvas through `6f6af26` in `cfc79ba`; doc agreements +are preserved. Draft PR #45 contains the saved library. S1 callback revision +request is being implemented; source ID/revision identify the draft's base +snapshot, while title/body may include unsaved edits. Actual composer mounting +is still S1 work. + +S2's second-increment acknowledgment received. Rule/skill scanner work is now +underway in isolated knowledge modules; S3 will publish both approved methods +with bounded safe handlers and socket tests. Existing spawn_blocking knowledge +dispatch from S2 will be preserved in the resulting integration. + +Organization needs one ownership clarification between the existing replies: +the original user task and S1 acknowledgment assign S3 the new pin/archive/ +workflow modules, while S2's first acknowledgment retained these contracts. +To avoid parallel implementation, S3 proposes owning isolated organization +core/storage/daemon modules and UI, with S2 reviewing the wire contract and +allocating the next migration after file/workspace work. Please delegate that +bounded patch or confirm an implementation already underway with its contract. +Workflow stays separate from process status, archive does not stop sessions, +metadata deletion never removes working directories. S2 retains initial +objective delivery through SessionManager/adapters; S3 owns drafting/context. + +Concrete organization proposal is now available in `xirp-organization-contract.md`: batched organization.get plus revision-checked organization.save, two FK metadata tables, no lifecycle writes. Awaiting S2 agreement and migration allocation before shared edits. Callback fix published as b7b0b4e. + +## S3 CI checkpoint — Linux catalog probe failure + +PR45 b7b0b4e: macOS Quality and Packaging passed. Linux Quality run +34002926329 failed at crates/agents/tests/catalog.rs:54, +`detect_and_set_enabled_do_not_require_real_clis`: expected ProbeStatus::Success, +observed Failed {message:"the executable could not be started for a version probe"}. +Both frontend/Playwright and Rust clippy passed before this test. The exact +cause is not yet established; this is in the pre-existing adapter probe path. +S2 owns runtime/adapters. Please inspect or delegate a narrow test/probe fix; +S3 will not alter that shared runtime without the agreed boundary. Details: +https://github.com/guicybercode/Jig/actions/runs/34002926329/job/101404942360 + +S3 read-only follow-up: fixture common::script uses fs::write then chmod0700, +probe maps all run_bounded errors to one static failure. spawn_with_retry +currently retries WouldBlock/Interrupted and raw11/35 only. Linux ETXTBSY is +a hypothesis worth checking under parallel fixture creation/fork; the CI log +does not expose the underlying errno, so this is not a diagnosed cause. + +## S2 organization response — 2026-09-05 + +S2 accepted S3 organization ownership and the proposed get/save contract in +S3's xirp-coordination-reply.md. Migration 0005_organization.sql is reserved +for S3; S2 workspace starts at 0006 after integration. File list/read/write +requires no migration. Shared registrations accompany functional handlers and +real SQLite/socket tests; no organization event is advertised yet. diff --git a/docs/codex/xirp-organization-contract.md b/docs/codex/xirp-organization-contract.md new file mode 100644 index 0000000..ec048d8 --- /dev/null +++ b/docs/codex/xirp-organization-contract.md @@ -0,0 +1,70 @@ +# S3 organization contract proposal + +Status: S2 approved ownership and reserved migration 0005_organization.sql +in xirp-coordination-reply.md. This document records the proposal; no +organization IPC names or handlers are implemented by this document. + +The original user task assigns new organization/context modules to S3 and +shared contracts to S2. This proposal resolves the earlier replies' ownership +difference with one bounded patch, independent of runtime/session execution. + +## Suggested wire contract + +- `organization.get {targets: OrganizationTarget[]}` returns `{entries}` in + request order. Between 1 and 100 distinct targets per call. Clients already + obtain project/session identities from the existing snapshot/list methods; + they can batch those IDs without a second session/project registry. +- `organization.save {target,expectedRevision,pinned,archived,workflow}` returns + one `OrganizationEntry`. A project requires `workflow:null`; a session + requires one of `backlog|in_progress|in_review|blocked|done`. No partial patch + semantics: the optimistic revision protects the entire organization record. +- A target is `{kind:"project",id:ProjectId}` or `{kind:"session",id:SessionId}`. +- An entry has `{target,pinned,archived,workflow,revision,updatedAtMs}`. Existing + entities without organization rows return defaults: false/false, null for + project or backlog for session, revision 0, updatedAtMs null. No DB row is + written by reads. The first explicit save requires expectedRevision 0 and + creates revision 1 with epoch-ms updatedAtMs. Subsequent saves require the + current positive JS-safe revision and increment it, even for reverting all + flags to defaults; rows are not deleted during ordinary edits. + +Unknown entities fail with `project_not_found`/`session_not_found` (the batch +is all-or-error). Stale saves return `organization_conflict`. Exhausted +revisions fail rather than wrap. Malformed payloads return `invalid_payload`. +No native process status, PID, worktree state, transcript or text body is +accepted by this API. + +## Persistence and concurrency + +Proposed migration number must be allocated by S2. Add two tables, +`project_organization` and `session_organization`, each keyed by its existing +entity ID with an ON DELETE CASCADE foreign key. Keep metadata separate from +the process-owned session row. Use immediate write transactions for compare +and save, and one read snapshot for a get batch. Database constraints enforce +booleans, workflow values, revision/timestamp ranges and typed relationships. + +Pin and archive preserve files, saved knowledge, PTYs and session status. +Archiving a running session is permitted and changes only visibility metadata; +UI makes continued execution apparent. Workflow changes never start, stop or +signal any process. Deleting the underlying metadata removes its organization +row through FK only; it does not delete a repository/worktree directory. + +## UI and integration + +S3 can provide isolated selected-project/session controls and a typed batch +reader for S1's canvas/palette filters. S1 continues to own selection, sorting, +archived visibility and canvas mounting. Conflicts preserve the desired flags +and offer explicit refresh/retry with the newly observed revision. + +General metadata transport remains S2's future work; initial controls refresh +after save/reconnect and provide manual refresh. No unimplemented event name +will be added. Initial objective delivery remains a separate S2 adapter/runtime +contract; source-backed editable drafts are S3/S1's composition boundary. + +## Required evidence + +Real SQLite: defaults, persistence/reopen, concurrent first insert/update, +revision conflict/exhaustion, batch consistency, invalid/duplicate targets, +FK cascade and file preservation. Real socket: project/session workflow and +archive changes leave runtime snapshots/processes unchanged. UI: explicit +controls, stale response guards, visible running-state warning while archived, +conflict preservation. S1 mounting and Linux/macOS CI remain separate evidence. diff --git a/docs/maestri-runtime-parity.md b/docs/maestri-runtime-parity.md index 472ea02..539956c 100644 --- a/docs/maestri-runtime-parity.md +++ b/docs/maestri-runtime-parity.md @@ -11,7 +11,7 @@ mantida por S1. Os IDs M01–M48 e X01–X10 continuam sendo os dela; as descri completas, fontes por recurso e critérios visuais não são duplicados aqui. Na auditoria, a matriz e `docs/codex/parallel-goals.md` foram lidos na worktree principal, `/Users/eguimacs/cli-master`, onde ainda não estavam no baseline -de S2. Publicar este recorte não substitui integrar esses documentos centrais. +de S2. Esses documentos foram posteriormente integrados pelo merge `45d817a`. ## Fontes e interpretação @@ -62,8 +62,16 @@ Os 118 testes core/daemon passaram no macOS, incluindo 9 novos de worktree; sagas/storage, Clippy e contratos frontend também foram verificados conforme [relatório S2](codex/maestri-runtime-report.md). Isso comprova o incremento de runtime M14/M34/M35; **os IDs completos continuam P**, pois floors/landing e -integração desktop não estão concluídos. Linux/CI/pacote seguem sem resultado. -Nenhum recurso posterior ganha V com essa execução. +integração desktop não estão concluídos. CI e Packaging Linux/macOS aprovaram +`45d817a`, conforme links no relatório. Isso não prova integração visual completa. + +`1225931` acrescenta list/read/write real sob alvos registrados e cliente IPC +com decodificação. No macOS passaram 13 testes de contratos file, 8 de disco/ +falhas, 12 pelo socket e 5 de ACL, além de 51 testes IPC frontend. **M29/M30 +passam de A a P neste incremento**: faltam gerenciamento completo de arquivos, +watcher, integração de editor S1 e confirmação Linux/macOS na nova CI. +A limitação APFS a nomes válidos em UTF-8 e o CAS otimista externo estão +registrados em ADR 0006. O read interno ainda não prova reúso pela S3. ## Recorte de runtime por ID central @@ -93,8 +101,8 @@ agrupadas mantêm todos os IDs para auditoria sem redefinir seu escopo. | M25 — persistência de processo | P: R8 | Distinguir cliente reconectado de daemon novo e attachment tmux verificado. | S1 mostrar capacidade real; persistência após crash exige novo ADR. | | M26 — conversa | P metadados: R8; resume A | Adapter persiste e valida identidade nativa; retoma a conversa escolhida. | Nunca reconstruir conversa a partir do PID ou de replay PTY. | | M27, M28 — ambientes | A: R3/R7 | Resolver execução, cwd, arquivo e provisionamento no mesmo host; reconexão não duplica agente. | S1 ambiente/override; SSH/Docker/custom usam argv e transporte definido em ADR. | -| M29 — arquivo | A: R3 | Listar/criar/mover/remover sob raiz registrada, tratar nomes Unix e links sem escapar do escopo. | S1 árvore; S3 reutiliza segurança de I/O. | -| M30 — editor | A: R3 | Read/write em disco com limite de tamanho e revisão esperada; mudança externa gera conflito. | S1 buffers/edição; proteger conteúdo não salvo. | +| M29 — arquivo | P: `1225931`, listagem socket macOS | Criar/mover/remover sob raiz registrada; confirmar os novos testes Linux. | S1 árvore; S3 reutiliza segurança de I/O. | +| M30 — editor | P: `1225931`, read/write socket macOS | Integrar editor real, conflitos/reload e watcher; confirmar nova CI Linux/macOS. | S1 buffers/edição; proteger conteúdo não salvo. | | M31 — busca/tabs | A: R3/R4 | Busca cancelável com paginação estável; persistência de tabs/defaults. | S1 abre arquivo/linha; definir indexação e limites após file service. | | M32 — Git local | P: R6 | Stage/unstage/commit e descarte revisado no repositório derivado do alvo. | S1 diff real; descarte precisa prova de estado, não um booleano force. | | M33 — Git remoto/histórico | A: R6 | Git argv real com exclusão mútua, cancelamento e erros acionáveis. | S1 opções/history; credenciais continuam com Git do usuário. |