diff --git a/scripts/observer/observer.py b/scripts/observer/observer.py index 4d349d4..967b26c 100644 --- a/scripts/observer/observer.py +++ b/scripts/observer/observer.py @@ -141,6 +141,11 @@ FAILED_EVENTS_DIR = STATE_DIR / "observer_failed_events" # "operator drops a file, the owning process consumes it" pattern the actions # pending/approved queue already uses. RESOLVE_REQUESTS_DIR = WORLD_DIR / "resolve-requests" +# mkdir(mode=...) is masked by the process umask, so the SSH operator (group +# aerbot) cannot drop a flag file into a dir created at the default 0o755 — +# same defect/fix as executor.py's INBOX_DIR_MODE (2026-08-06): an explicit, +# idempotent os.chmod after mkdir, applied on the same path the dir is used. +RESOLVE_REQUESTS_DIR_MODE = 0o775 # Time-based fallback for incidents that can never receive the service_healthy # event _resolve_incident() waits for (service removed/renamed/decommissioned @@ -277,6 +282,13 @@ class Observer: LOGS_DIR.mkdir(parents=True, exist_ok=True) FAILED_EVENTS_DIR.mkdir(parents=True, exist_ok=True) RESOLVE_REQUESTS_DIR.mkdir(parents=True, exist_ok=True) + try: + os.chmod(RESOLVE_REQUESTS_DIR, RESOLVE_REQUESTS_DIR_MODE) + except OSError as e: + logger.warning( + f"Could not set mode {oct(RESOLVE_REQUESTS_DIR_MODE)} on " + f"{RESOLVE_REQUESTS_DIR}: {e}" + ) def _quarantine_event_file(self, file_path: str, node_dir: str, exc: Exception) -> None: """Move an unreadable/unprocessable event out of the hot path.""" diff --git a/services/control-plane/tests/test_incident_lifecycle.py b/services/control-plane/tests/test_incident_lifecycle.py index f740648..4f44e65 100644 --- a/services/control-plane/tests/test_incident_lifecycle.py +++ b/services/control-plane/tests/test_incident_lifecycle.py @@ -2,6 +2,7 @@ from __future__ import annotations import json +import os import sys import time from pathlib import Path @@ -915,3 +916,19 @@ def test_resolve_request_flag_for_already_resolved_incident_is_removed(tmp_path) assert obs.world_state["incidents"][inc_id]["status"] == "resolved" assert obs.world_state["incidents"][inc_id]["resolved_reason"] == "manual_operator" assert not flag.exists() + + +def test_resolve_requests_dir_is_group_writable(tmp_path, monkeypatch): + """world/resolve-requests/ must be group-writable so an SSH operator + (group aerbot, not the observer's own user) can drop a resolve flag file + without docker exec — mkdir(mode=...) alone is masked by the process + umask, same defect/fix as executor.py's INBOX_DIR_MODE (2026-08-06).""" + old_umask = os.umask(0o022) + try: + obs = _make_observer_simple(tmp_path) + finally: + os.umask(old_umask) + import observer.observer as obs_mod + + mode = obs_mod.RESOLVE_REQUESTS_DIR.stat().st_mode & 0o777 + assert mode == 0o775