fix(auth): narrow keyring exception handler + log root cause#80
Conversation
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
Closes #38. core/auth.py:147-148 was a bare `except Exception:` with a flat `logger.debug("Keyring not available.")` that hid every keyring failure mode behind one line: locked macOS keychain, NoKeyringError on a headless host, InitError, OS-level reach errors, and library bugs. Operators running `mcp-synology check -v` saw the same useless "Keyring not available." regardless of why. Narrows to two typed handlers: - `except KeyringError as e` for the typed-error case (covers KeyringLocked, NoKeyringError, InitError, PasswordSetError, PasswordDeleteError, and any other keyring.errors.* class) - `except OSError as e` for D-Bus reach errors and OS-level permission failures on the keychain backend Both log at DEBUG with exc_info=True so verbose-mode surfaces the actual exception type, message, and traceback. Genuine bugs are no longer caught — they propagate. Bumped the pre-keyring D-Bus socket pre-check log from DEBUG to INFO and rewrote it with three concrete remediations: - run `mcp-synology setup` from a real desktop session - wrap with `dbus-run-session` - set credentials via SYNOLOGY_* env vars to bypass keyring The Linux + headless-service path is the most common keyring failure mode and previously needed -v to surface the actual cause. Updated tests/core/test_auth.py::_no_keyring() fixture to raise `keyring.errors.NoKeyringError` instead of bare Exception so existing tests exercise the production-shaped error path. New TestKeyringErrorHandling (3 cases): - KeyringError logged with exc_info and message text at DEBUG - OSError logged separately at DEBUG with exc_info - Defense-in-depth: keyring blow-up doesn't block credential resolution from config/env Strengthened TestDbusSocketMissing:: test_dbus_not_set_when_socket_missing_on_linux to assert the new INFO-level remediation hint contains the socket path AND all three remediation strings. 553 unit tests pass at 96.13% coverage; ruff/mypy clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
75f61e6 to
ec630b3
Compare
|
New commits pushed while QA was active. QA review invalidated — resetting to Awaiting CI. |
Rebased on main after PR #79 mergePR #79 (mcp-publisher v1.5.0 → v1.7.6) just merged into main, so this branch picked up a CHANGELOG conflict on the shared
Force-pushed with |
cmeans
left a comment
There was a problem hiding this comment.
QA Round 1 — PASS
Closes the keyring-handler narrowing I called out as a real-impact security item back in the PR #74 (SafeSkill) review. Clean fix shape, comprehensive tests, defense-in-depth preserved.
Issue #38 acceptance criteria
| AC | Coverage |
|---|---|
Narrow except to (KeyringError, OSError) |
✓ Two separate typed handlers at auth.py:158-175 |
Log actual exception at DEBUG with exc_info=True |
✓ Both branches; tests assert record.exc_info is not None, not just message presence |
| INFO-level hint when D-Bus socket missing | ✓ auth.py:131-140 rewritten from DEBUG to INFO with three concrete remediations (real desktop session, dbus-run-session, SYNOLOGY_* env vars) |
Unit test: KeyringError logged with message, not silently swallowed |
✓ TestKeyringErrorHandling::test_keyring_error_logged_with_exc_info_at_debug plus a regression assertion that the legacy flat "Keyring not available." message does NOT appear |
CHANGELOG ### Fixed entry |
✓ Present, #80-linked, accurate test count and coverage figure |
Verification
| Check | Result |
|---|---|
uv run pytest |
553 passed (+3 new TestKeyringErrorHandling), 100 deselected, 96.13% coverage |
Targeted TestKeyringErrorHandling + TestDbusSocketMissing |
4/4 pass directly |
uv run ruff check src/ tests/ scripts/ |
clean |
uv run ruff format --check src/ tests/ scripts/ |
72 files already formatted |
uv run mypy src/ scripts/ |
clean (30 files, strict-mode) |
grep -n 'except Exception' src/mcp_synology/core/auth.py |
only auth.py:282 (the on-reauth callback dispatch loop with # noqa: BLE001 — best-effort dispatch, QA-approved on #73). The pre-fix auth.py:147-148 bare-except is gone. |
Required CI on ec630b3 |
13/13 green (vdsm completed SUCCESS) |
Test-quality notes
A few things I want to call out as good (not findings):
- The
_no_keyring()fixture update — bumping the side-effect from bareExceptiontokeyring.errors.NoKeyringError— keeps the production-shaped error path exercised in every existing test that uses the fixture. Without this, the new typed handlers would only be exercised in the three new cases and the rest of the suite would test against an unrealistic error type. test_keyring_failure_does_not_block_config_credentialsis the right defense-in-depth check: the bare-except previously had a "feature" (silent swallow → fall through) that the narrow handlers must preserve. Tests prove they do.- The strengthened
test_dbus_not_set_when_socket_missing_on_linuxasserts the INFO message contains the socket path ANDmcp-synology setupANDSYNOLOGY_USERNAME— three independent strings — so a partial regression (e.g. the remediation hint truncated) would still fail the test. That's stronger than what AC#3 strictly requires. - Manual test #8 is a Linux-box verification of the operator-visible INFO line; the strengthened unit test asserts the exact output that would surface, so #8 is implicitly covered without needing to actually deploy the build to a headless Linux service.
PR-body manual tests 1–8 all checked.
Verdict
Ready for QA Signoff. Final maintainer call.
Side note: this is the third of the security-relevant items I listed in the PR #74 SafeSkill review (after #69/#71 atomic writes and #66 whitespace credential filtering already merged). #46 supply-chain hardening, #40 version-string validation, #39 background update-check error visibility, #44 PyPI gate are still open if the security-posture work is on a roll.
|
Applying Ready for QA Signoff as the final act of round 1. All 5 #38 acceptance criteria satisfied; 4/4 targeted tests pass; defense-in-depth fallback chain confirmed; only legitimate |
Addresses QA F1 on PR #81. An earlier Edit on this branch used a prefix of the #80 entry as `old_string`, which silently truncated the rest of #80's description AND concatenated it to the end of the new #81 entry. Net effect was the merged #80 entry on this branch read just "closes #38." while #81 had ~3KB of unrelated tail. Restored #80 to its origin/main form (full multi-sentence description) and trimmed the spurious tail from #81. `git diff origin/main -- CHANGELOG.md` now shows exactly one new line — the intended #81 entry. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Summary Cuts v0.5.2, shipping six PRs that landed since v0.5.1 (2026-05-01): - **#79** mcp-publisher v1.5.0 → v1.7.6 (registry OIDC audience fix) - **#80** keyring exception handler narrowing (closes #38) - **#81** bg update-check executor timeout + log (closes #39) - **#82** pygments 2.19.2 → 2.20.0 (GHSA-5239-wwwm-4pmq, ReDoS) - **#83** `--revert` version-string validation (closes #40) - **#85** per-path serial for `move_files` + `copy_files` (closes #84) ## Why now Two recent bug fixes (#83, #85) are user-visible enough to warrant shipping, and #84 in particular is a confusing silent-no-op regression on multi-file moves — getting that to PyPI promptly matters. The four post-0.5.1 quality fixes (#79–#82) are stacked behind it. This release also exercises **#79's mcp-publisher v1.7.6 pin end-to-end** so the registry can catch up to current. v0.5.1's registry entry is missing because #79 landed AFTER the v0.5.1 tag-push, and `actions/checkout@v6` resolved to the tag's commit on re-runs of the failed `publish-registry` job — the fix wasn't picked up. The v0.5.2 tag will pull the correct pin from main. ## State after merge Bug-labeled issue queue is empty. The structural multi-path-serial fix family (delete + getinfo + move + copy + restore) is now complete on real DSM 7.x — every File Station write tool that takes a `paths: list[str]` issues one DSM task per path, sidestepping the comma-joined-multipath quirk that #68 and #84 each surfaced. ## Files changed - `pyproject.toml` — version 0.5.1 → 0.5.2 - `server.json` — auto-synced via `python scripts/sync-server-json.py` - `uv.lock` — refreshed via `uv lock` - `CHANGELOG.md` — `## Unreleased` (with the six entries above) renamed to `## 0.5.2 (2026-05-01)`, fresh empty `## Unreleased` inserted above it for the next cycle ## Test plan - [x] CI green on this branch (lint, typecheck, test 3.11/3.12/3.13, vdsm integration tests, version-sync, validate-server-json) - [ ] After merge: tag `v0.5.2` push fires `publish.yml`; PyPI publish succeeds - [ ] After merge: `publish-registry` job succeeds end-to-end (this is the validation point for #79's fix — the failure mode in v0.5.1 was `invalid audience: expected https://registry.modelcontextprotocol.io, got [mcp-registry]`) - [ ] After tag: `mcp-synology --check-update` from a v0.5.1 install reports v0.5.2 available; `uv tool install mcp-synology@latest` upgrades cleanly - [ ] Smoke (post-install): two-file `move_files` actually moves both files (the #84 regression scenario) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: cmeans-claude-dev[bot] <272174644+cmeans-claude-dev[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
Closes #38. The bare
except Exceptionkeyring handler incore/auth.py:147-148collapsed every failure mode into the same useless"Keyring not available."debug line. Now narrows to typed handlers (KeyringError,OSError) withexc_info=True, and bumps the D-Bus socket-missing pre-check from DEBUG to INFO with three concrete remediations.Behavior change
except Exception: logger.debug("Keyring not available.")swallows everythingexcept KeyringError as e: logger.debug("Keyring access failed: %s", e, exc_info=True)+except OSError as e: logger.debug("Keyring OS-level error: %s", e, exc_info=True)mcp-synology check -vshows generic "Keyring not available." regardless of cause-vshows the actual exception class, message, and tracebackAttributeErrorfrom a corrupted keyring backend) silently swalloweddbus-run-session,SYNOLOGY_*env vars)The defense-in-depth property is preserved: a keyring blow-up still falls through to the next strategy in the resolver chain (config-file or env credentials), so a working
auth.username/passwordconfiguration continues to work even when the keychain is locked.Coverage of typed errors
keyring.errors.KeyringErroris the base class. The hierarchy this catches:NoKeyringError— common on headless hosts with no backend installedKeyringLocked— operator-actionable on macOS (unlock the keychain)InitError— backend installed but couldn't initialize (e.g. D-Bus reach failure surfaced via the keyring layer)PasswordSetError/PasswordDeleteError— write-path errors (we only read here, but these descend fromKeyringErrorand are correctly caught for free)OSErrorcovers the D-Bus-socket-present-but-unreachable variant separately, which the keyring library re-raises as a rawOSErrorrather than wrapping in anInitError.QA
Manual tests
uv run pytest— 553 passed at 96.13% coverage.uv run pytest tests/core/test_auth.py::TestKeyringErrorHandling -v— 3 passed.uv run pytest tests/core/test_auth.py::TestDbusSocketMissing -v— 1 passed; the strengthened assertion now requires the INFO-level remediation hint.uv run ruff check src/ tests/ scripts/— clean.uv run ruff format --check src/ tests/ scripts/— clean.uv run mypy src/ scripts/— clean.git grep -n 'except Exception' src/mcp_synology/core/auth.py— only the dispatch-loopexcept Exception as e: # noqa: BLE001 — best-effort dispatchremains (that one is intentional, lives inside the on-reauth callback fan-out).SYNOLOGY_LOG_LEVEL=info mcp-synology checkshould surface the new INFO line "D-Bus socket not found at /run/user//bus; OS keyring is unavailable. Run 'mcp-synology setup' from a real desktop session..." instead of falling through silently to "no credentials".Verification I already ran
uv run pytestuv run ruff check src/ tests/ scripts/uv run ruff format --check src/ tests/ scripts/uv run mypy src/ scripts/python -c "import keyring.errors; print([n for n in dir(keyring.errors) if not n.startswith('_')])"🤖 Generated with Claude Code