From f49b621d2f69fb9828aaf2b6f49ee10bdd210752 Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Tue, 28 Jul 2026 15:29:44 -0700 Subject: [PATCH 1/7] docs: update README and sessions documentation for clarity and security details --- docs/README.md | 6 +++--- docs/sessions.md | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/README.md b/docs/README.md index b86fd45..b7e6ae2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,7 +29,7 @@ Runtime state contains the daemon socket and lock. Configuration contains durabl ### Runtime directory (socket + lock) -The runtime directory holds `default.sock`, the mode-`0600` client↔daemon socket, and `daemon.lock`, the single-instance `flock`. The daemon records its PID in the lock file; `--kill` uses that PID rather than waiting for the socket. `fleetcom` creates the directory with mode `0700`. An existing path must be a real directory owned by the current user, so symlinks and directories owned by another user are rejected. +The runtime directory holds `default.sock`, the client↔daemon socket; `daemon.lock`, the single-instance `flock`; and `daemon.log`, the stderr of an autostarted daemon. The daemon records its PID in the lock file; `--kill` uses that PID rather than waiting for the socket. [Security](#security) documents the permissions and the ownership checks this directory must satisfy. Resolved in this order: @@ -51,7 +51,7 @@ Holds saved sessions under a `sessions/` subdirectory: one sanitized-name `.json | 2 | Linux | `${XDG_CONFIG_HOME:-~/.config}/fleetcom/sessions` | | 2 | macOS | `~/Library/Application Support/fleetcom/sessions` | -The platform default is [`dirs::config_dir()`](https://docs.rs/dirs/latest/dirs/fn.config_dir.html) joined with `fleetcom`. The first save creates missing session directories with mode `0700`; recipe files use mode `0600`. +The platform default is [`dirs::config_dir()`](https://docs.rs/dirs/latest/dirs/fn.config_dir.html) joined with `fleetcom`. The first save creates any missing session directories. ## First-run walkthrough @@ -187,7 +187,7 @@ Because the daemon holds each PTY master, daemon termination closes the terminal ### Environment and directory -Each launch uses the launching client's environment and working directory, sent once per connection during the hello handshake. Connect from a venv terminal and your spawns, reruns, and session loads all see that venv, whichever client originally autostarted the daemon. Environment is never written to disk; session files store only directories, commands, group assignments, and display names. +Each launch uses the launching client's environment and working directory, sent once per connection during the hello handshake. Connect from a venv terminal and your spawns, reruns, and session loads all see that venv, whichever client originally autostarted the daemon. [Security](#security) covers what that context does and does not persist. ### Scrollback depth is fixed per supervisor diff --git a/docs/sessions.md b/docs/sessions.md index 6d8f4a4..6fa467d 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -45,7 +45,7 @@ The `version` field must be an integer from 1 through the newest format supporte The shape, not the version, discriminates the schema. An object-valued `dirs` marks the wrapped form shown above. The loader also accepts a flat map whose top-level keys are directories and whose values are entry arrays. In that form, an array-valued key named `dirs` remains a directory entry, but a top-level `version` member is always the format version, never a directory. Flat-map files list by filename stem because they have no stored name. Saving one writes the wrapped form and permits overwriting it without a stored-name collision check. -Saves are atomic: `fleetcom` writes and syncs a private temporary file in the session directory, then renames it over the recipe. Recipes persist full command lines, which can embed secrets. New session directories use mode 0700, saves remove group and other permissions from existing session directories, and recipe files use mode 0600. +Saves are atomic: `fleetcom` writes and syncs a private temporary file in the session directory, then renames it over the recipe. Recipes persist full command lines, which can embed secrets. [Security](README.md#security) documents the directory and file permissions. The file is plain JSON and practical to edit by hand. Editing the `name` field changes which session the file claims to be: collision checks compare it, so a save under the old name will be refused. On load, the daemon removes control characters, trims surrounding whitespace, and limits group and display names to 64 characters. `Unassigned` maps to no group but remains a legal display name. Invalid JSON fails the entire load. Within valid JSON, `fleetcom` drops any member that matches neither entry form, including a non-string scalar, an object without a string `cmd`, or an object with a non-string `group` or `name`. @@ -79,7 +79,7 @@ A snapshot uses the session format above, with an `autosaved ` UTC la In the dashboard, `o` opens the [session picker](commands.md#the-o-session-picker) on the saved list; while snapshots exist, `Tab` flips it to the recovery list. -Recovery files carry the same caveat as saved recipes: they persist full command lines, which can embed secrets. New recovery directories use mode 0700, and snapshot files use mode 0600. +Recovery files carry the same caveat as saved recipes: they persist full command lines, which can embed secrets. ## Saving and loading From 06fbe97919d5e7a6eb6e91111cd1cad65a29701b Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Tue, 28 Jul 2026 15:32:58 -0700 Subject: [PATCH 2/7] refactor: enhance scratch directory management and PID handling in tests --- src/testutil.rs | 74 +++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 71 insertions(+), 3 deletions(-) diff --git a/src/testutil.rs b/src/testutil.rs index 4d1f399..21caa06 100644 --- a/src/testutil.rs +++ b/src/testutil.rs @@ -8,20 +8,88 @@ use std::{ os::unix::fs::PermissionsExt, path::{Path, PathBuf}, process::Command, + sync::{ + Once, + atomic::{AtomicU32, Ordering}, + }, time::{Duration, Instant, SystemTime}, }; use crate::{emulator::Emulator, format::civil_from_days}; -/// Fresh scratch directory under the system temp dir. Any leftover from a -/// previous run is removed first; the pid suffix isolates concurrent suites. +/// Scratch-directory name prefix, shared by creation and the sweep. +const SCRATCH_PREFIX: &str = "fleetcom_test_"; + +/// Create an empty `fleetcom_test___` directory under the system +/// temp directory. The PID separates test processes, and the sequence separates +/// calls within one process. Any existing path with the same name is removed. pub(crate) fn temp(tag: &str) -> PathBuf { - let d = std::env::temp_dir().join(format!("fleetcom_test_{tag}_{}", std::process::id())); + static SEQ: AtomicU32 = AtomicU32::new(0); + sweep_dead_scratch(); + let seq = SEQ.fetch_add(1, Ordering::Relaxed); + let d = std::env::temp_dir().join(format!( + "{SCRATCH_PREFIX}{tag}_{}_{seq}", + std::process::id() + )); let _ = fs::remove_dir_all(&d); fs::create_dir_all(&d).unwrap(); d } +/// Remove scratch directories whose recorded processes no longer exist. +/// +/// This runs once, before the current process creates its first directory, so +/// this process's directories remain available for post-failure inspection. +fn sweep_dead_scratch() { + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + let Ok(entries) = fs::read_dir(std::env::temp_dir()) else { + return; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(suffix) = name.to_str().and_then(|n| n.strip_prefix(SCRATCH_PREFIX)) else { + continue; + }; + if scratch_pid(suffix).is_some_and(pid_is_dead) { + let _ = fs::remove_dir_all(entry.path()); + } + } + }); +} + +/// Parse the `` from a `__` scratch suffix. Tags contain +/// underscores, so both trailing fields are read from the right. +fn scratch_pid(suffix: &str) -> Option { + let (rest, seq) = suffix.rsplit_once('_')?; + let (_, pid) = rest.rsplit_once('_')?; + let digits = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()); + if !digits(seq) || !digits(pid) { + return None; + } + pid.parse::().ok().filter(|p| *p > 0) +} + +/// Whether a PID is known to be dead. Only `ESRCH` proves death, so a live +/// process and one owned by another user both keep their directory. +fn pid_is_dead(pid: i32) -> bool { + use nix::{errno::Errno, sys::signal::kill, unistd::Pid}; + matches!(kill(Pid::from_raw(pid), None), Err(Errno::ESRCH)) +} + +/// Accept the current suffix format and reject malformed fields. +#[test] +fn scratch_pid_reads_the_pid_field() { + assert_eq!(scratch_pid("tag_with_underscores_123_4"), Some(123)); + assert_eq!(scratch_pid("session_mode_7_0"), Some(7)); + // Both trailing fields must be decimal integers. + assert_eq!(scratch_pid("tag_with_underscores_x_4"), None); + assert_eq!(scratch_pid("tag_123_x"), None); + // A suffix without all three components is invalid. + assert_eq!(scratch_pid("tag_123"), None); + assert_eq!(scratch_pid("tag_0_4"), None); +} + /// Poll `pred` until it holds or `budget` elapses; returns the final answer. /// `pred` always runs at least once. pub(crate) fn wait_until(budget: Duration, mut pred: impl FnMut() -> bool) -> bool { From 500b92cb7ab189c242a790c2ce907f38a442ccca Mon Sep 17 00:00:00 2001 From: Christopher Sardegna Date: Tue, 28 Jul 2026 15:54:09 -0700 Subject: [PATCH 3/7] feat(app): add the `/` find palette for jumping the dashboard selection --- docs/README.md | 4 +- docs/commands.md | 11 ++ src/app.rs | 96 ++++++++++++++++ src/app_tests.rs | 279 +++++++++++++++++++++++++++++++++++++++++++++++ src/ui.rs | 66 +++++++++-- 5 files changed, 447 insertions(+), 9 deletions(-) diff --git a/docs/README.md b/docs/README.md index b7e6ae2..463a247 100644 --- a/docs/README.md +++ b/docs/README.md @@ -62,7 +62,7 @@ Run `fleetcom`. The first invocation starts the daemon and opens an empty dashbo ```text fleetcom 0 running · 0 idle · 0 done by state · dir · custom - ❯ n run · @ dir · s sort · w save · o load + ❯ n run · @ dir · / find · s sort · w save · o load ↑↓ select · enter attach · space peek · m tag · g group · R rename · r rerun · X kill · q detach · Q quit ``` @@ -75,7 +75,7 @@ Press `n`, enter a command, and press `Enter`. The command runs in its own PTY a ✻ cargo watch -x test test result: ok. 42 passed 9s ✻ npm run dev VITE v5.0 ready in 312 ms 4s - ❯ n run · @ dir · s sort · w save · o load + ❯ n run · @ dir · / find · s sort · w save · o load ↑↓ select · enter attach · space peek · m tag · g group · R rename · r rerun · X kill · q detach · Q quit ``` diff --git a/docs/commands.md b/docs/commands.md index 58e1ac2..834d16e 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -26,6 +26,7 @@ | `Space` | Peek at the selected task | | `n` | New command in the invocation directory | | `@` | New command in a directory you pick | +| `/` | Jump the selection to a task by name, command, or group (opens the [find palette](#the--find-palette)) | | `s` | Cycle grouping: by state / by directory / by custom group | | `m` | Tag the selected task "in use" (toggles) | | `g` | Assign the selected task to a group (opens the group picker) | @@ -145,6 +146,16 @@ The daemon removes control characters, trims surrounding whitespace, and limits Typing filters the rows; `Backspace` deletes one character and the matches re-filter; `↑`/`↓` move the highlight; `Esc` cancels. Completion updates on each input, permitting navigation and launch without leaving the dashboard. `←`/`→` move the caret within the typed path (`→` descends only when the caret is at the end), and `Ctrl-A`/`Ctrl-E` (or `Home`/`End`) jump to either end; the same caret keys work in every `fleetcom` text field. +## The `/` find palette + +`/` opens a bottom panel over the dashboard listing the tasks that match what you type. Each row reads `