fix(vector): self-heal stale app passwords on auth failure

Deleted/disabled Nextcloud users left their app_passwords row in storage,
so user_manager_task re-spawned their scanner every poll interval only to
401 again — an endless re-spawn/auth-failure loop (observed on
tenant-blackbox-demo: ~534 respawns/3h, matching the 60s poll interval).

- Delete the stored app password on a hard 401/403 in user_scanner_task
  (both the pre-validation and in-scan-loop paths), breaking the re-spawn
  loop at the source so the user-manager stops recreating the scanner.
- Add a periodic credential_cleanup_task backstop (hourly) that sweeps
  cleanup_invalid_app_passwords for anything the per-scanner path misses.
- Run the startup cleanup for all deployment modes: drop the stale
  `not oauth_enabled` guard so login_flow tenants (the cloud default) are
  covered. NOTE: login_flow startup now makes one concurrent OCS
  validation call per stored user before readiness.

Refs Deck #198.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-06-16 19:13:05 +02:00
co-authored by Claude Opus 4.8
parent 60df141442
commit 3790cf6d60
3 changed files with 259 additions and 15 deletions
+29 -13
View File
@@ -130,6 +130,7 @@ from nextcloud_mcp_server.server.oauth_tools import register_oauth_tools
from nextcloud_mcp_server.vector.metrics_publisher import vector_sync_metrics_task
from nextcloud_mcp_server.vector.oauth_sync import (
ProvisionSignal,
credential_cleanup_task,
oauth_processor_task,
user_manager_task,
)
@@ -2067,20 +2068,25 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# how many per-user scanners the user-manager later starts.
await _sweep_orphan_placeholders_if_enabled()
# Clean up stale app passwords at startup (BasicAuth mode only)
if not oauth_enabled:
try:
removed = await token_storage.cleanup_invalid_app_passwords(
nextcloud_host=nextcloud_host_for_sync
# Clean up stale app passwords at startup. All deployment modes
# now authenticate background sync via locally-stored app
# passwords (the OAuth refresh-token path was removed), so this
# must run regardless of oauth_enabled — login_flow tenants were
# previously skipped, letting deleted-user credentials linger and
# drive an endless scanner re-spawn/401 loop (Deck #198). The
# credential_cleanup_task started below repeats it on a cadence.
try:
removed = await token_storage.cleanup_invalid_app_passwords(
nextcloud_host=nextcloud_host_for_sync
)
if removed:
logger.info(
"Cleaned up %s stale app password(s): %s",
len(removed),
removed,
)
if removed:
logger.info(
"Cleaned up %s stale app password(s): %s",
len(removed),
removed,
)
except Exception as e:
logger.warning("App password cleanup failed (non-fatal): %s", e)
except Exception as e:
logger.warning("App password cleanup failed (non-fatal): %s", e)
# Initialize the ingest transport. INGEST_QUEUE selects the
# backend (Deck #183, ADR-028): ``memory`` builds an in-process
@@ -2134,6 +2140,16 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
provision_signal,
)
# Periodic backstop sweep removing app passwords that no
# longer authenticate (deleted/disabled users), complementing
# the per-scanner self-heal in user_scanner_task (Deck #198).
await tg.start(
credential_cleanup_task,
token_storage,
shutdown_event,
nextcloud_host_for_sync,
)
# In-process consumer pool. ``run_consumers`` is a no-op for
# the distributed (postgres) backend — the out-of-process
# ``worker`` role consumes there. The closure binds this