diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5d79d0a4d..008f0d85e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,7 +13,7 @@ All notable changes to Bambuddy will be documented in this file.
### Fixed
- **External Sidebar Link Icon Not Showing** ([#878](https://github.com/maziggy/bambuddy/issues/878)) — Custom icons uploaded for external sidebar links rendered correctly in the edit dialog but were missing from the sidebar itself, and opening the icon URL directly returned `{"detail":"Valid camera stream token required..."}`. The sidebar `
` tag in `Layout.tsx` used a raw `/api/v1/external-links/{id}/icon` URL, but that endpoint is protected by a query-string stream token (the same mechanism used for camera streams and archive thumbnails, because `
` tags cannot send Authorization headers). The edit dialog already routed through `api.getExternalLinkIconUrl()`, which wraps the URL via `withStreamToken()`; the sidebar now does the same, so icons appear when auth is enabled.
- **Shortest Job First Toggle Disappears After Clicking** ([#879](https://github.com/maziggy/bambuddy/issues/879)) — The SJF toggle badge on the queue page was rendered inside the Pending Queue section header, which is only shown when there is at least one pending item and the list view is active. Clicking the toggle often coincided with the scheduler starting the only pending print, at which point the Pending section unmounted and the toggle vanished along with it — making it look like the button had disappeared after clicking. The toggle has been moved to the top of the queue page, next to the list/timeline view switcher, so it stays reachable regardless of pending-item count, active filters, or the selected view mode.
-- **SpoolBuddy Update Fails in Docker with "no user exists for uid 1000/1001"** — The SpoolBuddy remote-update flow shelled out to the OpenSSH `ssh-keygen` and `ssh` binaries for keypair creation and command execution. Both binaries call `getpwuid(getuid())` at startup and abort with `No user exists for uid ` when the container runs under an arbitrary PUID that is not listed in `/etc/passwd` (the stock `python:3.13-slim` image only has an entry for root, so running with `user: "1000:1000"`, `"1001:1001"`, or any non-root user tripped the same error). The entire SpoolBuddy update path is now subprocess-free: keypairs are generated in-process via the `cryptography` library (already a dependency), SSH commands run through the pure-Python `asyncssh` client, and git-branch detection reads `.git/HEAD` directly instead of shelling out to `git`. asyncssh also calls `getpass.getuser()` for local `~/.ssh/config` host matching, which hit the same passwd lookup failure; the Docker image now sets `LOGNAME=bambuddy`, `USER=bambuddy`, and `HOME=/app` so `getpass.getuser()` resolves via env vars before touching the passwd database, and `asyncssh.connect()` is called with `config=[]` so it does not attempt to load `~/.ssh/config` at all. Native installs behave identically — they already worked because the running user was always in `/etc/passwd`. A regression test asserts that neither keypair creation nor command execution spawns any subprocess.
+- **SpoolBuddy Update Fails in Docker with "no user exists for uid 1000/1001"** — The SpoolBuddy remote-update flow shelled out to the OpenSSH `ssh-keygen` and `ssh` binaries for keypair creation and command execution. Both binaries call `getpwuid(getuid())` at startup and abort with `No user exists for uid ` when the container runs under an arbitrary PUID that is not listed in `/etc/passwd` (the stock `python:3.13-slim` image only has an entry for root, so running with `user: "1000:1000"`, `"1001:1001"`, or any non-root user tripped the same error). The entire SpoolBuddy update path is now subprocess-free: keypairs are generated in-process via the `cryptography` library (already a dependency), SSH commands run through the pure-Python `asyncssh` client, and git-branch detection reads `.git/HEAD` directly instead of shelling out to `git`. asyncssh also calls `getpass.getuser()` for local `~/.ssh/config` host matching, which hit the same passwd lookup failure; the Docker image now sets `LOGNAME=bambuddy`, `USER=bambuddy`, and `HOME=/app` so `getpass.getuser()` resolves via env vars before touching the passwd database, and `asyncssh.connect()` is called with `config=[]` so it does not attempt to load `~/.ssh/config` at all. Branch detection also now looks for `.git` in the *application root* rather than `settings.base_dir` — in Docker the data directory is a separate volume (`DATA_DIR=/app/data`) and never contains `.git`, so the previous code always silently fell through to the `main` fallback and pushed the wrong branch to the SpoolBuddy device regardless of which branch Bambuddy itself was on. Native installs behave identically — they already worked because the running user was always in `/etc/passwd` and `base_dir` happened to coincide with the project root. Regression tests assert that neither keypair creation nor command execution spawns any subprocess, and that branch detection reads from the application root even when a decoy `.git` sits inside the data dir.
- **Camera Stream "6 of 5" Reconnect Counter + ffmpeg Log Flood** ([#925](https://github.com/maziggy/bambuddy/issues/925)) — Two bugs surfaced while investigating camera reconnect behaviour. First, the camera page briefly displayed "Reconnecting attempt 6 of 5" before giving up, because the attempt counter could be incremented to the maximum while the reconnect banner was still rendering. The displayed value is now clamped to the configured maximum. Second, every failed ffmpeg spawn logged the full ~20-line ffmpeg version/configuration banner, producing hundreds of lines of noise per failed camera click (one reported click produced 555 log lines across 30 retries). A new stderr summarizer strips the ffmpeg banner before logging so only the actual error lines remain. The underlying "camera service stops accepting new connections after prolonged uptime" behaviour in the X1C firmware is still under investigation.
- **LDAP POSIX Primary Group Ignored** — LDAP authentication only looked at groups that listed the user explicitly via `memberUid` (supplementary group membership). A user's POSIX primary group — referenced by the `gidNumber` attribute on the user object and matching the `gidNumber` on a `posixGroup` — was ignored entirely, so users whose role came from their primary group landed without the expected permissions. The authenticator now also searches for `posixGroup` entries whose `gidNumber` matches the user's primary `gidNumber`, and dedupes DNs case-insensitively before resolving the group mapping (LDAP DNs are case-insensitive by spec).
- **Support Bundle Leaks Virtual Printer IP Address** — The debug support bundle included the `virtual_printer_remote_interface_ip` setting value unmasked in `support-info.json`. The setting key didn't match any of the existing sensitive-key filters, so the raw IP address was included in the bundle. Added `_ip` to the sensitive key filter so IP address settings are excluded from support bundles. Log file content was already covered by the existing IPv4 regex redaction.
diff --git a/backend/app/services/spoolbuddy_ssh.py b/backend/app/services/spoolbuddy_ssh.py
index f86db14d8..a91094768 100644
--- a/backend/app/services/spoolbuddy_ssh.py
+++ b/backend/app/services/spoolbuddy_ssh.py
@@ -28,6 +28,13 @@ logger = logging.getLogger(__name__)
SSH_USER = "spoolbuddy"
DEFAULT_INSTALL_PATH = "/opt/bambuddy"
+# Project root — where the `.git` directory lives for native installs and for
+# Docker containers that bind-mount the repo. This is intentionally distinct
+# from `settings.base_dir`, which points at the persistent *data* directory
+# (e.g. `DATA_DIR=/app/data` in Docker) and therefore never contains `.git`.
+# `backend/app/services/spoolbuddy_ssh.py` → parents[3] = project root.
+_APP_DIR = Path(__file__).resolve().parents[3]
+
# Note for Docker: asyncssh.connect() internally calls getpass.getuser() to
# resolve the *local* username for ~/.ssh/config host matching. Under an
# arbitrary PUID with no /etc/passwd entry this would raise OSError. The
@@ -93,22 +100,27 @@ async def get_public_key() -> str:
def detect_current_branch() -> str:
"""Detect the git branch Bambuddy is running on.
- Reads `.git/HEAD` directly rather than shelling out to `git`. This keeps
- the behaviour identical for native installs, bare Docker containers
- (no .git — fall through to the env var), and Docker containers that
- bind-mount the repo (.git is present, no `git` binary required, and no
- `getpwuid()` call that could fail under an arbitrary PUID).
+ Reads `.git/HEAD` directly from the application root (``_APP_DIR``) rather
+ than shelling out to `git`. The application root is deliberately distinct
+ from ``settings.base_dir``: in Docker, ``base_dir`` points at the data
+ volume (``/app/data``) which never contains ``.git``, while the repo is
+ bind-mounted (or COPYd) to ``/app``. This works for native installs,
+ bare Docker containers (no ``.git`` — fall through to the env var), and
+ Docker containers that bind-mount the repo (``.git`` is present, no
+ ``git`` binary required, and no ``getpwuid()`` call that could fail under
+ an arbitrary PUID).
- Fallback order: `.git/HEAD` → `GIT_BRANCH` env var → `"main"`.
+ Fallback order: ``.git/HEAD`` → ``GIT_BRANCH`` env var → ``"main"``.
"""
- git_path = settings.base_dir / ".git"
+ git_path = _APP_DIR / ".git"
try:
if git_path.exists():
- # Git worktrees use a file containing `gitdir: ` instead of a dir.
+ # Git worktrees use a file containing `gitdir: ` instead of
+ # a directory — follow the pointer.
if git_path.is_file():
content = git_path.read_text(encoding="utf-8").strip()
if content.startswith("gitdir:"):
- git_path = (settings.base_dir / content.removeprefix("gitdir:").strip()).resolve()
+ git_path = (_APP_DIR / content.removeprefix("gitdir:").strip()).resolve()
head_file = git_path / "HEAD"
if head_file.is_file():
diff --git a/backend/tests/unit/services/test_spoolbuddy_ssh.py b/backend/tests/unit/services/test_spoolbuddy_ssh.py
index e555a01bf..1fbfd430e 100644
--- a/backend/tests/unit/services/test_spoolbuddy_ssh.py
+++ b/backend/tests/unit/services/test_spoolbuddy_ssh.py
@@ -115,17 +115,16 @@ async def test_get_public_key(tmp_path):
def test_detect_branch_from_git_head(tmp_path):
- """Read branch directly from .git/HEAD — no subprocess."""
+ """Read branch directly from .git/HEAD in the application root — no subprocess."""
git_dir = tmp_path / ".git"
git_dir.mkdir()
(git_dir / "HEAD").write_text("ref: refs/heads/dev\n")
with (
- patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
+ patch("backend.app.services.spoolbuddy_ssh._APP_DIR", tmp_path),
patch("asyncio.create_subprocess_exec") as mock_exec,
patch("subprocess.run") as mock_run,
):
- mock_settings.base_dir = tmp_path
assert detect_current_branch() == "dev"
# Regression guard: must not shell out (fails with getpwuid under
# arbitrary Docker PUIDs if ever reintroduced).
@@ -133,6 +132,36 @@ def test_detect_branch_from_git_head(tmp_path):
mock_run.assert_not_called()
+def test_detect_branch_uses_app_dir_not_data_dir(tmp_path):
+ """Branch detection must look in the application root, not the data dir.
+
+ Regression guard for the Docker bug where `.git` was being looked up in
+ `settings.base_dir` (which is `DATA_DIR=/app/data` in Docker), so it was
+ never found and the fallback always returned "main" — even when the user
+ was on a feature branch bind-mounted at `/app`.
+ """
+ app_dir = tmp_path / "app"
+ data_dir = tmp_path / "app" / "data"
+ app_dir.mkdir()
+ data_dir.mkdir()
+
+ # Real .git lives at the application root (bind-mount style).
+ (app_dir / ".git").mkdir()
+ (app_dir / ".git" / "HEAD").write_text("ref: refs/heads/dev\n")
+
+ # Decoy .git in the data dir — if the code ever regresses to reading
+ # from settings.base_dir, this would be returned instead.
+ (data_dir / ".git").mkdir()
+ (data_dir / ".git" / "HEAD").write_text("ref: refs/heads/wrong-branch\n")
+
+ with (
+ patch("backend.app.services.spoolbuddy_ssh._APP_DIR", app_dir),
+ patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
+ ):
+ mock_settings.base_dir = data_dir
+ assert detect_current_branch() == "dev"
+
+
def test_detect_branch_worktree_gitdir_file(tmp_path):
"""Git worktrees store a `gitdir:` pointer instead of a dir — follow it."""
real_git_dir = tmp_path / "real-git"
@@ -140,8 +169,7 @@ def test_detect_branch_worktree_gitdir_file(tmp_path):
(real_git_dir / "HEAD").write_text("ref: refs/heads/feature-x\n")
(tmp_path / ".git").write_text(f"gitdir: {real_git_dir}\n")
- with patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings:
- mock_settings.base_dir = tmp_path
+ with patch("backend.app.services.spoolbuddy_ssh._APP_DIR", tmp_path):
assert detect_current_branch() == "feature-x"
@@ -152,28 +180,25 @@ def test_detect_branch_detached_head_falls_back(tmp_path):
(git_dir / "HEAD").write_text("deadbeef1234\n")
with (
- patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
+ patch("backend.app.services.spoolbuddy_ssh._APP_DIR", tmp_path),
patch.dict(os.environ, {"GIT_BRANCH": "release"}),
):
- mock_settings.base_dir = tmp_path
assert detect_current_branch() == "release"
def test_detect_branch_env_fallback(tmp_path):
with (
- patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
+ patch("backend.app.services.spoolbuddy_ssh._APP_DIR", tmp_path),
patch.dict(os.environ, {"GIT_BRANCH": "staging"}),
):
- mock_settings.base_dir = tmp_path
assert detect_current_branch() == "staging"
def test_detect_branch_default_main(tmp_path):
with (
- patch("backend.app.services.spoolbuddy_ssh.settings") as mock_settings,
+ patch("backend.app.services.spoolbuddy_ssh._APP_DIR", tmp_path),
patch.dict(os.environ, {}, clear=True),
):
- mock_settings.base_dir = tmp_path
# Remove GIT_BRANCH if present
os.environ.pop("GIT_BRANCH", None)
assert detect_current_branch() == "main"