fix(doctor): survive a PATH entry the user can't execute

The install itself now works end to end on a fresh macOS account — venv built,
package installed from git, entry point linked — and `doctor` then died with an
unhandled traceback:

    PermissionError: [Errno 13] Permission denied: 'ip'

netpool's probe helpers caught only FileNotFoundError. That is not the only way
a probe binary can be unavailable: when a name on PATH exists but this user
cannot execute it, exec fails with EACCES, and CPython reports that in
preference to the ENOENT from the other PATH entries. So `except
FileNotFoundError` misses it and the crash propagates all the way out of
`doctor`. Reproduced directly: a mode-000 file named `ip` on PATH yields
exactly the error above.

_run_ok's docstring already stated the intent — treat an unavailable binary as
failure rather than crashing — so this widens the catch to OSError to match
what it says. Any OSError means the probe could not run, which for a
fail-closed check is indistinguishable from "not present". The same narrow
catch is fixed in overlapping_routes and in the two docker probes
(compose ls, docker ps), which are the same shape and equally reachable.

Deliberately not touched: the FileNotFoundError catches around file I/O in
bottle_state and orchestrator/service, where the narrow exception is correct.

The harness also required `bot-bottle` on PATH before running doctor, which
could never be true: install.sh prints the PATH line rather than editing a
shell profile, by design, so on a fresh account the entry point is installed
and working but not on PATH. It now looks where the installer actually puts
it (~/.local/bin, then the venv) and notes when it's running by absolute path.
That was the harness failing a run for a reason the installer intends.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WEfZZhakx13bxTfXcZCoS5
This commit is contained in:
2026-07-27 01:20:19 -04:00
parent 4096409495
commit 5c12c6bf4e
6 changed files with 66 additions and 14 deletions
+11
View File
@@ -44,6 +44,17 @@ class TestProjectNaming(unittest.TestCase):
class TestComposeProjectListing(unittest.TestCase):
def test_compose_ls_empty_when_docker_unusable(self):
# Missing is the obvious case; present-but-not-executable raises
# PermissionError instead, which must not escape as a crash.
for exc in (FileNotFoundError, PermissionError(13, "Permission denied", "docker")):
with self.subTest(exc=type(exc).__name__):
with mock.patch(
"bot_bottle.backend.docker.compose.subprocess.run",
side_effect=exc,
):
self.assertEqual([], list_compose_projects())
def test_compose_ls_error_warns_by_default(self):
with (
mock.patch(
+15
View File
@@ -56,6 +56,21 @@ class TestNetpoolProbes(unittest.TestCase):
with patch.object(netpool.subprocess, "run", side_effect=FileNotFoundError):
self.assertFalse(netpool._run_ok(["nft"]))
def test_run_ok_false_on_unexecutable_binary(self):
# A name on PATH that this user can't execute raises PermissionError,
# not FileNotFoundError — CPython reports that EACCES in preference to
# the ENOENT from the other PATH entries. Catching only the latter made
# `doctor` die with a traceback on a fresh macOS account.
with patch.object(netpool.subprocess, "run",
side_effect=PermissionError(13, "Permission denied", "ip")):
self.assertFalse(netpool._run_ok(["ip", "link", "show", "bbfc0"]))
def test_overlapping_routes_empty_when_ip_unusable(self):
for exc in (FileNotFoundError, PermissionError(13, "Permission denied", "ip")):
with self.subTest(exc=type(exc).__name__), \
patch.object(netpool.subprocess, "run", side_effect=exc):
self.assertEqual([], netpool.overlapping_routes())
def test_tap_and_nft_probes(self):
with patch.object(netpool, "_run_ok", return_value=True) as ok:
self.assertTrue(netpool.tap_present("bbfc0"))