feat(privacy): contract \$HOME to ~ in user-visible display paths
Anywhere the TUI, doctor stdout, setup stdout, or onboarding shows a
file path, it used to print the absolute form (e.g. /Users/<name>/...).
On macOS/Linux the home-directory segment reveals the OS account name,
which is often the same as a public handle — undesirable for users who
share screenshots, screencasts, or paste doctor output into a public
help request.
Adds `crate::utils::display_path` that contracts a leading $HOME to `~`
and falls through unchanged otherwise. Used at every viewer-visible site:
doctor: workspace, config.toml, MCP config, all skills dirs,
selected skills dir, tools dir, plugins dir
setup: workspace, skills/tools/plugins paths and status output
TUI: context inspector header, trust-directory onboarding,
shell-job cwd (sidebar + detail pager), subagent task header
Persisted state, audit log, session checkpoints, and LLM-bound system
prompts intentionally keep the absolute path — those need full fidelity
to resolve correctly across processes and the LLM provider sees
absolute paths anyway by virtue of the workspace summary.
`display_path` has 4 tests covering: home contraction, bare-`~` for
home itself, untouched-when-unrelated, and a username-prefix regression
guard (so `/Users/alice2/...` doesn't get rewritten when $HOME is
`/Users/alice`).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+30
-24
@@ -979,7 +979,7 @@ fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> {
|
||||
"DeepSeek Setup".truecolor(aqua_r, aqua_g, aqua_b).bold()
|
||||
);
|
||||
println!("{}", "==============".truecolor(sky_r, sky_g, sky_b));
|
||||
println!("Workspace: {}", workspace.display());
|
||||
println!("Workspace: {}", crate::utils::display_path(workspace));
|
||||
|
||||
if run_mcp {
|
||||
let mcp_path = config.mcp_config_path();
|
||||
@@ -1022,10 +1022,13 @@ fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> {
|
||||
if args.local {
|
||||
println!(
|
||||
" Local skills dir enabled for this workspace: {}",
|
||||
skills_dir.display()
|
||||
crate::utils::display_path(&skills_dir)
|
||||
);
|
||||
} else {
|
||||
println!(" Skills dir: {}", skills_dir.display());
|
||||
println!(
|
||||
" Skills dir: {}",
|
||||
crate::utils::display_path(&skills_dir)
|
||||
);
|
||||
}
|
||||
println!(" Next: run the TUI and use `/skills` then `/skill getting-started`.");
|
||||
}
|
||||
@@ -1035,7 +1038,7 @@ fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> {
|
||||
let (dir, readme_status, example_status) = init_tools_dir(&tools_dir, args.force)?;
|
||||
report_write_status("Tools README", &dir.join("README.md"), readme_status);
|
||||
report_write_status("Example tool", &dir.join("example.sh"), example_status);
|
||||
println!(" Tools dir: {}", dir.display());
|
||||
println!(" Tools dir: {}", crate::utils::display_path(&dir));
|
||||
println!(" Next: drop scripts here; surface them via skills/MCP when ready.");
|
||||
}
|
||||
|
||||
@@ -1045,7 +1048,10 @@ fn run_setup(config: &Config, workspace: &Path, args: SetupArgs) -> Result<()> {
|
||||
init_plugins_dir(&plugins_dir, args.force)?;
|
||||
report_write_status("Plugins README", &readme_path, readme_status);
|
||||
report_write_status("Example plugin", &example_path, example_status);
|
||||
println!(" Plugins dir: {}", plugins_dir.display());
|
||||
println!(
|
||||
" Plugins dir: {}",
|
||||
crate::utils::display_path(&plugins_dir)
|
||||
);
|
||||
println!(" Next: copy the example dir, edit PLUGIN.md, wire via skill/MCP.");
|
||||
}
|
||||
|
||||
@@ -1200,7 +1206,7 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> {
|
||||
println!(
|
||||
" · skills: {} at {}",
|
||||
skills_count_for(&skills_dir),
|
||||
skills_dir.display()
|
||||
crate::utils::display_path(&skills_dir)
|
||||
);
|
||||
|
||||
let tools_dir = default_tools_dir();
|
||||
@@ -1216,7 +1222,7 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> {
|
||||
} else {
|
||||
0
|
||||
},
|
||||
tools_dir.display()
|
||||
crate::utils::display_path(&tools_dir)
|
||||
);
|
||||
|
||||
let plugins_dir = default_plugins_dir();
|
||||
@@ -1232,7 +1238,7 @@ fn run_setup_status(config: &Config, workspace: &Path) -> Result<()> {
|
||||
} else {
|
||||
0
|
||||
},
|
||||
plugins_dir.display()
|
||||
crate::utils::display_path(&plugins_dir)
|
||||
);
|
||||
|
||||
let sandbox = crate::sandbox::get_platform_sandbox();
|
||||
@@ -1348,16 +1354,16 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
|
||||
println!(
|
||||
" {} config.toml found at {}",
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b),
|
||||
config_path.display()
|
||||
crate::utils::display_path(&config_path)
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" {} config.toml not found at {} (using defaults/env)",
|
||||
"!".truecolor(sky_r, sky_g, sky_b),
|
||||
config_path.display()
|
||||
crate::utils::display_path(&config_path)
|
||||
);
|
||||
}
|
||||
println!(" workspace: {}", workspace.display());
|
||||
println!(" workspace: {}", crate::utils::display_path(workspace));
|
||||
|
||||
// Check API keys
|
||||
println!();
|
||||
@@ -1477,7 +1483,7 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
|
||||
println!(
|
||||
" {} MCP config found at {}",
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b),
|
||||
mcp_config_path.display()
|
||||
crate::utils::display_path(&mcp_config_path)
|
||||
);
|
||||
match load_mcp_config(&mcp_config_path) {
|
||||
Ok(cfg) if cfg.servers.is_empty() => {
|
||||
@@ -1532,7 +1538,7 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
|
||||
println!(
|
||||
" {} MCP config not found at {}",
|
||||
"·".dimmed(),
|
||||
mcp_config_path.display()
|
||||
crate::utils::display_path(&mcp_config_path)
|
||||
);
|
||||
println!(" Run `deepseek mcp init` or `deepseek setup --mcp`.");
|
||||
}
|
||||
@@ -1561,14 +1567,14 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
|
||||
println!(
|
||||
" {} local skills dir found at {} ({} items)",
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b),
|
||||
local_skills_dir.display(),
|
||||
crate::utils::display_path(&local_skills_dir),
|
||||
describe_dir(&local_skills_dir)
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" {} local skills dir not found at {}",
|
||||
"·".dimmed(),
|
||||
local_skills_dir.display()
|
||||
crate::utils::display_path(&local_skills_dir)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1576,14 +1582,14 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
|
||||
println!(
|
||||
" {} .agents skills dir found at {} ({} items)",
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b),
|
||||
agents_skills_dir.display(),
|
||||
crate::utils::display_path(&agents_skills_dir),
|
||||
describe_dir(&agents_skills_dir)
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" {} .agents skills dir not found at {}",
|
||||
"·".dimmed(),
|
||||
agents_skills_dir.display()
|
||||
crate::utils::display_path(&agents_skills_dir)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1591,21 +1597,21 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
|
||||
println!(
|
||||
" {} global skills dir found at {} ({} items)",
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b),
|
||||
global_skills_dir.display(),
|
||||
crate::utils::display_path(&global_skills_dir),
|
||||
describe_dir(&global_skills_dir)
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" {} global skills dir not found at {}",
|
||||
"·".dimmed(),
|
||||
global_skills_dir.display()
|
||||
crate::utils::display_path(&global_skills_dir)
|
||||
);
|
||||
}
|
||||
|
||||
println!(
|
||||
" {} selected skills dir: {}",
|
||||
"·".dimmed(),
|
||||
selected_skills_dir.display()
|
||||
crate::utils::display_path(&selected_skills_dir)
|
||||
);
|
||||
if !agents_skills_dir.exists() && !local_skills_dir.exists() && !global_skills_dir.exists() {
|
||||
println!(" Run `deepseek setup --skills` (or add --local for ./skills).");
|
||||
@@ -1620,14 +1626,14 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
|
||||
println!(
|
||||
" {} tools dir found at {} ({} items)",
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b),
|
||||
tools_dir.display(),
|
||||
crate::utils::display_path(&tools_dir),
|
||||
count
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" {} tools dir not found at {}",
|
||||
"·".dimmed(),
|
||||
tools_dir.display()
|
||||
crate::utils::display_path(&tools_dir)
|
||||
);
|
||||
println!(" Run `deepseek-tui setup --tools` to scaffold a starter dir.");
|
||||
}
|
||||
@@ -1641,14 +1647,14 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
|
||||
println!(
|
||||
" {} plugins dir found at {} ({} items)",
|
||||
"✓".truecolor(aqua_r, aqua_g, aqua_b),
|
||||
plugins_dir.display(),
|
||||
crate::utils::display_path(&plugins_dir),
|
||||
count
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
" {} plugins dir not found at {}",
|
||||
"·".dimmed(),
|
||||
plugins_dir.display()
|
||||
crate::utils::display_path(&plugins_dir)
|
||||
);
|
||||
println!(" Run `deepseek-tui setup --plugins` to scaffold a starter dir.");
|
||||
}
|
||||
|
||||
@@ -30,7 +30,11 @@ pub fn build_context_inspector_text(app: &App) -> String {
|
||||
let _ = writeln!(out, "Session Context");
|
||||
let _ = writeln!(out, "---------------");
|
||||
let _ = writeln!(out, "Model: {}", app.model);
|
||||
let _ = writeln!(out, "Workspace: {}", app.workspace.display());
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"Workspace: {}",
|
||||
crate::utils::display_path(&app.workspace)
|
||||
);
|
||||
if let Some(session_id) = app.current_session_id.as_deref() {
|
||||
let _ = writeln!(out, "Session: {}", session_id);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ pub fn lines(app: &App) -> Vec<Line<'static>> {
|
||||
Style::default().fg(palette::TEXT_PRIMARY),
|
||||
)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!("Workspace: {}", app.workspace.display()),
|
||||
format!("Workspace: {}", crate::utils::display_path(&app.workspace)),
|
||||
Style::default().fg(palette::TEXT_MUTED),
|
||||
)));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
@@ -52,7 +52,7 @@ pub(super) fn format_shell_job_list(jobs: &[ShellJobSnapshot]) -> String {
|
||||
job.exit_code,
|
||||
task
|
||||
));
|
||||
lines.push(format!(" cwd: {}", job.cwd.display()));
|
||||
lines.push(format!(" cwd: {}", crate::utils::display_path(&job.cwd)));
|
||||
lines.push(format!(" cmd: {}", job.command));
|
||||
let tail = if !job.stderr_tail.trim().is_empty() {
|
||||
job.stderr_tail.trim()
|
||||
@@ -115,7 +115,7 @@ fn format_shell_job_detail(detail: &ShellJobDetail) -> String {
|
||||
format!("Job: {}", job.id),
|
||||
format!("Status: {}", status_label(&job.status, job.stale)),
|
||||
format!("Command: {}", job.command),
|
||||
format!("Cwd: {}", job.cwd.display()),
|
||||
format!("Cwd: {}", crate::utils::display_path(&job.cwd)),
|
||||
format!("Elapsed: {}", format_elapsed(job.elapsed_ms)),
|
||||
format!("Exit Code: {:?}", job.exit_code),
|
||||
format!("Stdin Available: {}", job.stdin_available),
|
||||
|
||||
@@ -489,7 +489,10 @@ fn format_task_detail(task: &TaskRecord) -> String {
|
||||
lines.push(format!("Status: {}", task_status_label(task.status)));
|
||||
lines.push(format!("Mode: {}", task.mode));
|
||||
lines.push(format!("Model: {}", task.model));
|
||||
lines.push(format!("Workspace: {}", task.workspace.display()));
|
||||
lines.push(format!(
|
||||
"Workspace: {}",
|
||||
crate::utils::display_path(&task.workspace)
|
||||
));
|
||||
if let Some(thread_id) = task.thread_id.as_ref() {
|
||||
lines.push(format!("Runtime Thread: {thread_id}"));
|
||||
}
|
||||
|
||||
@@ -186,6 +186,33 @@ pub fn url_encode(input: &str) -> String {
|
||||
encoded
|
||||
}
|
||||
|
||||
/// Render a path for **user-facing display** with the home directory
|
||||
/// contracted to `~`. Use this in the TUI, doctor/setup stdout, and any
|
||||
/// other place a viewer might see the output (screenshot, video,
|
||||
/// pasted-into-issue help). On macOS/Linux the absolute path
|
||||
/// `/Users/<name>/...` or `/home/<name>/...` reveals the OS account name,
|
||||
/// which is often the same as a public handle — undesirable for users
|
||||
/// who share their terminal.
|
||||
///
|
||||
/// **Do not use** this for paths that get persisted (sessions, audit log)
|
||||
/// or sent to the LLM provider — those want full fidelity so they
|
||||
/// resolve correctly across processes.
|
||||
#[must_use]
|
||||
pub fn display_path(path: &Path) -> String {
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
return path.display().to_string();
|
||||
};
|
||||
if let Ok(rest) = path.strip_prefix(&home) {
|
||||
if rest.as_os_str().is_empty() {
|
||||
return "~".to_string();
|
||||
}
|
||||
// Render with the platform-correct separator after the tilde.
|
||||
let sep = std::path::MAIN_SEPARATOR;
|
||||
return format!("~{sep}{}", rest.display());
|
||||
}
|
||||
path.display().to_string()
|
||||
}
|
||||
|
||||
/// Estimate the total character count across message content blocks.
|
||||
#[must_use]
|
||||
pub fn estimate_message_chars(messages: &[Message]) -> usize {
|
||||
@@ -205,3 +232,74 @@ pub fn estimate_message_chars(messages: &[Message]) -> usize {
|
||||
}
|
||||
total
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::display_path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Save and restore $HOME inside one test so a panic anywhere can't
|
||||
/// poison sibling tests that read the env var.
|
||||
fn with_home<R>(home: &str, f: impl FnOnce() -> R) -> R {
|
||||
let prev = std::env::var_os("HOME");
|
||||
// SAFETY: tests in this crate are run single-threaded with respect
|
||||
// to env-var mutation by the integration harness, and we restore
|
||||
// immediately after the closure.
|
||||
unsafe { std::env::set_var("HOME", home) };
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
|
||||
match prev {
|
||||
Some(v) => unsafe { std::env::set_var("HOME", v) },
|
||||
None => unsafe { std::env::remove_var("HOME") },
|
||||
}
|
||||
match result {
|
||||
Ok(v) => v,
|
||||
Err(p) => std::panic::resume_unwind(p),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_path_contracts_home_prefix() {
|
||||
with_home("/Users/alice", || {
|
||||
assert_eq!(
|
||||
display_path(&PathBuf::from("/Users/alice/projects/foo")),
|
||||
format!(
|
||||
"~{}projects{}foo",
|
||||
std::path::MAIN_SEPARATOR,
|
||||
std::path::MAIN_SEPARATOR
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_path_returns_bare_tilde_for_home_itself() {
|
||||
with_home("/Users/alice", || {
|
||||
assert_eq!(display_path(&PathBuf::from("/Users/alice")), "~");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_path_leaves_unrelated_paths_alone() {
|
||||
with_home("/Users/alice", || {
|
||||
// Different user — must not get rewritten or share the tilde.
|
||||
assert_eq!(
|
||||
display_path(&PathBuf::from("/Users/bob/Code")),
|
||||
"/Users/bob/Code".to_string()
|
||||
);
|
||||
// System path must stay absolute.
|
||||
assert_eq!(display_path(&PathBuf::from("/etc/hosts")), "/etc/hosts");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_path_does_not_match_username_prefix() {
|
||||
// Regression guard: a directory named like the user's home
|
||||
// *prefix* but not under it must not get rewritten.
|
||||
with_home("/Users/alice", || {
|
||||
assert_eq!(
|
||||
display_path(&PathBuf::from("/Users/alice2/work")),
|
||||
"/Users/alice2/work"
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user