Harden Hub startup and display-name handling

- Display names starting with '-' were parsed by tmux rename-window as
  flags. The alias was persisted before tmux rejected it, so every later
  start/refresh/open failed in refresh_labels until aliases.json was
  edited by hand. Guard rename-window with '--', apply the name before
  persisting it, and roll back the in-memory table on failure.
- Start the Hub tmux server with -f /dev/null by default. Reading the
  user's ~/.tmux.conf let theme status-formats replace the Sessions bar
  and root-table bindings leak into Herdr; HERDR_HUB_TMUX_CONFIG still
  opts into a custom file.
- An unreadable aliases.json blocked start/refresh/open/choose. Degrade
  to default names with a warning (stderr, or the popup message) and
  leave the file untouched for `doctor` to report.
- `doctor` crashed with a traceback when a *_BIN override pointed at a
  missing executable; report it as a failure instead.
- Refuse to run inside another tmux server before discovery and server
  creation, so no unattached Hub is left behind.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-03 02:58:45 +00:00
co-authored by Claude Fable 5.1
parent 6b906de476
commit ebbd5821fd
4 changed files with 184 additions and 21 deletions
+11
View File
@@ -128,6 +128,17 @@ class CliIntegrationTests(unittest.TestCase):
# Operational recovery must remain possible even if optional
# display-name configuration is later corrupted.
(root / "aliases.json").write_text("not json", encoding="utf-8")
degraded = subprocess.run(
[str(launcher), "refresh"],
cwd=repository_root,
env=environment,
capture_output=True,
text=True,
check=False,
)
self.assertEqual(degraded.returncode, 0, degraded.stderr)
self.assertIn("display names unavailable", degraded.stderr)
self.assertEqual(hub.managed_windows()[0].name, "L default")
stop = subprocess.run(
[str(launcher), "stop"],
cwd=repository_root,
+75
View File
@@ -1,20 +1,29 @@
from __future__ import annotations
import argparse
import contextlib
import io
import json
import os
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest.mock import patch
from herdr_hub.cli import (
DEFAULT_SESSION_NAME,
DEFAULT_TMUX_CONFIG,
Endpoint,
HubError,
command_doctor,
command_start,
decode_cmdline,
dedupe_endpoints,
deserialize_endpoint,
discover_local_sessions,
discover_remote_processes,
make_hub,
parse_remote_argv,
serialize_endpoint,
)
@@ -199,5 +208,71 @@ class DiscoveryTests(unittest.TestCase):
)
class CliEnvironmentTests(unittest.TestCase):
def test_hub_ignores_user_tmux_conf_unless_overridden(self) -> None:
with tempfile.TemporaryDirectory() as directory:
environment = {
"HERDR_HUB_TMUX_BIN": "/bin/true",
"HERDR_HUB_ALIASES_FILE": str(Path(directory) / "aliases.json"),
}
with patch.dict(os.environ, environment):
os.environ.pop("HERDR_HUB_TMUX_CONFIG", None)
hub = make_hub(argparse.Namespace())
self.assertEqual(hub.config_file, DEFAULT_TMUX_CONFIG)
self.assertEqual(hub.base_command()[-2:], ["-f", "/dev/null"])
with patch.dict(
os.environ, {**environment, "HERDR_HUB_TMUX_CONFIG": "~/hub.conf"}
):
hub = make_hub(argparse.Namespace())
self.assertEqual(hub.config_file, os.path.expanduser("~/hub.conf"))
def test_corrupt_alias_file_degrades_to_defaults_with_warning(self) -> None:
with tempfile.TemporaryDirectory() as directory:
aliases = Path(directory) / "aliases.json"
aliases.write_text("not json", encoding="utf-8")
environment = {
"HERDR_HUB_TMUX_BIN": "/bin/true",
"HERDR_HUB_ALIASES_FILE": str(aliases),
}
warnings: list[str] = []
with patch.dict(os.environ, environment):
hub = make_hub(argparse.Namespace(), warnings=warnings)
self.assertEqual(hub.aliases, {})
self.assertEqual(len(warnings), 1)
self.assertIn("aliases.json", warnings[0])
stderr = io.StringIO()
with patch.dict(os.environ, environment), contextlib.redirect_stderr(stderr):
make_hub(argparse.Namespace())
self.assertIn("display names unavailable", stderr.getvalue())
# The unreadable file is left untouched for the user to inspect.
self.assertEqual(aliases.read_text(encoding="utf-8"), "not json")
def test_start_refuses_nested_tmux_before_discovery(self) -> None:
environment = {"TMUX": "/tmp/tmux-1/other,1,0", "HERDR_HUB_SOCKET": "herdr-hub"}
with patch.dict(os.environ, environment), patch(
"herdr_hub.cli.discover_endpoints",
side_effect=AssertionError("discovery must not run inside another tmux"),
):
with self.assertRaisesRegex(HubError, "another tmux server"):
command_start(argparse.Namespace())
def test_doctor_reports_unusable_binary_override_instead_of_crashing(self) -> None:
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
environment = {
"HERDR_HUB_HERDR_BIN": str(root / "missing-herdr"),
"HERDR_HUB_REMOTES_FILE": str(root / "remotes.json"),
"HERDR_HUB_ALIASES_FILE": str(root / "aliases.json"),
"HERDR_HUB_PROC_ROOT": directory,
}
stdout = io.StringIO()
with patch.dict(os.environ, environment), contextlib.redirect_stdout(stdout):
status = command_doctor(argparse.Namespace())
self.assertGreaterEqual(status, 1)
self.assertIn("herdr: cannot run", stdout.getvalue())
self.assertIn("discovery failed", stdout.getvalue())
if __name__ == "__main__":
unittest.main()
+10
View File
@@ -310,6 +310,16 @@ class TmuxIntegrationTests(unittest.TestCase):
renamed = self.hub.apply_alias(window.key, "临时名称")
assert renamed is not None
self.assertEqual(renamed.name, "临时名称")
# tmux parses a leading '-' as a flag unless the name is guarded; a
# stored name like this must also survive the label refresh that every
# later start/refresh performs.
dashed = self.hub.apply_alias(window.key, "-dash")
assert dashed is not None
self.assertEqual(dashed.name, "-dash")
self.hub.ensure_server()
refreshed = self.hub.window_for_key(window.key)
assert refreshed is not None
self.assertEqual(refreshed.name, "-dash")
restored = self.hub.apply_alias(window.key, "")
assert restored is not None
self.assertEqual(restored.name, "R orangepi/work")