diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ad03eb..4d6c3a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,60 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project aims to follow [Semantic Versioning](https://semver.org/). +## [1.3.0] — 2026-07-02 + +One release for the rest of the 2026-07-01 code-review findings. + +### Security +- **Background memory passes no longer get home-wide write access.** The persist-nudge Stop hook + and the nightly dream ran `--permission-mode acceptEdits` from `cwd=$HOME`, auto-approving + writes anywhere under the home directory while consuming transcript content they didn't author. + Both now use path-scoped `Edit(/**)` / `Write(/**)` allow rules instead + (verified headlessly: in-scope writes succeed, outside writes are blocked). The persist pass's + `--add-dir` is also narrowed to the reviewed transcript's project dir. +- **Secrets/tmp hygiene:** the bot token (tg-send, setup) and `ANTHROPIC_API_KEY` (setup) now go + to curl via `--config` on a private fd instead of argv (world-readable in `/proc` while curl + runs); the dream's result file and the health-check cooldown marker moved from predictable + `/tmp` paths into `state/` (in world-writable /tmp any local user could squat the lock and + permanently mute health alerts); service logs are now created 0600 via `UMask=0077` (the + gateway log carries chat content); the default BBC feed is https. + +### Fixed +- **A transient `claude` error no longer wipes the chat's context.** The gateway retried every + nonzero exit with a fresh session; if the retry succeeded, the new session id silently replaced + the old one. It now retries fresh only when the CLI actually reports "No conversation found + with session ID", keeps the session otherwise, and says so in the error reply. +- **`/briefing` and `/dream` can no longer stack concurrent Claude runs.** Each detached script + now takes a `state/.lock` flock (covers the bot, the timer, and manual runs at once) and + the gateway refuses with a notice while a run is in progress — previously repeated commands + spawned unbounded parallel `claude` processes outside the `OGMA_MAX_CONCURRENT` limit. +- **Backups are written atomically.** `bin/backup` tars to `.partial` and renames on + success, so a crash, power loss, or the bot's command timeout can't leave a truncated archive + for retention/restore to trip over; stale partials are swept. The bot's `/backup` also gets a + 300s timeout (was killed at the generic 60s) and timeouts now say the command was killed. + +### Added +- **Log rotation:** new `bin/logtrim` + `ogma-logtrim.timer` (daily) cap the append-only + gateway/dream/briefing logs at 5 MiB, keeping the newest 1 MiB (`OGMA_LOG_MAX`/`OGMA_LOG_KEEP` + to tune). Install after pulling: `bin/setup --reconfigure systemd`, then + `systemctl --user enable --now ogma-logtrim.timer`. +- **`--any-host` for `bin/backup --list` / `bin/restore`.** In a shared backup dir, listing and + retention pruning now only touch THIS host's archives, and restore prefers this host's newest + (falling back to any host, with a warning, on a fresh box) — previously prune could delete + another machine's backups and restore could silently apply another machine's `.env`. + +### Changed +- **One `.env` parser everywhere.** New `bin/_env.sh` (`env_get`) is sourced by + ogmactl/briefing/dream/tg-send, and `gateway.py`/`news-fetch` follow the same rules: duplicate + keys resolve last-wins, one pair of surrounding quotes is stripped. Previously + `OGMA_MODEL="…"` worked in the gateway but broke the briefing/dream `--model` call. +- Gateway resilience: replies to non-allowed chats are throttled (once per 10 min per chat — + the first still includes the chat-ID hint setup relies on); `sessions.json` is written + atomically; long replies are chunked by UTF-16 length (Telegram's actual limit) and a failed + send is retried once; `ogma-gateway.service` gains `StartLimitIntervalSec/Burst` so a config + error can't restart-loop every 5s forever; skills re-runs in `bin/setup` now offer to update + stale copies after a `git pull` instead of always skipping. + ## [1.2.2] — 2026-07-01 ### Security diff --git a/bin/_env.sh b/bin/_env.sh new file mode 100644 index 0000000..f411ad2 --- /dev/null +++ b/bin/_env.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# _env.sh — shared .env reader for Ogma's shell tools. Sourced, never executed. +# +# One parser so a value behaves identically everywhere; gateway.py's load_env() +# follows the same two rules, keep them in sync: +# - duplicate keys resolve LAST-wins (the dotenv convention) +# - one pair of surrounding single or double quotes is stripped from the value + +# env_get — print KEY's value (empty if unset). KEY is a literal +# name (OGMA_*, TELEGRAM_*), not a pattern. +env_get() { + local val + val="$(grep -E "^${2}=" "$1" 2>/dev/null | tail -n1 | cut -d= -f2-)" + # trim surrounding whitespace + val="${val#"${val%%[![:space:]]*}"}" + val="${val%"${val##*[![:space:]]}"}" + # strip one pair of matching surrounding quotes + case "$val" in + \"*\") val="${val#\"}"; val="${val%\"}" ;; + \'*\') val="${val#\'}"; val="${val%\'}" ;; + esac + printf '%s' "$val" +} diff --git a/bin/backup b/bin/backup index f8b4184..cb3bdbe 100755 --- a/bin/backup +++ b/bin/backup @@ -28,17 +28,20 @@ OUT="${OGMA_BACKUP_DIR:-$HOME/ogma-backups}" KEEP="${OGMA_BACKUP_KEEP:-14}" NOTIFY=0 ACTION="create" +HOST="$(hostname -s 2>/dev/null || echo host)" +ANY_HOST=0 usage() { cat </dev/null | cut -f1)" "$(basename "$f")" done exit 0 @@ -117,22 +125,28 @@ count="$(grep -zc '' "$mf" 2>/dev/null || echo '?')" # --- Create the archive ---------------------------------------------------------------- mkdir -p "$OUT" || { err "cannot create $OUT"; exit 1; } -host="$(hostname -s 2>/dev/null || echo host)" +# Sweep partials left by an interrupted/killed earlier run (never counted by +# retention or restore — *.partial doesn't match the *.tar.gz globs). +rm -f "$OUT"/ogma-backup-*.partial 2>/dev/null ts="$(date +%Y%m%d-%H%M%S)" -archive="$OUT/ogma-backup-${host}-${ts}.tar.gz" +archive="$OUT/ogma-backup-${HOST}-${ts}.tar.gz" # Never clobber an existing archive (two backups in the same second — e.g. restore's # safety snapshot — would otherwise collide on the second-resolution timestamp). -n=2; while [ -e "$archive" ]; do archive="$OUT/ogma-backup-${host}-${ts}-${n}.tar.gz"; ((n++)); done - -if tar -C "$OGMA_DIR" --null -czf "$archive" -T "$mf" 2>/dev/null; then - chmod 600 "$archive" 2>/dev/null || true # contains .env — keep it private +n=2; while [ -e "$archive" ]; do archive="$OUT/ogma-backup-${HOST}-${ts}-${n}.tar.gz"; ((n++)); done + +# Write to a .partial and rename only on success: a crash, power loss, or the +# gateway's command timeout killing us mid-tar must not leave a truncated archive +# where retention or restore-newest would pick it up. +if tar -C "$OGMA_DIR" --null -czf "$archive.partial" -T "$mf" 2>/dev/null; then + chmod 600 "$archive.partial" 2>/dev/null || true # contains .env — keep it private + mv "$archive.partial" "$archive" || { err "could not finalize $archive"; exit 1; } size="$(du -h "$archive" 2>/dev/null | cut -f1)" ok "Backed up $count file(s) → $archive ($size)" result="🗄️ Ogma backup OK — ${count} files, ${size:-?} → $(basename "$archive")" else err "tar failed — archive not written." - [ "$NOTIFY" = 1 ] && printf '⚠️ Ogma backup FAILED on %s (tar error).' "$host" | "$OGMA_DIR/bin/tg-send" 2>/dev/null - rm -f "$archive" 2>/dev/null + [ "$NOTIFY" = 1 ] && printf '⚠️ Ogma backup FAILED on %s (tar error).' "$HOST" | "$OGMA_DIR/bin/tg-send" 2>/dev/null + rm -f "$archive.partial" 2>/dev/null exit 1 fi diff --git a/bin/briefing b/bin/briefing index e4c1735..546e49d 100755 --- a/bin/briefing +++ b/bin/briefing @@ -21,10 +21,18 @@ LAST="$STATE/last-briefing" # epoch of the previous successful briefing mkdir -p "$STATE" cd "$WORKDIR" || exit 1 -# Read a config value (OGMA_) from .env. -cfg() { - grep -E "^OGMA_${1}=" "$ENV" 2>/dev/null | cut -d= -f2- -} +# Single instance: the timer, a bot-triggered /briefing, and a manual run contend on +# this lock — each briefing is a full claude run, and several at once OOM a small box. +# The gateway checks the same lock before launching. +exec 9>"$STATE/briefing.lock" +if ! flock -n 9; then + echo "$(date '+%F %T') briefing: another run holds the lock — exiting" >&2 + exit 0 +fi + +# Shared .env reader (last-wins, quote-stripping — same rules as gateway.py). +. "$BIN/_env.sh" +cfg() { env_get "$ENV" "OGMA_$1"; } # Explicit standard-context id, not the bare 'sonnet' alias — the alias can # resolve to the credit-gated 1M-context variant in headless runs. Keep in sync diff --git a/bin/dream b/bin/dream index 5387c6e..b2286c9 100755 --- a/bin/dream +++ b/bin/dream @@ -12,6 +12,19 @@ export OGMA_PERSIST_PASS=1 # belt-and-suspenders: never trigger the gateway pe BASE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ENV="$BASE/.env" +STATE="$BASE/state" +mkdir -p "$STATE" && chmod 700 "$STATE" 2>/dev/null +OUT_JSON="$STATE/dream-out.json" + +# Single instance: the nightly timer, a bot-triggered /dream, and a manual run all +# contend on this lock — concurrent passes would race on the memory dir (and several +# claude processes at once can OOM a small box). The gateway checks the same lock. +exec 9>"$STATE/dream.lock" +if ! flock -n 9; then + echo "$(date '+%F %T') dream: another run holds the lock — exiting" + exit 0 +fi + # Claude Code's per-project memory lives under ~/.claude/projects//. # We run AS $HOME, so derive that project dir from $HOME (e.g. /home/bob -> -home-bob). PROJ="$(printf '%s' "$HOME" | sed 's#/#-#g')" @@ -23,10 +36,9 @@ YDAY_MAX=3000 # (the gateway hook is under workspace/), and OGMA_PERSIST_PASS guards anyway. cd "$HOME" || exit 1 -# Read a config value (OGMA_) from .env. -cfg() { - grep -E "^OGMA_${1}=" "$ENV" 2>/dev/null | cut -d= -f2- -} +# Shared .env reader (last-wins, quote-stripping — same rules as gateway.py). +. "$BASE/bin/_env.sh" +cfg() { env_get "$ENV" "OGMA_$1"; } # Default to the explicit standard-context model id, NOT the bare 'sonnet' alias: # in headless runs the alias can resolve to the credit-gated 1M-context variant # (a large nightly transcript sweep tripped "Usage credits required for 1M context"). @@ -89,12 +101,14 @@ if cp -r "$MEM" "$snap" 2>/dev/null; then ls -1dt "$BACKUPS"/*/ 2>/dev/null | tail -n +8 | xargs -r rm -rf fi +# Write access is path-scoped to the memory dir — NOT acceptEdits, which from cwd=$HOME +# would auto-approve writes anywhere under the home directory while this pass consumes +# transcript content it didn't author. Reads still cover all transcripts via --add-dir. claude -p "$PROMPT" \ --model "$model" \ - --allowedTools "Read Glob Grep Edit Write" \ - --permission-mode acceptEdits \ + --allowedTools "Read Glob Grep Edit($MEM/**) Write($MEM/**)" \ --add-dir "$HOME/.claude/projects" \ - --output-format json >/tmp/ogma-dream-out.json 2>/dev/null + --output-format json >"$OUT_JSON" 2>/dev/null # Guarantee the hard cap on yesterday.md regardless of what the model wrote. if [ -f "$YESTERDAY" ] && [ "$(wc -c < "$YESTERDAY")" -gt "$YDAY_MAX" ]; then @@ -102,7 +116,7 @@ if [ -f "$YESTERDAY" ] && [ "$(wc -c < "$YESTERDAY")" -gt "$YDAY_MAX" ]; then echo "$(date '+%F %T') dream: truncated yesterday.md to ${YDAY_MAX}c" fi -result="$(python3 -c 'import sys,json; print(json.load(open("/tmp/ogma-dream-out.json")).get("subtype","?"))' 2>/dev/null)" +result="$(python3 -c "import json; print(json.load(open('$OUT_JSON')).get('subtype','?'))" 2>/dev/null)" ysize="$( [ -f "$YESTERDAY" ] && wc -c < "$YESTERDAY" || echo 0 )" midx="$( [ -f "$MEM/MEMORY.md" ] && wc -c < "$MEM/MEMORY.md" || echo 0 )" done_ts="$(date '+%F %T')" diff --git a/bin/health-check b/bin/health-check index 24f9ff1..b469ecf 100755 --- a/bin/health-check +++ b/bin/health-check @@ -13,7 +13,11 @@ set -uo pipefail BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -LOCK="/tmp/ogma-health-alerted" +BASE="$(cd "$BIN/.." && pwd)" +# The cooldown marker lives in state/ (operator-private), not world-writable /tmp, +# where any local user could pre-create it and permanently mute the alerts. +LOCK="${HEALTH_LOCK:-$BASE/state/health-alerted}" +mkdir -p "$(dirname "$LOCK")" 2>/dev/null COOLDOWN="${HEALTH_COOLDOWN:-1800}" # 30 min between repeat alerts TEMP_MAX="${HEALTH_TEMP_MAX:-75}" # °C diff --git a/bin/logtrim b/bin/logtrim new file mode 100755 index 0000000..635cd60 --- /dev/null +++ b/bin/logtrim @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# logtrim — cap Ogma's append-only logs so they can never fill the disk. +# +# The systemd units log via StandardOutput=append:, which never rotates. This +# trims each ogma log in place (copytruncate-style) once it exceeds OGMA_LOG_MAX +# bytes, keeping the newest OGMA_LOG_KEEP bytes. In-place is safe with the +# writers' O_APPEND fds — no service restart needed; at worst a few lines +# written during the trim are lost, acceptable for diagnostics. +# +# Run by ogma-logtrim.timer (daily) or by hand. +set -uo pipefail + +BASE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +MAX="${OGMA_LOG_MAX:-5242880}" # trim when larger than this (5 MiB) +KEEP="${OGMA_LOG_KEEP:-1048576}" # keep this many newest bytes (1 MiB) + +shopt -s nullglob +for log in "$BASE"/*.log; do + size="$(stat -c %s "$log" 2>/dev/null || echo 0)" + (( size > MAX )) || continue + tmp="$(mktemp "${TMPDIR:-/tmp}/ogma-logtrim.XXXXXX")" || continue + # Keep whole lines: drop the first (probably partial) line of the tail window. + if tail -c "$KEEP" "$log" | sed '1d' > "$tmp"; then + cat "$tmp" > "$log" + echo "$(date '+%F %T') logtrim: trimmed $(basename "$log") from ${size} to $(stat -c %s "$log" 2>/dev/null || echo '?') bytes" >> "$log" + fi + rm -f "$tmp" +done diff --git a/bin/news-fetch b/bin/news-fetch index 5062d2f..4465d88 100755 --- a/bin/news-fetch +++ b/bin/news-fetch @@ -12,7 +12,7 @@ Defaults to 24h ago. Output is plain text on stdout, grouped by source. FEEDS are configurable via the OGMA_RSS_FEEDS environment variable, a comma-separated list of `Label|url` pairs, e.g.: - OGMA_RSS_FEEDS="BBC World|http://feeds.bbci.co.uk/news/world/rss.xml,Guardian World|https://www.theguardian.com/world/rss" + OGMA_RSS_FEEDS="BBC World|https://feeds.bbci.co.uk/news/world/rss.xml,Guardian World|https://www.theguardian.com/world/rss" If unset, a small set of generic English world-news feeds is used. Edit these to your own sources (and language) — the briefing's tone/language is set in bin/briefing. @@ -31,8 +31,10 @@ from email.utils import parsedate_to_datetime from xml.etree import ElementTree as ET # Generic defaults — override with OGMA_RSS_FEEDS (see module docstring). +# https only — feed text ends up inside the briefing prompt, so don't hand a +# network MITM a plaintext channel into the LLM. DEFAULT_FEEDS = [ - ("BBC World", "http://feeds.bbci.co.uk/news/world/rss.xml"), + ("BBC World", "https://feeds.bbci.co.uk/news/world/rss.xml"), ("Guardian World", "https://www.theguardian.com/world/rss"), ("NPR News", "https://feeds.npr.org/1001/rss.xml"), ] @@ -43,15 +45,18 @@ def _feeds_from_env_file(): environment (so the setting works whether news-fetch is run by bin/briefing, by systemd, or directly by hand).""" path = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), ".env") + val = "" try: with open(path) as f: - for line in f: + for line in f: # duplicate keys: last wins (same rule as bin/_env.sh) line = line.strip() if line.startswith("OGMA_RSS_FEEDS="): - return line.split("=", 1)[1].strip().strip('"').strip("'") + val = line.split("=", 1)[1].strip() + if len(val) >= 2 and val[0] == val[-1] and val[0] in "\"'": + val = val[1:-1] except OSError: pass - return "" + return val def load_feeds(): diff --git a/bin/ogmactl b/bin/ogmactl index ebd0c5c..2c2e9de 100755 --- a/bin/ogmactl +++ b/bin/ogmactl @@ -24,10 +24,10 @@ usage() { [ -x "$LOCAL" ] && "$LOCAL" help 2>/dev/null } -# Read a config value (OGMA_) from .env. -cfg() { - grep -E "^OGMA_${1}=" "$ENV" 2>/dev/null | cut -d= -f2- -} +# Read a config value (OGMA_) from .env via the shared parser +# (last-wins, quote-stripping — same rules as gateway.py). +. "$BASE/bin/_env.sh" +cfg() { env_get "$ENV" "OGMA_$1"; } cmd="${1:-}" case "$cmd" in @@ -35,7 +35,7 @@ case "$cmd" in echo "service : $(systemctl --user is-active "$SVC" 2>/dev/null || echo unknown)" up="$(systemctl --user show "$SVC" -p ActiveEnterTimestamp --value 2>/dev/null)" echo "since : ${up:-?}" - echo "model : $(cfg MODEL || echo default)" + m="$(cfg MODEL)"; echo "model : ${m:-default}" ;; logs) # Accept both `logs [N]` (gateway, back-compat) and `logs [N]`. diff --git a/bin/restore b/bin/restore index 7d23feb..3fda2c4 100755 --- a/bin/restore +++ b/bin/restore @@ -28,17 +28,21 @@ err() { printf '%s ✗ %s%s\n' "$c_red" "$*" "$c_rst" >&2; } ask() { local p="$1" d="${2:-}" a; if [ -n "$d" ]; then read -r -p "$p [$d]: " a || true; printf '%s' "${a:-$d}"; else read -r -p "$p: " a || true; printf '%s' "$a"; fi; } OUT="${OGMA_BACKUP_DIR:-$HOME/ogma-backups}" -ARCHIVE=""; ASSUME_YES=0; SAFETY=1; DRYRUN=0; DO_LIST=0 +ARCHIVE=""; ASSUME_YES=0; SAFETY=1; DRYRUN=0; DO_LIST=0; ANY_HOST=0 +HOST="$(hostname -s 2>/dev/null || echo host)" usage() { cat </dev/null | head -1)" + ARCHIVE="$(ls -t "${cands[@]}" 2>/dev/null | head -1)" fi [ -f "$ARCHIVE" ] || { err "archive not found: $ARCHIVE"; exit 1; } if ! tar tzf "$ARCHIVE" >/dev/null 2>&1; then err "not a readable .tar.gz: $ARCHIVE"; exit 1; fi diff --git a/bin/setup b/bin/setup index 5e42ef2..5bd1fd6 100755 --- a/bin/setup +++ b/bin/setup @@ -55,7 +55,8 @@ do_check() { printf ' fallback : %s\n' "$(grep -E '^OGMA_FALLBACK_MODEL=' "$ENV" 2>/dev/null | cut -d= -f2- | sed 's/^$/(none)/')" [ -x "$cb" ] && ok "claude CLI: $cb" || { err "claude CLI missing ($cb)"; problems=1; } if [ -n "$tok" ] && command -v curl >/dev/null; then - u="$(curl -sS -m 12 "https://api.telegram.org/bot${tok}/getMe" 2>/dev/null | "$PY" -c 'import sys,json + # Token goes to curl via --config on a private fd, not argv (world-readable in /proc). + u="$(curl -sS -m 12 --config <(printf 'url = "https://api.telegram.org/bot%s/getMe"\n' "$tok") 2>/dev/null | "$PY" -c 'import sys,json try: d=json.load(sys.stdin) except Exception: print("?"); raise SystemExit print(("OK "+((d.get("result") or {}).get("username") or "?")) if d.get("ok") else "BAD")' 2>/dev/null)" @@ -191,8 +192,10 @@ step "Model & reasoning effort" # Live model list needs an API key; Claude Code subscription auth can't list models, # so this is best-effort — otherwise use the curated aliases (or type any full id). if [ -n "${ANTHROPIC_API_KEY:-}" ]; then + # The API key goes to curl via --config on a private fd, not argv. live="$(curl -sS -m 12 https://api.anthropic.com/v1/models \ - -H "x-api-key: $ANTHROPIC_API_KEY" -H "anthropic-version: 2023-06-01" 2>/dev/null \ + --config <(printf 'header = "x-api-key: %s"\n' "$ANTHROPIC_API_KEY") \ + -H "anthropic-version: 2023-06-01" 2>/dev/null \ | "$PY" -c ' import sys, json try: @@ -309,7 +312,22 @@ if yes "Install skills into ~/.claude/skills/?" "y"; then mkdir -p "$HOME/.claude/skills" for s in tickets session-search daily-briefing; do dest="$HOME/.claude/skills/$s" - if [ -e "$dest" ]; then warn "$s already present — skipped."; else cp -r "$OGMA_DIR/skills/$s" "$dest"; ok "installed $s"; fi + src="$OGMA_DIR/skills/$s" + if [ -L "$dest" ]; then + ok "$s is a symlink — already tracks the repo." + elif [ -e "$dest" ]; then + # Copies go stale after a `git pull`; offer the repo version, but never + # clobber a locally customised skill without asking. + if diff -rq "$src" "$dest" >/dev/null 2>&1; then + ok "$s up to date." + elif yes " $s differs from the repo version — overwrite with the repo copy?" "n"; then + rm -rf "$dest" && cp -r "$src" "$dest" && ok "updated $s" + else + warn "$s left as-is (kept your local version)." + fi + else + cp -r "$src" "$dest"; ok "installed $s" + fi done say "${c_dim}(Tip: symlink instead — ln -s $OGMA_DIR/skills/ ~/.claude/skills/ — to track repo updates.)${c_rst}" else @@ -336,7 +354,7 @@ if want systemd && [ "$HAVE_SYSTEMD" = 1 ]; then if yes "Enable linger (keep services running when you're logged out)?" "y"; then loginctl enable-linger "$USER" 2>/dev/null && ok "Linger enabled." || warn "Could not enable linger (may need: sudo loginctl enable-linger $USER)." fi - say "${c_dim}Optional routines: systemctl --user enable --now ogma-briefing.timer ogma-dream.timer ogma-health.timer ogma-backup.timer${c_rst}" + say "${c_dim}Optional routines: systemctl --user enable --now ogma-briefing.timer ogma-dream.timer ogma-health.timer ogma-backup.timer ogma-logtrim.timer${c_rst}" else warn "Skipped. Templates are in systemd/ — install them manually later (see README)." fi diff --git a/bin/tg-send b/bin/tg-send index d1acac8..126c062 100755 --- a/bin/tg-send +++ b/bin/tg-send @@ -6,8 +6,9 @@ set -uo pipefail BASE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ENV="$BASE/.env" -tok="$(grep -E '^TELEGRAM_BOT_TOKEN=' "$ENV" | cut -d= -f2-)" -users="$(grep -E '^TELEGRAM_ALLOWED_USERS=' "$ENV" | cut -d= -f2- | tr ',' ' ')" +. "$BASE/bin/_env.sh" +tok="$(env_get "$ENV" TELEGRAM_BOT_TOKEN)" +users="$(env_get "$ENV" TELEGRAM_ALLOWED_USERS | tr ',' ' ')" [ -n "$tok" ] || { echo "tg-send: no TELEGRAM_BOT_TOKEN in .env" >&2; exit 1; } [ -n "$users" ] || { echo "tg-send: no TELEGRAM_ALLOWED_USERS in .env" >&2; exit 1; } @@ -17,7 +18,9 @@ msg="${msg:0:4000}" # stay under Telegram's 4096-char limit rc=0 for chat in $users; do - curl -sS -m 20 "https://api.telegram.org/bot${tok}/sendMessage" \ + # The URL carries the bot token, so it goes to curl via --config on a private fd + # instead of the command line (argv is world-readable in /proc while curl runs). + curl -sS -m 20 --config <(printf 'url = "https://api.telegram.org/bot%s/sendMessage"\n' "$tok") \ --data-urlencode "chat_id=${chat}" \ --data-urlencode "text=${msg}" >/dev/null || rc=1 done diff --git a/gateway.py b/gateway.py index 209c686..9ad7937 100755 --- a/gateway.py +++ b/gateway.py @@ -28,15 +28,26 @@ BASE = Path(__file__).resolve().parent def load_env(path: Path) -> None: - """Tiny .env loader (KEY=VALUE, ignores blanks/#comments). No deps.""" + """Tiny .env loader (KEY=VALUE, ignores blanks/#comments). No deps. + + Real environment variables win over the file. Duplicate keys in the file + resolve last-wins, and one pair of surrounding quotes is stripped — the + same two rules as bin/_env.sh, keep them in sync. + """ if not path.exists(): return + vals: dict[str, str] = {} for line in path.read_text().splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue key, _, val = line.partition("=") - os.environ.setdefault(key.strip(), val.strip().strip('"').strip("'")) + val = val.strip() + if len(val) >= 2 and val[0] == val[-1] and val[0] in "\"'": + val = val[1:-1] + vals[key.strip()] = val + for key, val in vals.items(): + os.environ.setdefault(key, val) load_env(BASE / ".env") @@ -74,6 +85,8 @@ MAX_CONCURRENT = max(1, int(cfg("MAX_CONCURRENT", "1") or "1")) _inflight: set = set() # chat_ids with a message currently being handled _inflight_lock = threading.Lock() +DENY_COOLDOWN = 600 # seconds between replies to a non-allowed chat +_denied: dict[str, float] = {} # chat_id -> when we last answered its denial _sessions_lock = threading.Lock() # guards the shared sessions dict + file _run_sem = threading.Semaphore(MAX_CONCURRENT) # bounds concurrent `claude` invocations @@ -98,7 +111,11 @@ def load_sessions() -> dict[str, str]: def save_sessions(s: dict[str, str]) -> None: - SESSIONS_FILE.write_text(json.dumps(s, indent=2)) + # Atomic replace — a crash mid-write must not corrupt the file (a corrupt + # sessions.json silently drops every chat's session on the next start). + tmp = SESSIONS_FILE.with_name(SESSIONS_FILE.name + ".tmp") + tmp.write_text(json.dumps(s, indent=2)) + tmp.replace(SESSIONS_FILE) def set_env_var(key: str, value: str) -> None: @@ -137,6 +154,11 @@ def tg(method: str, params: dict, timeout: int = 60) -> dict: return json.loads(r.read().decode()) +def _tg_len(s: str) -> int: + """Telegram's 4096 limit counts UTF-16 code units — emoji count double.""" + return len(s.encode("utf-16-le")) // 2 + + def send(chat_id: str, text: str) -> None: # Split long replies on line/space boundaries. while text: @@ -145,11 +167,18 @@ def send(chat_id: str, text: str) -> None: cut = max(chunk.rfind("\n"), chunk.rfind(" ")) if cut > TG_MAX // 2: text, chunk = chunk[cut:] + text, chunk[:cut] - try: - tg("sendMessage", {"chat_id": chat_id, "text": chunk}) - except Exception as e: # noqa: BLE001 - log("sendMessage failed:", e) - return + while _tg_len(chunk) > TG_MAX: + text, chunk = chunk[-200:] + text, chunk[:-200] + for attempt in (1, 2): # one retry — a silently dropped reply looks like a dead bot + try: + tg("sendMessage", {"chat_id": chat_id, "text": chunk}) + break + except Exception as e: # noqa: BLE001 + if attempt == 2: + log("sendMessage failed (giving up):", e) + return + log("sendMessage failed (retrying):", e) + time.sleep(2) def typing(chat_id: str) -> None: @@ -196,13 +225,20 @@ def ask_claude(prompt: str, session_id: str | None) -> tuple[str, str | None]: return ("⏱️ That took too long and timed out. Try a smaller ask?", session_id) if proc.returncode != 0: log("claude exited", proc.returncode, proc.stderr[:500]) - # A bad/expired session id is the common cause — retry once fresh. - if session_id: + # Retry fresh ONLY when the resume itself failed (the CLI says "No conversation + # found with session ID: …"). A blanket retry would silently drop the chat's + # context whenever a transient error cleared on the second attempt. + if session_id and "no conversation found" in (proc.stderr or "").lower(): + log("stale session id — retrying with a fresh session") return ask_claude(prompt, None) # Surface the actual reason (e.g. unknown model / bad flag) instead of a bare code. hint = next((ln.strip() for ln in (proc.stderr or "").splitlines() if ln.strip()), "") msg = f"⚠️ Claude error (exit {proc.returncode})." - return (f"{msg} {hint[:200]}".rstrip() if hint else msg, session_id) + if hint: + msg = f"{msg} {hint[:200]}".rstrip() + if session_id: + msg += " (Your session is kept — if this persists, /new starts fresh.)" + return (msg, session_id) try: out = json.loads(proc.stdout) except json.JSONDecodeError: @@ -310,18 +346,41 @@ def build_help() -> str: return f"{CORE_HELP}\n\nThis host:\n{lines}" -def run_ogmactl(chat_id: str, argv: list[str]) -> None: +# Commands that legitimately run long get their own limit; everything else 60s. +# On expiry subprocess.run KILLS the child, so the message must say so. +CMD_TIMEOUTS: dict[str, int] = {"/backup": 300} + + +def run_ogmactl(chat_id: str, argv: list[str], timeout: int = 60) -> None: """Invoke ogmactl with a whitelisted subcommand + positional args; relay output.""" typing(chat_id) try: proc = subprocess.run([OGMACTL, *argv], cwd=str(BASE), - capture_output=True, text=True, timeout=60) + capture_output=True, text=True, timeout=timeout) except subprocess.TimeoutExpired: - send(chat_id, "⏱️ Command timed out.") + send(chat_id, f"⏱️ Command killed after {timeout}s — it may have stopped mid-run.") return send(chat_id, (proc.stdout or proc.stderr or "").strip() or "(no output)") +def script_busy(script: str) -> bool: + """True if bin/