agnes-the-ai-analyst/services/telegram_bot/runner.py
ZdenekSrotyr c97fd504c5 release: 0.45.0 — easy-wins bundle (#84 #164 #177 #178 #203 #204)
Operator-and-analyst quality bundle: a security fix for the optional
Telegram bot, two CLI gaps closed, and three rounds of UX polish on
`agnes diagnose` and `agnes pull` so non-TTY consumers (CI runners,
Claude Code SessionStart hooks, sub-agent watchdogs) get readable,
actionable signal.

- Pairing-code RNG: random.choices -> secrets.choice (CSPRNG).
- Telegram script runner: refuse out-of-shape usernames before sudo -u.

CLAUDE.md.bak.<ISO-timestamp> before regenerating.

- agnes admin unregister-table <id> -> DELETE /api/admin/registry/{id}
- agnes admin update-table <id> --field=value ...  -> PUT /api/admin/registry/{id}

response but never promotes the headline. BQ billing-equals-data check
downgraded warning -> info.

default (5 s / 1 MiB vs 30 s / 10%) so sub-agent watchdogs don't kill
the pull as a hung process. New env knobs:
AGNES_PULL_PROGRESS_INTERVAL_{SECONDS,BYTES}.

--include-schema (or ?include=schema) to opt back in.

Tests: 120 passed across the touched modules, including new tests for
each fix. Pre-existing failures on main (DB migration v1->v9, binary
rename) are unrelated and not introduced here.
2026-05-07 11:43:16 +02:00

76 lines
2.8 KiB
Python

"""
Script runner for Telegram bot - executes user notification scripts on demand.
Used by callback query handler when user clicks "Run" button in /status.
Runs the script as the owning user via the notify-scripts helper.
"""
import json
import logging
import re
import subprocess
from . import config
logger = logging.getLogger(__name__)
NOTIFY_SCRIPTS_BIN = "/usr/local/bin/notify-scripts"
# POSIX-conservative username shape: alphanumerics, dot, hyphen, underscore;
# must start with `[a-z_]` so the value can never be interpreted as a sudo
# flag (e.g. `-u`, `--shell`). Mirrors the `useradd` defaults. Anything
# outside this shape is refused before we hand it to `sudo -u`.
_USERNAME_RE = re.compile(r"^[a-z_][a-z0-9._-]{0,31}$")
def run_user_script(username: str, script_name: str) -> dict | None:
"""Run a notification script as the specified user and return parsed JSON output.
Returns None on error, or the parsed JSON dict on success.
"""
if not _USERNAME_RE.match(username):
logger.error(f"Refusing to run script: invalid username shape: {username!r}")
return None
if not script_name.endswith(".py"):
logger.warning(f"Not a Python script: {script_name}")
return None
try:
result = subprocess.run(
["/usr/bin/sudo", "-u", username, NOTIFY_SCRIPTS_BIN, "run", script_name],
capture_output=True,
text=True,
timeout=config.SCRIPT_TIMEOUT_SECONDS + 10, # extra margin over inner timeout
)
if result.returncode != 0:
# notify-scripts prints JSON error to stdout on failure
try:
error_info = json.loads(result.stdout)
logger.warning(f"Script {script_name} (user={username}) failed: {error_info.get('error', 'unknown')}")
except (json.JSONDecodeError, Exception):
logger.warning(
f"Script {script_name} (user={username}) exited with code "
f"{result.returncode}: {result.stderr[:500]}"
)
return None
stdout = result.stdout.strip()
if not stdout:
logger.warning(f"Script {script_name} produced no stdout")
return None
parsed = json.loads(stdout)
logger.info(f"Script {script_name} output: image_path={parsed.get('image_path', 'MISSING')}")
return parsed
except subprocess.TimeoutExpired:
logger.error(f"Script {script_name} timed out after {config.SCRIPT_TIMEOUT_SECONDS}s")
return None
except json.JSONDecodeError as e:
logger.error(f"Script {script_name} returned invalid JSON: {e}")
return None
except Exception:
logger.exception(f"Error running script {script_name} for user {username}")
return None