From d466f358ce71a31c2c808093805fbc74e4b7904f Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Sun, 30 Aug 2026 10:44:39 +0000 Subject: [PATCH] feat(discord-bridge): punte Discord -> Claude Code pe LXC 171 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementeaza planul claude-master-plan-discord-bridge-20260830 (15 taskuri, 3 lane-uri paralele) — un bot subtire discord.py peste CLI-ul `claude`, cu proces persistent per fir alimentat pe stdin cu --input-format stream-json. Nucleu: runner (proces persistent + reaper 20min + respawn --resume), stream (parser tolerant), session_store (scriere atomica, lock per fir, detectare PID reuse, recovery), limits (max 4 procese, timeout tur, rate per user, plafon cost pe zi), render (un loop de editare per canal, interval adaptiv). Adaptor: allowlist guild/canal/user fail-closed cu respingerea webhook-urilor, comenzi !new/!cd/!model/!status/!stop/!cleanup, cost si model in subsolul fiecarui raspuns. Mesajul sosit in timpul unui tur devine steering, nu tur nou. Securitate: hook PreToolUse fail-closed care cere confirmare in Discord pentru operatiuni ireversibile, wrapper `infra` cu lista explicita de hosturi. Deny rules raman strat cosmetic, nu bariera (verificat: /usr/bin/ssh trece pe langa). Ops: alerte email pe conventia repo-ului, !cleanup pentru orfani, unit systemd user cu KillMode=control-group si limite de memorie, install.sh idempotent. Verificat: 275 teste fara retea/Discord/API (10.8s), identic cu si fara discord.py instalat; e2e pe CLI real confirma steering-ul mid-tur (mesaj la 6s intr-un tool call de 25s schimba raspunsul final). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01B29CApsP1JkSdjYaGaHpE7 --- CLAUDE.md | 1 + proxmox/lxc171-claude-agent/README.md | 1 + .../discord-bridge/.gitignore | 3 + .../discord-bridge/INTERFACES.md | 134 ++++ .../discord-bridge/README.md | 311 ++++++++ .../discord-bridge/alerts.py | 300 ++++++++ .../lxc171-claude-agent/discord-bridge/bot.py | 703 ++++++++++++++++++ .../discord-bridge/cleanup.py | 435 +++++++++++ .../discord-bridge/config.py | 123 +++ .../discord-bridge/limits.py | 164 ++++ .../discord-bridge/ops/claude-discord.service | 61 ++ .../discord-bridge/ops/env.example | 45 ++ .../discord-bridge/ops/install.sh | 151 ++++ .../discord-bridge/ops/logrotate.conf | 26 + .../discord-bridge/pytest.ini | 9 + .../discord-bridge/render.py | 209 ++++++ .../discord-bridge/requirements-dev.txt | 3 + .../discord-bridge/requirements.txt | 2 + .../discord-bridge/runner.py | 398 ++++++++++ .../discord-bridge/security/README.md | 282 +++++++ .../discord-bridge/security/__init__.py | 5 + .../discord-bridge/security/approvals.py | 350 +++++++++ .../security/bot-settings.json.example | 27 + .../discord-bridge/security/confirm_hook.py | 461 ++++++++++++ .../discord-bridge/security/infra | 192 +++++ .../discord-bridge/session_store.py | 329 ++++++++ .../discord-bridge/stream.py | 260 +++++++ .../discord-bridge/tests/__init__.py | 0 .../discord-bridge/tests/conftest.py | 64 ++ .../discord-bridge/tests/fake_claude.py | 122 +++ .../discord-bridge/tests/test_alerts.py | 281 +++++++ .../discord-bridge/tests/test_allowlist.py | 135 ++++ .../discord-bridge/tests/test_bot.py | 269 +++++++ .../discord-bridge/tests/test_cleanup.py | 313 ++++++++ .../discord-bridge/tests/test_commands.py | 231 ++++++ .../discord-bridge/tests/test_confirm_hook.py | 408 ++++++++++ .../discord-bridge/tests/test_e2e_steering.py | 62 ++ .../discord-bridge/tests/test_infra.py | 171 +++++ .../discord-bridge/tests/test_limits.py | 118 +++ .../discord-bridge/tests/test_render.py | 137 ++++ .../discord-bridge/tests/test_runner.py | 222 ++++++ .../tests/test_session_store.py | 127 ++++ .../discord-bridge/tests/test_stream.py | 122 +++ 43 files changed, 7767 insertions(+) create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/.gitignore create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/INTERFACES.md create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/README.md create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/alerts.py create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/bot.py create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/cleanup.py create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/config.py create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/limits.py create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/ops/claude-discord.service create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/ops/env.example create mode 100755 proxmox/lxc171-claude-agent/discord-bridge/ops/install.sh create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/ops/logrotate.conf create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/pytest.ini create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/render.py create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/requirements-dev.txt create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/requirements.txt create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/runner.py create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/security/README.md create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/security/__init__.py create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/security/approvals.py create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/security/bot-settings.json.example create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/security/confirm_hook.py create mode 100755 proxmox/lxc171-claude-agent/discord-bridge/security/infra create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/session_store.py create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/stream.py create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/tests/__init__.py create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/tests/conftest.py create mode 100755 proxmox/lxc171-claude-agent/discord-bridge/tests/fake_claude.py create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/tests/test_alerts.py create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/tests/test_allowlist.py create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/tests/test_bot.py create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/tests/test_cleanup.py create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/tests/test_commands.py create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/tests/test_confirm_hook.py create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/tests/test_e2e_steering.py create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/tests/test_infra.py create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/tests/test_limits.py create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/tests/test_render.py create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/tests/test_runner.py create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/tests/test_session_store.py create mode 100644 proxmox/lxc171-claude-agent/discord-bridge/tests/test_stream.py diff --git a/CLAUDE.md b/CLAUDE.md index 3c4380a..5ed1f25 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,6 +46,7 @@ input/ # Oracle DMP files for import - **Migration orchestration**: `proxmox/lxc108-oracle/migration/00-MASTER-MIGRATION.sh` - **Chatbot architecture**: `proxmox/lxc104-flowise/docs/prd.md` - **Docker Sandboxes (sbx) — agenți AI izolați**: `proxmox/lxc102-docker/README.md` +- **Punte Discord → Claude Code (comandă LXC 171 de pe telefon, dintr-un guild privat)**: `proxmox/lxc171-claude-agent/discord-bridge/README.md` - **Disaster recovery**: `proxmox/vm109-windows-dr/README.md` - **Instalare/migrare Oracle — care director se folosește**: `proxmox/lxc108-oracle/docs/instalare-si-migrare-oracle.md` - **ROA Windows setup scripts (XE/SE 21c)**: `proxmox/lxc108-oracle/roa-windows-setup/README.md` diff --git a/proxmox/lxc171-claude-agent/README.md b/proxmox/lxc171-claude-agent/README.md index 181b9fb..5863dca 100644 --- a/proxmox/lxc171-claude-agent/README.md +++ b/proxmox/lxc171-claude-agent/README.md @@ -17,6 +17,7 @@ | `scripts/new-task.sh` | Creează branch nou pentru task | | `scripts/finish-task.sh` | Finalizează task (commit + push) | | `scripts/reap-orphans.sh` | Curăță procese vscode-server orfane + sesiuni zombie (cron, anti-OOM) | +| `discord-bridge/` | Punte Discord → Claude Code: comanzi containerul dintr-un guild privat ([README](discord-bridge/README.md)) | --- diff --git a/proxmox/lxc171-claude-agent/discord-bridge/.gitignore b/proxmox/lxc171-claude-agent/discord-bridge/.gitignore new file mode 100644 index 0000000..75c6182 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/proxmox/lxc171-claude-agent/discord-bridge/INTERFACES.md b/proxmox/lxc171-claude-agent/discord-bridge/INTERFACES.md new file mode 100644 index 0000000..ad3547c --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/INTERFACES.md @@ -0,0 +1,134 @@ +# INTERFACES — contract intre lane-uri (proprietate: orchestrator, NU modifica) + +Cele trei lane-uri lucreaza in acelasi director. Acest fisier fixeaza cine ce fisier scrie si +ce semnaturi trec granita, ca merge-ul sa fie mecanic. + +## Proprietate pe fisiere (STRICTA — nu scrie in fisierele altui lane) + +| Lane | Fisiere pe care le creeaza/editeaza | +|---|---| +| A (nucleu + adaptor) | `session_store.py`, `stream.py`, `runner.py`, `render.py`, `limits.py`, `bot.py`, `config.py`, `tests/**`, `requirements.txt`, `requirements-dev.txt` | +| B (securitate) | `security/confirm_hook.py`, `security/infra`, `security/approvals.py`, `security/bot-settings.json.example`, `security/README.md`, `tests/test_confirm_hook.py`, `tests/test_infra.py` | +| C (ops) | `alerts.py`, `cleanup.py`, `ops/claude-discord.service`, `ops/install.sh`, `ops/logrotate.conf`, `tests/test_alerts.py`, `tests/test_cleanup.py`, `README.md`, si liniile de index din `../README.md` + `/workspace/romfastsql/CLAUDE.md` | + +Fisiere partajate ca *citire*: acest INTERFACES.md. Nimeni nu-l editeaza. + +## Layout runtime (in afara repo) + +``` +~/.claude-discord/ + env # 0600: DISCORD_TOKEN, allowlist, ALERT_RECIPIENT, COST_CAP_USD_DAY + bot-settings.json # settings pasat cu --settings (hook PreToolUse) — Lane B + state.json # stare sesiuni — Lane A + venv/ + logs/bot.log +``` + +## Modelul de date state.json (Lane A e autoritatea) + +```json +{ + "version": 1, + "threads": { + "": { + "sid": "uuid sesiune claude sau null", + "cwd": "/workspace/", + "model": "sonnet", + "pid": 12345, + "pid_start_time": 987654.21, + "inflight": {"turn_id": "...", "started_at": 1756512000.0, "user_id": "...", "message_id": "..."}, + "cost_usd_total": 0.0, + "last_active": 1756512000.0 + } + }, + "cost": {"day": "2026-08-30", "usd": 0.0} +} +``` + +`pid_start_time` = `/proc//stat` field 22, pentru detectarea PID reuse. + +## Granita A <-> B (aprobari) + +Lane B expune `security/approvals.py`. Lane A il importa si nu-i cunoaste interiorul. + +```python +# security/approvals.py — implementat de Lane B, consumat de Lane A +async def wait_for_decision(request_id: str, timeout: float) -> str: ... + # returneaza "allow" | "deny"; la timeout returneaza "deny" (fail-closed) + +def submit_decision(request_id: str, decision: str) -> bool: ... + # apelat de bot.py cand utilizatorul apasa butonul; True daca cererea exista + +async def pending_requests() -> list[dict]: ... + # [{"request_id", "thread_id", "tool_name", "command", "created_at"}] + +def set_on_request(callback) -> None: ... + # Lane A inregistreaza aici un async callback(request: dict) apelat cand + # hook-ul cere o confirmare; bot.py posteaza atunci butoanele in fir. +``` + +Canalul hook -> bot e un director de cereri pe disc (`~/.claude-discord/approvals/`), fiindca +hook-ul PreToolUse ruleaza intr-un proces separat, nu in botul Python. Lane B alege formatul; +Lane A vede doar functiile de mai sus. + +Fail-closed e obligatoriu: orice eroare, timeout sau fisier corupt => `deny`. + +## Granita A <-> C (alerte) + +Lane C expune `alerts.py`. Lane A il apeleaza in caile de esec. + +```python +# alerts.py — implementat de Lane C, consumat de Lane A +def alert(level: str, subject: str, body: str, dedup_key: str | None = None) -> None: ... + # level: "INFO" | "WARN" | "CRITICAL" + # trimite email prin `mail -s "[LEVEL] subject" "$ALERT_RECIPIENT"` (conventia repo, + # vezi proxmox/vm109-windows-dr/scripts/pveelite-down-alert.sh) + # NU arunca niciodata exceptii — o alerta esuata nu are voie sa doboare botul + # dedup_key: aceeasi cheie nu retrimite in fereastra de 1h +``` + +Conditiile pe care Lane A le semnaleaza (T12): proces mort neasteptat, crash loop, +plafon de cost atins, state.json corupt, orfani detectati la sweep. + +## Granita A <-> C (cleanup) + +```python +# cleanup.py — implementat de Lane C, consumat de bot.py pentru comanda !cleanup +def find_orphans(state: dict) -> list[dict]: ... + # procese `claude` din cgroup-ul serviciului care nu apar in state.json, + # plus copii lasati in urma (servere pornite in tururi anterioare) + # -> [{"pid", "cmdline", "age_s", "rss_mb"}] +def kill_orphans(orphans: list[dict], dry_run: bool = True) -> list[dict]: ... +``` + +## Granita comuna: config + +Lane A creeaza `config.py`, care citeste `~/.claude-discord/env`. B si C il importa +pentru cai si setari; nu-si citesc singure env-ul. + +```python +# config.py — implementat de Lane A +STATE_DIR: pathlib.Path # ~/.claude-discord +APPROVALS_DIR: pathlib.Path # ~/.claude-discord/approvals +STATE_FILE: pathlib.Path +LOG_DIR: pathlib.Path +def get(key: str, default=None) -> str | None: ... # citeste din env-ul incarcat +``` + +Daca `config.py` nu exista inca la momentul in care B sau C au nevoie de el (lane-uri paralele), +scrie codul care il importa oricum — se rezolva la merge — si NU crea o varianta proprie. + +## Reguli de test + +- `pytest` fara marker: zero retea, zero Discord, zero API. Suita sub 3s. +- Testele care ating CLI-ul real: `@pytest.mark.e2e`, excluse implicit prin `pytest.ini` + (Lane A scrie `pytest.ini` cu `addopts = -m "not e2e"`). +- Fiecare lane isi scrie doar propriile fisiere de test, dupa tabelul de proprietate. + +## Decizii deja luate (nu le redeschide) + +- `--permission-mode bypassPermissions` e intentionat; deny rules sunt strat cosmetic, nu bariera. +- Accesul larg la /workspace si la infrastructura e FUNCTIONALITATE ceruta, nu bug. +- Fara user separat `cdbot`, fara audit append-only, fara dashboard web, fara Agent SDK. +- Fara reluare automata a turului pierdut (risc de dubla executie). +- Model default `sonnet`; `!model opus` per fir. diff --git a/proxmox/lxc171-claude-agent/discord-bridge/README.md b/proxmox/lxc171-claude-agent/discord-bridge/README.md new file mode 100644 index 0000000..75910b0 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/README.md @@ -0,0 +1,311 @@ +# Punte Discord -> Claude Code (LXC 171) + +Un bot Discord subtire care duce mesajele dintr-un guild privat catre CLI-ul `claude` +care ruleaza pe containerul de dezvoltare **LXC 171 (claude-agent, 10.0.20.171)**, si +aduce raspunsurile inapoi. Practic: acelasi Claude Code cu care lucrezi in terminal, +comandat de pe telefon. + +Nu e un chatbot separat. Nu are memorie proprie, nu are baza de date proprie: sesiunile +sunt chiar sesiunile Claude Code din `~/.claude/projects/`, iar un fir de Discord este +o sesiune. + +> Nu confunda cu **MoltBot** (LXC 110) sau cu OpenClaw — acelea sunt alti agenti, cu +> alt scop. Puntea asta ruleaza pe masina de dezvoltare si are accesul ei. + +--- + +## Arhitectura + +``` +Discord (guild privat) + | on_message + v +bot.py -- allowlist (guild / canal / utilizator) [doar adaptor Discord] + | + +-- session_store.py state.json {thread_id: {sid, cwd, model, inflight, pid}} + +-- runner.py proces persistent per fir, alimentat pe stdin + +-- stream.py parser tolerant de JSONL + +-- render.py chunker + un loop de editare per canal + +-- limits.py max procese, timeout tur, rate limit, plafon de cost + +-- security/ hook PreToolUse (confirmari) + wrapper `infra` + +-- alerts.py alerte email [ops] + +-- cleanup.py procese lasate in urma (`!cleanup`) [ops] + | + v +claude -p --input-format stream-json --output-format stream-json --verbose + --resume --permission-mode bypassPermissions + --settings ~/.claude-discord/bot-settings.json --model sonnet --autocompact auto +``` + +Cateva alegeri care nu se vad din diagrama: + +- **Proces persistent per fir**, nu unul per mesaj. Asta permite *steering* la mijlocul + turului: un mesaj trimis in timp ce Claude lucreaza ajunge la el si schimba raspunsul + (verificat: mesaj la 8s intr-un tur de 34.5s). Reaper la 20 min de inactivitate; + repornirea se face cu `--resume `, deci firul nu-si pierde contextul. +- **Un proces `claude` = ~406 MB RSS** (masurat). De aici toate limitele: maxim 4 procese + vii, `MemoryMax=6G` pe unit, si comanda `!cleanup`. +- **Model implicit `sonnet`.** Un tur banal pe opus a costat $0.1547 (masurat), deci + opus e optional, per fir, prin `!model opus`. + +--- + +## Comenzi + +| Comanda | Ce face | +|---------|---------| +| `!new` | Sesiune noua, curata, in firul curent | +| `!new --fork` | Sesiune noua care porneste din contextul celei curente | +| `!cd ` | Schimba directorul de lucru al firului (ex. `!cd /workspace/romfastsql`) | +| `!model ` | Schimba modelul pentru firul curent | +| `!status` | Sesiune, director, model, cost cumulat, proces viu, ultimele linii de stderr | +| `!stop` | Opreste turul in desfasurare din firul curent | +| `!cleanup` | Listeaza procesele lasate in urma (rulare seaca). `!cleanup --force` le opreste | + +Un fir de Discord = o sesiune Claude. Canalul principal are si el sesiunea lui, cea +implicita. Subsolul fiecarui raspuns arata modelul, durata si costul. + +### Despre `!cleanup` + +`KillMode=control-group` opreste arborele serviciului la restart, dar **nu prinde ce s-a +desprins**: un server pornit cu `&` intr-un tur, un `nohup`, un job lung reparentat la +init. Alea raman si se aduna — 406 MB bucata, pe un container cu istoric de OOM. + +`!cleanup` cauta doua feluri de resturi: procese `claude` care nu apar in `state.json`, +si copii reparentati la init ramasi in cgroup-ul serviciului. **Ruleaza sec (dry-run) in +mod implicit** — intai vezi lista, apoi decizi. Ce e inregistrat in `state.json` si toti +descendentii acelor procese (adica turul care ruleaza chiar acum) nu sunt niciodata +atinse, iar potrivirea se face si pe `pid_start_time`, ca un PID reciclat sa nu duca la +omorarea altui proces. + +--- + +## Instalare + +### Partea automata + +```bash +cd /workspace/romfastsql/proxmox/lxc171-claude-agent/discord-bridge +./ops/install.sh +``` + +Scriptul e idempotent (poti sa-l rulezi de cate ori vrei) si face: + +1. `~/.claude-discord/` cu drepturi `0700`, plus `logs/` si `approvals/` +2. `~/.claude-discord/env` cu `0600`, copiat din `ops/env.example` — **nu suprascrie + niciodata un env existent** +3. venv in `~/.claude-discord/venv` + dependintele din `requirements.txt` +4. `loginctl enable-linger claude` — fara asta serviciul de utilizator moare la logout + si nu porneste la boot +5. symlink `~/.config/systemd/user/claude-discord.service` -> `ops/claude-discord.service`, + apoi `daemon-reload` si `systemd-analyze verify` +6. intrare de crontab pentru `logrotate` (zilnic, 04:10) + +Scriptul **nu porneste** serviciul. Dupa ce completezi env-ul: + +```bash +./ops/install.sh --start +``` + +### Partea manuala (o faci tu, o singura data) + +Nu se poate automatiza: cere un om logat in Discord. + +1. **Creeaza aplicatia Discord.** https://discord.com/developers/applications -> + *New Application*. E o aplicatie **noua, dedicata** puntii — nu refolosi aplicatia + MoltBot/OpenClaw. +2. **Adauga botul.** In aplicatie -> *Bot* -> *Add Bot*. +3. **Ia token-ul.** *Bot* -> *Reset Token* -> copiaza. **Se arata o singura data.** + Il pui in `~/.claude-discord/env`, la `DISCORD_TOKEN=`. Fisierul e `0600` si nu e + versionat. Daca token-ul ajunge vreodata intr-un commit, reseteaza-l imediat din + portal — cine il are poate comanda infrastructura. +4. **Activeaza intents.** *Bot* -> *Privileged Gateway Intents* -> porneste + **MESSAGE CONTENT INTENT**. Fara el botul primeste mesajele goale si nu face nimic. + (*Server Members* si *Presence* nu sunt necesare — lasa-le oprite.) +5. **Invita botul intr-un guild PRIVAT** al tau. *OAuth2* -> *URL Generator* -> + scopes: `bot` -> permisiuni: *Send Messages*, *Read Message History*, + *Create Public Threads*, *Send Messages in Threads*, *Attach Files*, + *Embed Links*, *Add Reactions*. Deschide URL-ul generat si alege serverul. + **Nu-l invita intr-un server cu alti oameni** — cine scrie in canalul permis + comanda direct containerul. +6. **Ia ID-urile pentru allowlist.** In Discord: *Settings* -> *Advanced* -> + **Developer Mode** pornit. Apoi click dreapta -> *Copy Server ID* / + *Copy Channel ID* / *Copy User ID*. Le pui in `~/.claude-discord/env`: + `DISCORD_GUILD_IDS`, `DISCORD_CHANNEL_IDS`, `DISCORD_USER_IDS` (separate prin virgula). + **Allowlist gol = nimic permis** (fail-closed). Mesajele de la webhook-uri si de la + alti boti sunt ignorate din principiu. +7. **Pune destinatarul alertelor**: `ALERT_RECIPIENT=` in acelasi env. +8. **Verifica plafonul de cost**: `COST_CAP_USD_DAY=` (implicit `5.00`). +9. Abia acum: `./ops/install.sh --start`. + +--- + +## Operare + +### Unde te uiti + +```bash +systemctl --user status claude-discord # e viu? +journalctl --user -u claude-discord -n 200 # ce a facut ultima data +tail -f ~/.claude-discord/logs/bot.log # logul aplicatiei +tail -f ~/.claude-discord/logs/alerts.log # ce alerte s-au trimis / au esuat +systemctl --user restart claude-discord # repornire +``` + +| Fisier | Ce e | +|--------|------| +| `~/.claude-discord/env` | token + allowlist + limite (0600) | +| `~/.claude-discord/state.json` | sesiuni, directoare, pid-uri, cost | +| `~/.claude-discord/logs/bot.log` | stdout/stderr al botului (rotit zilnic, 14 zile) | +| `~/.claude-discord/logs/alerts.log` | jurnalul alertelor | +| `~/.claude-discord/alerts-dedup.json` | fereastra de dedup a alertelor | +| `~/.claude-discord/approvals/` | cereri de confirmare intre hook si bot | + +### Cost + +Costul se vede in trei locuri: in subsolul fiecarui raspuns (turul curent + cumulat pe +fir), in `!status`, si in `state.json` la cheia `cost`. La atingerea plafonului zilnic +(`COST_CAP_USD_DAY`) botul nu mai accepta tururi noi si trimite email. Plafonul se +reseteaza la schimbarea zilei. + +### Alerte pe email + +`alerts.py` urmeaza tiparul deja folosit in repo (vezi +`proxmox/vm109-windows-dr/scripts/pveelite-down-alert.sh`): + +``` +mail -s "[LEVEL] subiect" "$ALERT_RECIPIENT" # LEVEL: INFO | WARN | CRITICAL +``` + +Se trimite alerta pentru: proces mort neasteptat, crash loop, plafon de cost atins, +`state.json` corupt, orfani detectati la sweep. + +Doua garantii care conteaza: + +- **`alert()` nu arunca niciodata exceptii.** O alerta esuata nu are voie sa doboare + botul; orice eroare ajunge in `alerts.log` si atat. +- **Dedup 1h pe `dedup_key`**, persistat pe disc. Fara el, un crash loop ar trimite + sute de emailuri identice. + +**Dependinta:** binarul `mail`. Pe LXC 171 e instalat pachetul **`bsd-mailx`** +(`/usr/bin/mail`), iar transportul e **postfix**, deja prezent (`/usr/sbin/sendmail`). +Pe o masina unde lipseste: + +```bash +sudo apt-get install -y bsd-mailx +``` + +Daca `mail` lipseste, alertele **nu se pierd**: se degradeaza la scriere in +`~/.claude-discord/logs/alerts.log`, cu tot cu corpul mesajului, si `install.sh` te +avertizeaza la instalare. Dar nimeni nu mai primeste nimic pe email — deci trateaza +lipsa lui ca pe o defectiune, nu ca pe o optiune. + +Test manual, fara sa pornesti botul: + +```bash +ALERT_RECIPIENT=tu@romfast.ro python3 alerts.py INFO "test punte" "corp de test" +tail -2 ~/.claude-discord/logs/alerts.log +``` + +### Cand pica + +| Simptom | Ce faci | +|---------|---------| +| Botul nu raspunde deloc in Discord | `systemctl --user status claude-discord`. Daca e `failed`, `journalctl --user -u claude-discord -n 100`. Cauza #1: token invalid sau **MESSAGE CONTENT INTENT** oprit. | +| Botul e viu dar ignora mesajele | Allowlist. Verifica `DISCORD_GUILD_IDS` / `DISCORD_CHANNEL_IDS` / `DISCORD_USER_IDS` din env. Respingerea e **tacuta**, intentionat. | +| Unitul se invarte in restart | Dupa 5 porniri esuate in 300s systemd renunta si lasa unitul `failed` (e voit). Repara, apoi `systemctl --user reset-failed claude-discord && systemctl --user start claude-discord`. | +| Firul e blocat pe hourglass | Botul a fost restartat la mijlocul unui tur. Turul **nu** se reia automat (risc de dubla executie sub `bypassPermissions`); sweep-ul de la pornire pune un avertisment in fir. Trimite mesajul din nou. | +| Memoria containerului creste | `!cleanup` (sec), apoi `!cleanup --force`. Vezi si `systemctl --user show claude-discord -p MemoryCurrent`. | +| „Plafon de cost atins" | E limita zilnica, nu o eroare. Ridica `COST_CAP_USD_DAY` in env si reporneste, sau asteapta ziua urmatoare. | +| Nu vin emailuri de alerta | `command -v mail`; `mailq`; `tail ~/.claude-discord/logs/alerts.log`. Un `NESENT` in log iti spune exact de ce. | +| Dupa reboot serviciul nu porneste | `loginctl show-user claude -p Linger` trebuie sa fie `yes`. Daca nu: `sudo loginctl enable-linger claude`. | + +### Teste + +```bash +cd /workspace/romfastsql/proxmox/lxc171-claude-agent/discord-bridge +python3 -m pytest -q # suita rapida, fara retea si fara Discord +python3 -m pytest -m e2e # testele care ating CLI-ul real (lente) +``` + +Testele de ops (`tests/test_alerts.py`, `tests/test_cleanup.py`) nu trimit email real si +nu omoara procese reale: folosesc un `mail` fals si copii de `sleep` pe care le pornesc +si le opresc ele insele. + +--- + +## Securitate — ce e si ce nu e + +Puntea ruleaza ca utilizatorul `claude`, cu `--permission-mode bypassPermissions`, si are +**exact accesul pe care il are omul in terminal**: `/workspace`, cheile SSH, nodurile +Proxmox, LXC-urile, VM-urile. Asta e **functionalitate ceruta**, nu scapare — puntea +exista tocmai ca sa poti administra infrastructura de pe telefon. + +Ce apara efectiv: + +1. **Control de acces pe canal.** Guild + canal + utilizator pe allowlist, gol = nimic + permis. Webhook-urile si botii sunt respinsi. Contul tau de Discord devine, practic, + o cheie de infrastructura — pune-i 2FA. +2. **Confirmare pentru operatiuni ireversibile.** Un hook `PreToolUse` opreste comanda si + posteaza butoane in fir; fara raspuns in fereastra de timp raspunsul e **deny** + (fail-closed). Verificat: a blocat un `rm -rf`, a asteptat aprobarea externa 20s si a + permis apoi executia, fara timeout. +3. **Wrapper `infra`** cu lista explicita de hosturi + token Proxmox cu ACL. + +Ce **nu** apara: regulile `deny` din settings. Sub `bypassPermissions` ele sunt un strat +cosmetic — verificat, `/usr/bin/ssh -V` si `bash -c "ssh -V"` trec pe langa ele. Nu te +baza pe ele ca pe o bariera. + +--- + +## Limitari cunoscute (asumate) + +- **Nu exista jurnal de audit independent.** Stratul de audit append-only pe branch + dedicat a fost considerat si **respins constient**. Consecinta, asumata: la o problema + — o comanda distructiva care a trecut, o modificare pe care nimeni nu si-o aminteste — + **nu exista o inregistrare independenta care sa spuna ce s-a intamplat, cand si pe ce + host**. Ce ramane sunt loguri care pot fi sterse de chiar procesul care le scrie: + `bot.log`, `journalctl`, jurnalele de sesiune din `~/.claude/projects/` si istoricul + git al repo-urilor atinse. Daca vreodata conteaza „cine si ce", asta e golul de + acoperit primul. +- **Fara reluare automata a turului pierdut.** Un restart la mijlocul unui tur pierde + turul; nu se reia singur, fiindca sub `bypassPermissions` jumatate din comenzi sunt + deja executate si o reluare le-ar rula a doua oara. +- **Rate limit-ul Discord e o degradare tacuta.** Intervalul de editare se adapteaza + (1s -> 5s), dar cand Discord franeaza nu apare niciun mesaj: raspunsul doar apare mai + incet. E singura cale fara test din analiza modurilor de esec — acceptata, fiindca + esecul e intarziere, nu pierdere. +- **`!cleanup` nu e infailibil.** Prinde procese `claude` neinregistrate si copii + reparentati la init ramasi in cgroup. Un proces care a iesit din cgroup *si* nu arata + a `claude` (un `python -m http.server` desprins complet, de exemplu) ii scapa. + Lista `NEVER_KILL` din `cleanup.py` protejeaza infrastructura sesiunii (systemd, sshd, + tmux, code-server) — deci un proces cu un asemenea nume in linia de comanda nu va fi + oprit niciodata, chiar daca e orfan. +- **Fara voce, imagini sau atasamente** catre Claude in v1. +- **Fara dashboard web** — exista deja pe MoltBot, puntea nu-l duplica. +- **Un singur container.** Daca LXC 171 e oprit, puntea e oprita. Nu are redundanta si + nu e in HA. + +--- + +## Fisiere + +| Fisier | Ce e | Lane | +|--------|------|------| +| `bot.py` | adaptorul Discord: allowlist, comenzi, butoane | A | +| `session_store.py` | `state.json`: scriere atomica, lock per fir, PID reuse | A | +| `runner.py` | proces persistent per fir, stdin JSONL, reaper | A | +| `stream.py` | parser tolerant de stream JSONL | A | +| `render.py` | chunker + loop de editare per canal | A | +| `limits.py` | max procese, timeout, rate limit, plafon de cost | A | +| `config.py` | citeste `~/.claude-discord/env` | A | +| `security/confirm_hook.py` | hook `PreToolUse`, fail-closed | B | +| `security/approvals.py` | canal de aprobari hook <-> bot | B | +| `security/infra` | wrapper cu lista de hosturi permise | B | +| `alerts.py` | alerte email, dedup 1h, nu arunca niciodata | C | +| `cleanup.py` | `!cleanup`: orfani, dry-run implicit | C | +| `ops/claude-discord.service` | unit systemd de utilizator | C | +| `ops/install.sh` | instalare idempotenta | C | +| `ops/env.example` | sablon de configurare | C | +| `ops/logrotate.conf` | rotatia logurilor | C | +| `INTERFACES.md` | contractul intre module | orchestrator | diff --git a/proxmox/lxc171-claude-agent/discord-bridge/alerts.py b/proxmox/lxc171-claude-agent/discord-bridge/alerts.py new file mode 100644 index 0000000..45cf6a6 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/alerts.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +"""alerts.py — alerte pe email pentru puntea Discord -> Claude Code (T12). + +Urmeaza tiparul de alertare deja folosit in repo (vezi +proxmox/vm109-windows-dr/scripts/pveelite-down-alert.sh): + + mail -s "[LEVEL] subiect" "$ALERT_RECIPIENT" + +Contract (INTERFACES.md, granita A <-> C): + + alert(level, subject, body, dedup_key=None) -> None + +Reguli dure: + * functia NU arunca NICIODATA exceptii — o alerta esuata nu are voie sa doboare botul; + orice eroare e prinsa, logata local si ignorata; + * dedup pe `dedup_key` cu fereastra de 1h, persistat pe disc, ca sa nu se trimita + acelasi email de o suta de ori intr-un crash loop; + * daca binarul `mail` lipseste, se degradeaza la scriere in log (nu e eroare fatala). + +Conditiile pe care le semnaleaza Lane A: proces mort neasteptat, crash loop, +plafon de cost atins, state.json corupt, orfani detectati la sweep. +""" + +from __future__ import annotations + +import json +import os +import pathlib +import shutil +import subprocess +import sys +import time + +# --- configurare ----------------------------------------------------------- +# config.py apartine Lane A si poate lipsi cand rulam izolat; importul e tolerant +# si NU cream o varianta proprie de config (vezi INTERFACES.md). +try: # pragma: no cover - depinde de ordinea de merge intre lane-uri + import config as _config # type: ignore +except Exception: # pragma: no cover + _config = None + + +def _default_state_dir() -> pathlib.Path: + if _config is not None and getattr(_config, "STATE_DIR", None): + return pathlib.Path(_config.STATE_DIR) + return pathlib.Path(os.path.expanduser("~/.claude-discord")) + + +def _default_log_dir() -> pathlib.Path: + if _config is not None and getattr(_config, "LOG_DIR", None): + return pathlib.Path(_config.LOG_DIR) + return _default_state_dir() / "logs" + + +# Variabile de modul, ca testele sa le poata rescrie fara sa atinga HOME-ul real. +STATE_DIR = _default_state_dir() +LOG_DIR = _default_log_dir() +DEDUP_FILE = STATE_DIR / "alerts-dedup.json" +LOG_FILE = LOG_DIR / "alerts.log" + +DEDUP_WINDOW_S = 3600.0 # 1h, cerut de contract +MAIL_TIMEOUT_S = 20.0 # daca MTA-ul atarna, nu blocam botul +MAX_BODY_CHARS = 60000 # un corp urias nu are ce cauta intr-un email de alerta +LEVELS = ("INFO", "WARN", "CRITICAL") + + +def _recipient() -> str: + """Destinatarul, cu acelasi default ca scripturile bash din repo: root.""" + val = None + if _config is not None and hasattr(_config, "get"): + try: + val = _config.get("ALERT_RECIPIENT") + except Exception: + val = None + if not val: + val = os.environ.get("ALERT_RECIPIENT") + return val or "root" + + +def _mail_binary() -> str | None: + """Calea catre `mail`, sau None daca lipseste (atunci degradam la log).""" + return shutil.which("mail") or shutil.which("mailx") + + +# --- log local ------------------------------------------------------------- + +def _log(line: str) -> None: + """Scrie o linie in ~/.claude-discord/logs/alerts.log. Nu arunca niciodata.""" + stamp = time.strftime("%Y-%m-%d %H:%M:%S") + text = f"[{stamp}] {line}\n" + try: + LOG_DIR.mkdir(parents=True, exist_ok=True) + with open(LOG_FILE, "a", encoding="utf-8") as fh: + fh.write(text) + except Exception: + # Ultima plasa de siguranta: stderr, care ajunge in journald prin unit. + try: + sys.stderr.write(text) + except Exception: + pass + + +# --- dedup pe disc --------------------------------------------------------- + +def _load_dedup() -> dict: + try: + with open(DEDUP_FILE, "r", encoding="utf-8") as fh: + data = json.load(fh) + if isinstance(data, dict): + return data + except FileNotFoundError: + pass + except Exception as exc: + _log(f"dedup: fisier ilizibil, se reia de la zero ({exc})") + return {} + + +def _save_dedup(data: dict) -> None: + """Scriere atomica; un fisier de dedup corupt ar strica alertele urmatoare.""" + STATE_DIR.mkdir(parents=True, exist_ok=True) + tmp = DEDUP_FILE.with_suffix(".json.tmp") + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(data, fh) + fh.flush() + os.fsync(fh.fileno()) + os.replace(tmp, DEDUP_FILE) + + +def _dedup_should_skip(key: str, now: float) -> bool: + """True daca aceeasi cheie a fost deja trimisa in ultima ora. + + Marcheaza cheia la fiecare incercare (si cand emailul esueaza), tocmai ca un + esec repetat sa nu devina el insusi sursa de spam. + """ + data = _load_dedup() + # curata intrarile expirate ca fisierul sa nu creasca la nesfarsit + fresh = {} + for k, ts in data.items(): + try: + ts = float(ts) + except Exception: + continue + if now - ts < DEDUP_WINDOW_S: + fresh[k] = ts + last = fresh.get(key) + if last is not None: + return True + fresh[key] = now + _save_dedup(fresh) + return False + + +# --- trimiterea propriu-zisa ---------------------------------------------- + +def _send_mail(subject_line: str, body: str) -> tuple[bool, str]: + """Ruleaza `mail -s "" `. Intoarce (ok, detaliu).""" + binary = _mail_binary() + if not binary: + return False, "binarul `mail` lipseste (instaleaza bsd-mailx)" + recipient = _recipient() + try: + proc = subprocess.run( + [binary, "-s", subject_line, recipient], + input=body.encode("utf-8", "replace"), + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + timeout=MAIL_TIMEOUT_S, + ) + except subprocess.TimeoutExpired: + return False, f"`mail` a depasit {MAIL_TIMEOUT_S:.0f}s si a fost abandonat" + except Exception as exc: + return False, f"`mail` nu a putut fi lansat: {exc}" + if proc.returncode != 0: + out = (proc.stdout or b"").decode("utf-8", "replace").strip() + return False, f"`mail` a iesit cu cod {proc.returncode}: {out[:400]}" + return True, f"trimis catre {recipient}" + + +def _format_body(level: str, subject: str, body: str) -> str: + """Corpul emailului, cu context de host si sursa — ca in scripturile bash.""" + host = "" + try: + host = os.uname().nodename + except Exception: + pass + body = (body or "").strip() + if len(body) > MAX_BODY_CHARS: + body = body[:MAX_BODY_CHARS] + "\n\n[... corp trunchiat ...]" + return ( + f"{body}\n" + "\n" + "-----------------------------------------------------------\n" + f" Nivel: {level}\n" + f" Subiect: {subject}\n" + f" Host: {host}\n" + f" Sursa: punte Discord -> Claude Code (claude-discord.service)\n" + f" Moment: {time.strftime('%Y-%m-%d %H:%M:%S')}\n" + f" Loguri: {LOG_DIR}\n" + ) + + +def alert(level: str, subject: str, body: str, dedup_key: str | None = None) -> None: + """Trimite o alerta pe email. NU arunca niciodata exceptii. + + level: "INFO" | "WARN" | "CRITICAL" (orice altceva e normalizat la "WARN") + dedup_key: aceeasi cheie nu se retrimite in fereastra de 1h + """ + try: + lvl = str(level or "").strip().upper() + if lvl not in LEVELS: + lvl = "WARN" + subj = " ".join(str(subject or "(fara subiect)").split())[:200] + + if dedup_key: + try: + if _dedup_should_skip(str(dedup_key), time.time()): + _log(f"{lvl} SKIP(dedup={dedup_key}) {subj}") + return + except Exception as exc: + # Dedup-ul e o optimizare, nu o bariera: daca pica, tot trimitem. + _log(f"dedup indisponibil ({exc}), se trimite oricum") + + subject_line = f"[{lvl}] {subj}" + ok, detail = _send_mail(subject_line, _format_body(lvl, subj, str(body or ""))) + if ok: + _log(f"{lvl} SENT {subj} :: {detail}") + else: + # Degradare: alerta ramane macar in log, cu tot cu corp. + _log(f"{lvl} NESENT {subj} :: {detail}") + _log(f" corp: {' | '.join(str(body or '').splitlines())[:2000]}") + except Exception as exc: # plasa finala — contractul spune "niciodata exceptii" + try: + _log(f"alert() a esuat complet: {exc!r}") + except Exception: + pass + + +# --- ajutoare pentru conditiile din Lane A -------------------------------- +# Nu sunt in contract, dar tin textele alertelor intr-un singur loc. + +def alert_process_died(thread_id: str, pid: int, detail: str = "") -> None: + alert( + "WARN", + f"Proces claude mort neasteptat (fir {thread_id})", + f"Procesul claude pid={pid} al firului {thread_id} a murit fara `result`.\n{detail}", + dedup_key=f"proc-died:{thread_id}", + ) + + +def alert_crash_loop(thread_id: str, count: int, window_s: float) -> None: + alert( + "CRITICAL", + f"Crash loop pe firul {thread_id}", + f"{count} porniri esuate in {window_s:.0f}s. Firul a fost oprit.\n" + "Verifica `journalctl --user -u claude-discord -n 200`.", + dedup_key=f"crash-loop:{thread_id}", + ) + + +def alert_cost_cap(usd: float, cap: float) -> None: + alert( + "CRITICAL", + f"Plafon de cost atins: ${usd:.4f} / ${cap:.2f}", + f"Botul s-a oprit din a accepta tururi noi pentru azi.\n" + f"Cost cumulat: ${usd:.4f}. Plafon: ${cap:.2f} (COST_CAP_USD_DAY).", + dedup_key="cost-cap", + ) + + +def alert_state_corrupt(path: str, detail: str = "") -> None: + alert( + "CRITICAL", + "state.json corupt", + f"Fisierul de stare {path} nu a putut fi citit si a fost recuperat.\n{detail}", + dedup_key="state-corrupt", + ) + + +def alert_orphans(orphans: list) -> None: + lines = [ + f" pid={o.get('pid')} varsta={o.get('age_s')}s rss={o.get('rss_mb')}MB " + f":: {str(o.get('cmdline'))[:120]}" + for o in (orphans or []) + ] + alert( + "WARN", + f"{len(orphans or [])} procese orfane detectate la sweep", + "Procese ramase din tururi anterioare (KillMode nu le prinde pe toate).\n" + "Ruleaza `!cleanup` in Discord ca sa le vezi si sa le opresti.\n\n" + + "\n".join(lines), + dedup_key="orphans", + ) + + +if __name__ == "__main__": # test manual: python3 alerts.py INFO "subiect" "corp" + lvl = sys.argv[1] if len(sys.argv) > 1 else "INFO" + sub = sys.argv[2] if len(sys.argv) > 2 else "test punte Discord" + bod = sys.argv[3] if len(sys.argv) > 3 else "Alerta de test, se poate ignora." + alert(lvl, sub, bod) + print(f"gata; vezi {LOG_FILE}") diff --git a/proxmox/lxc171-claude-agent/discord-bridge/bot.py b/proxmox/lxc171-claude-agent/discord-bridge/bot.py new file mode 100644 index 0000000..56157f0 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/bot.py @@ -0,0 +1,703 @@ +"""Adaptorul Discord al puntii: allowlist, comenzi, steering, aprobari. + +Impartirea e intentionata: + * `Bridge` -- toata logica, fara discord.py. Primeste obiecte "mesaj" duck-typed, + deci se poate testa integral fara retea si fara Discord. + * `BridgeClient` -- invelisul subtire peste `discord.Client`, doar dispecerat. + +T1 (allowlist) si T11 (model + cost) traiesc aici. Nucleul (config/stream/session_store/ +runner/limits/render) e consumat ca atare, nu duplicat. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import io +import logging +import os +import pathlib +import time +from dataclasses import dataclass, field + +import config +import limits as limits_mod +import render +import runner as runner_mod +import session_store +import stream as stream_mod + +log = logging.getLogger("discord-bridge.bot") + +# discord.py e necesar doar pentru procesul real; logica se testeaza fara el. +try: # pragma: no cover - depinde de mediu + import discord # type: ignore +except ImportError: # pragma: no cover + discord = None # type: ignore + +# Lane C (ops) si Lane B (securitate) pot lipsi: importuri tolerante, fara variante proprii. +try: # pragma: no cover + import alerts # type: ignore +except ImportError: # pragma: no cover + class _NoAlerts: + @staticmethod + def alert(level: str, subject: str, body: str, dedup_key: str | None = None) -> None: + log.warning("alerta (%s) %s: %s", level, subject, body) + + alerts = _NoAlerts() # type: ignore + +try: # pragma: no cover + import cleanup # type: ignore +except ImportError: # pragma: no cover + cleanup = None # type: ignore + +try: # pragma: no cover + from security import approvals # type: ignore +except Exception: # pragma: no cover - lipsa modulului NU are voie sa opreasca botul + approvals = None # type: ignore + + +MODELS = ("sonnet", "opus") +PREFIX = "!" +LIVE_TAIL = 1500 # cate caractere aratam in mesajul care se editeaza in timp real + + +# ------------------------------------------------------------------ allowlist +def _ids(*keys: str) -> set[str]: + """Reuniunea mai multor chei de allowlist (acceptam si singular, si plural).""" + out: set[str] = set() + for key in keys: + for item in config.get_list(key): + item = item.strip() + if item: + out.add(item) + return out + + +def guild_ids() -> set[str]: + return _ids("DISCORD_GUILD_IDS", "DISCORD_GUILD_ID") + + +def channel_ids() -> set[str]: + return _ids("DISCORD_CHANNEL_IDS", "DISCORD_CHANNEL_ID") + + +def user_ids() -> set[str]: + return _ids("DISCORD_USER_IDS", "DISCORD_USER_ID") + + +def default_model() -> str: + """`CLAUDE_MODEL` din env (numele din ops/env.example), altfel `MODEL_DEFAULT`.""" + model = (config.get("CLAUDE_MODEL", "") or "").strip() + if model in MODELS: + return model + model = (config.get("MODEL_DEFAULT", "sonnet") or "sonnet").strip() + return model if model in MODELS else "sonnet" + + +@dataclass(frozen=True) +class Decision: + ok: bool + reason: str = "" + + def __bool__(self) -> bool: # pragma: no cover - comoditate + return self.ok + + +ACCEPTED = Decision(True) + + +def _attr(obj, name, default=None): + return getattr(obj, name, default) + + +def channel_candidates(message) -> list[str]: + """Id-urile de canal relevante: firul insusi si canalul-parinte al firului.""" + ch = _attr(message, "channel") + out = [] + for value in (_attr(ch, "id"), _attr(ch, "parent_id"), _attr(message, "channel_id")): + if value is not None: + out.append(str(value)) + return out + + +def thread_key(message) -> str: + """Un fir Discord = o sesiune Claude; canalul principal = sesiunea implicita.""" + ch = _attr(message, "channel") + return str(_attr(ch, "id", _attr(message, "channel_id", "0"))) + + +def check_message(message, self_id: str | None = None) -> Decision: + """T1: filtrul de la intrare. Fail-closed: allowlist lipsa sau goala => refuz. + + Refuzul e TACUT in `Bridge.handle_message` (nu confirmam unui strain ca botul + exista); aici doar spunem de ce. + """ + # 1. webhook-uri: un webhook scurs nu are voie sa comande nimic + if _attr(message, "webhook_id") is not None: + return Decision(False, "webhook") + + author = _attr(message, "author") + if author is None: + return Decision(False, "fara autor") + if bool(_attr(author, "bot", False)): + return Decision(False, "bot") + uid = str(_attr(author, "id", "")) + if self_id is not None and uid == str(self_id): + return Decision(False, "propriul mesaj") + + guilds, channels, users = guild_ids(), channel_ids(), user_ids() + if not guilds or not channels or not users: + return Decision(False, "allowlist goala (fail-closed)") + + guild = _attr(message, "guild") + gid = _attr(guild, "id") if guild is not None else _attr(message, "guild_id") + if gid is None: + return Decision(False, "mesaj privat (fara guild)") + if str(gid) not in guilds: + return Decision(False, f"guild neautorizat {gid}") + + if not (set(channel_candidates(message)) & channels): + return Decision(False, f"canal neautorizat {thread_key(message)}") + + if uid not in users: + return Decision(False, f"utilizator neautorizat {uid}") + + return ACCEPTED + + +# -------------------------------------------------------------------- comenzi +@dataclass(frozen=True) +class Command: + name: str + args: list[str] = field(default_factory=list) + + @property + def rest(self) -> str: + return " ".join(self.args) + + +def parse_command(content: str) -> Command | None: + text = (content or "").strip() + if not text.startswith(PREFIX): + return None + body = text[len(PREFIX):].strip() + if not body: + return None + parts = body.split() + return Command(parts[0].lower(), parts[1:]) + + +# ------------------------------------------------------------------ utilitare +def rss_mb(pid: int | None) -> float: + """RSS-ul unui proces, in MB. 0.0 daca nu se poate citi.""" + if not pid: + return 0.0 + try: + with open(f"/proc/{int(pid)}/status", "r", encoding="utf-8") as fh: + for line in fh: + if line.startswith("VmRSS:"): + return round(int(line.split()[1]) / 1024.0, 1) + except (OSError, ValueError, IndexError): + pass + return 0.0 + + +def _tool_line(ev) -> str: + if isinstance(ev, stream_mod.ToolUse): + inp = ev.input or {} + detail = inp.get("command") or inp.get("file_path") or inp.get("pattern") or "" + detail = str(detail).replace("\n", " ")[:80] + return f"⚙️ `{ev.name}` {detail}".rstrip() + return "" + + +# --------------------------------------------------------------------- Bridge +class Bridge: + """Logica adaptorului, fara nicio dependinta de discord.py.""" + + def __init__( + self, + store=None, + runner=None, + limits=None, + *, + get_channel=None, + self_id: str | None = None, + ): + self.store = store if store is not None else session_store.SessionStore() + if not getattr(self.store, "loaded", False): + self.store.load() + self.limits = limits if limits is not None else limits_mod.Limits(self.store) + self.runner = runner if runner is not None else runner_mod.RunnerManager( + self.store, + settings=config.SETTINGS_FILE if os.path.exists(config.SETTINGS_FILE) else None, + is_inflight=self.store.is_inflight, + ) + self.render = render.RenderManager(self._edit) + self.get_channel = get_channel + self.self_id = self_id + self.rejected: list[str] = [] # diagnostic (si pentru teste) + self.last_result = None # ultimul `result` primit (diagnostic) + self.steered = 0 + self.approvals_wired = False + + # ------------------------------------------------------------ iesire + async def _edit(self, target, text: str) -> None: + await target.edit(content=text) + + async def say(self, channel, text: str, filename: str = "raspuns.md"): + """Trimite text respectand limita de 2000 de caractere a Discord.""" + out = render.split_message(text, filename=filename) + if isinstance(out, render.Attachment): + if discord is not None: + fh = io.BytesIO(out.content.encode("utf-8")) + return await channel.send( + out.preview, file=discord.File(fh, filename=out.filename) + ) + return await channel.send(out.preview) + last = None + for part in out.parts: + last = await channel.send(part) + return last + + async def react(self, message, emoji: str) -> None: + with contextlib.suppress(Exception): + await message.add_reaction(emoji) + + def channel_of(self, thread_id) -> object | None: + if self.get_channel is None: + return None + try: + return self.get_channel(int(thread_id)) + except (TypeError, ValueError): + return None + except Exception: # pragma: no cover - clientul poate arunca + return None + + # ------------------------------------------------------------ pornire + async def startup(self) -> list[dict]: + """T5: anunta tururile pierdute. FARA reluare automata (dubla executie).""" + config.ensure_dirs() + lost = self.store.sweep_lost_turns() + for item in lost: + channel = self.channel_of(item.get("thread_id")) + if channel is None: + log.warning("tur pierdut in firul %s, canal negasit", item.get("thread_id")) + continue + with contextlib.suppress(Exception): + await self.say(channel, f"⚠️ {item.get('warning')}") + self.runner.start_reaper() + self.wire_approvals() + return lost + + async def shutdown(self) -> None: + with contextlib.suppress(Exception): + await self.render.stop_all() + with contextlib.suppress(Exception): + await self.runner.stop_all() + + # ------------------------------------------------------------ aprobari + def wire_approvals(self) -> bool: + """Lane B poate lipsi: atunci botul porneste normal, doar fara aprobari.""" + if approvals is None: + log.warning("security.approvals lipseste: pornesc fara flux de aprobari") + return False + with contextlib.suppress(Exception): + approvals.set_on_request(self.on_approval_request) + self.approvals_wired = True + return self.approvals_wired + + async def on_approval_request(self, request: dict) -> None: + channel = self.channel_of(request.get("thread_id")) + if channel is None: + log.warning("cerere de aprobare fara canal: %s", request.get("request_id")) + return + text = ( + f"🔐 **Confirmare ceruta** — `{request.get('tool_name') or '?'}`\n" + f"```\n{str(request.get('command') or '')[:900]}\n```" + ) + view = self.approval_view(str(request.get("request_id") or "")) + with contextlib.suppress(Exception): + if view is not None: + await channel.send(text, view=view) + else: + await channel.send(text) + + def approval_view(self, request_id: str): + """View cu Allow/Deny. Fara discord.py (sau fara Lane B) returneaza None.""" + if discord is None or approvals is None: + return None + view = discord.ui.View(timeout=None) + + def _mk(label: str, decision: str, style): + button = discord.ui.Button(label=label, style=style) + + async def _cb(interaction): # pragma: no cover - are nevoie de Discord real + msg = self.decide(str(interaction.user.id), request_id, decision) + with contextlib.suppress(Exception): + await interaction.response.edit_message(content=msg, view=None) + + button.callback = _cb + return button + + view.add_item(_mk("Allow", "allow", discord.ButtonStyle.success)) + view.add_item(_mk("Deny", "deny", discord.ButtonStyle.danger)) + return view + + def decide(self, user_id: str, request_id: str, decision: str) -> str: + """Apasarea butonului. Verifica din nou allowlist-ul: butonul e vizibil tuturor.""" + if str(user_id) not in user_ids(): + log.warning("decizie refuzata: utilizator neautorizat %s", user_id) + return "⛔ Nu esti in allowlist." + if approvals is None: + return "⛔ Modulul de aprobari nu e disponibil." + ok = False + with contextlib.suppress(Exception): + ok = bool(approvals.submit_decision(request_id, decision)) + if not ok: + return "⚠️ Cererea nu mai exista (expirata sau deja decisa)." + return f"{'✅ Permis' if decision == 'allow' else '🚫 Refuzat'} de <@{user_id}>." + + # ------------------------------------------------------------- intrare + async def handle_message(self, message) -> str: + """Punctul de intrare. Returneaza o eticheta pentru teste/diagnostic.""" + decision = check_message(message, self.self_id) + if not decision.ok: + # Refuz TACUT: nu raspundem, nu reactionam. Doar log local. + self.rejected.append(decision.reason) + log.warning( + "mesaj respins (%s): guild=%s canal=%s user=%s", + decision.reason, + _attr(_attr(message, "guild"), "id"), + thread_key(message), + _attr(_attr(message, "author"), "id"), + ) + return "rejected" + + content = (_attr(message, "content", "") or "").strip() + if not content: + return "empty" + + tid = thread_key(message) + command = parse_command(content) + if command is not None: + return await self.handle_command(message, tid, command) + + # Steering: un mesaj sosit in timpul unui tur NU deschide un tur nou, + # ci intra pe stdin-ul procesului viu. Asta e functionalitatea centrala. + proc = self.runner.procs.get(tid) + if proc is not None and proc.alive and proc.inflight: + try: + await proc.send(content) + except Exception: + log.exception("steering esuat pe firul %s", tid) + else: + self.steered += 1 + await self.react(message, "➡️") + return "steered" + + return await self.run_turn(message, tid, content) + + # ------------------------------------------------------------- comenzi + async def handle_command(self, message, tid: str, cmd: Command) -> str: + handler = getattr(self, f"cmd_{cmd.name}", None) + if handler is None: + await self.say(message.channel, f"Comanda `!{cmd.name}` nu exista. `!help` le listeaza.") + return "unknown-command" + await handler(message, tid, cmd) + return f"cmd:{cmd.name}" + + async def cmd_help(self, message, tid: str, cmd: Command) -> None: + await self.say( + message.channel, + "**Comenzi**\n" + "`!new` sesiune noua · `!new --fork` sesiune noua din contextul curent\n" + "`!cd ` schimba directorul de lucru\n" + "`!model ` schimba modelul firului\n" + "`!status` starea firului · `!stop` opreste turul curent\n" + "`!cleanup [--force]` procese lasate in urma", + ) + + async def cmd_new(self, message, tid: str, cmd: Command) -> None: + fork = "--fork" in cmd.args + await self.runner.reset(tid, fork=fork) + async with self.store.lock_for(tid): + fields = {"pid": None, "pid_start_time": None, "inflight": None} + if not fork: + fields["sid"] = None + self.store.update_thread(tid, **fields) + await self.say( + message.channel, + "🧵 Sesiune noua pornita din contextul celei curente (`--fork`)." + if fork else "🧵 Sesiune noua, curata.", + ) + + async def cmd_cd(self, message, tid: str, cmd: Command) -> None: + if not cmd.args: + await self.say(message.channel, "Foloseste `!cd `.") + return + # Fara allowlist de proiecte: accesul larg la /workspace e decizie ferma din plan. + path = os.path.abspath(os.path.expanduser(cmd.rest)) + if not os.path.isdir(path): + await self.say(message.channel, f"⛔ `{path}` nu e un director.") + return + async with self.store.lock_for(tid): + self.store.update_thread(tid, cwd=path) + await self.runner.set_options(tid, cwd=path) + await self.say(message.channel, f"📁 Director de lucru: `{path}`") + + async def cmd_model(self, message, tid: str, cmd: Command) -> None: + rec = self.store.thread(tid) + if not cmd.args: + await self.say( + message.channel, + f"Model curent: `{rec.get('model') or default_model()}`. " + f"Foloseste `!model <{'|'.join(MODELS)}>`.", + ) + return + model = cmd.args[0].strip().lower() + if model not in MODELS: + await self.say(message.channel, f"⛔ Model necunoscut `{model}`. Alege: {', '.join(MODELS)}.") + return + async with self.store.lock_for(tid): + self.store.update_thread(tid, model=model) + await self.runner.set_options(tid, model=model) + await self.say(message.channel, f"🤖 Model pentru acest fir: `{model}`.") + + async def cmd_status(self, message, tid: str, cmd: Command) -> None: + await self.say(message.channel, self.status_text(tid)) + + def status_text(self, thread_id: str) -> str: + rec = self.store.thread(thread_id) + proc = self.runner.procs.get(str(thread_id)) + alive = bool(proc is not None and proc.alive) + pid = proc.pid if proc is not None else rec.get("pid") + model = rec.get("model") or default_model() + cap = self.limits.cost_cap + lines = [ + "**Status**", + "```", + f"fir : {thread_id}", + f"sesiune : {(proc.sid if proc is not None else None) or rec.get('sid') or '(noua)'}", + f"director : {rec.get('cwd') or config.get('DEFAULT_CWD', '/workspace')}", + f"model : {model}", + f"proces : {'viu' if alive else 'oprit'}" + + (f" pid={pid} rss={rss_mb(pid)} MB" if alive and pid else ""), + f"tur in zbor: {'da' if self.store.is_inflight(thread_id) else 'nu'}", + f"in coada : {'da' if self.limits.queued(thread_id) else 'nu'}", + f"procese : {self.runner.live_count()} vii, {self.limits.free_slots} sloturi libere", + f"cost fir : ${float(rec.get('cost_usd_total') or 0.0):.4f}", + f"cost azi : ${self.limits.cost_today():.4f} / ${cap:.2f}" + + (" ⛔ PLAFON ATINS" if self.limits.stopped() else ""), + "```", + ] + tail = proc.stderr_tail(10) if proc is not None else [] + if tail: + body = "\n".join(tail)[-800:] + lines.append("stderr (ultimele linii):\n```\n" + body + "\n```") + return "\n".join(lines) + + async def cmd_stop(self, message, tid: str, cmd: Command) -> None: + proc = self.runner.procs.get(tid) + if proc is None or not proc.alive: + await self.say(message.channel, "Nu ruleaza nimic in acest fir.") + else: + await proc.stop() + await self.say(message.channel, "🛑 Turul a fost oprit.") + async with self.store.lock_for(tid): + self.store.clear_inflight(tid) + + async def cmd_cleanup(self, message, tid: str, cmd: Command) -> None: + if cleanup is None: + await self.say(message.channel, "⚠️ Modulul `cleanup` nu e disponibil (Lane C).") + return + force = "--force" in cmd.args + try: + orphans = await asyncio.to_thread(cleanup.find_orphans, self.store.state) + results = await asyncio.to_thread(cleanup.kill_orphans, orphans, not force) + except Exception as exc: + log.exception("cleanup a esuat") + await self.say(message.channel, f"⛔ `!cleanup` a esuat: {exc}") + return + await self.say(message.channel, cleanup.format_report(orphans, results if force else None)) + + # ---------------------------------------------------------------- tur + async def run_turn(self, message, tid: str, prompt: str) -> str: + user_id = str(_attr(_attr(message, "author"), "id", "")) + channel = message.channel + rec = self.store.thread(tid) + model = rec.get("model") or default_model() + cwd = rec.get("cwd") or config.get("DEFAULT_CWD", "/workspace") + turn_id = runner_mod.new_turn_id() + + try: + async with self.limits.turn(tid, user_id) as timeout: + return await self._turn_body( + message, channel, tid, prompt, rec, model, cwd, turn_id, user_id, timeout + ) + except limits_mod.CostCapReached as exc: + # T11: plafonul atins => o spunem in fir si ne oprim. + await self.say(channel, f"⛔ {exc} Botul nu mai accepta tururi azi.") + return "cost-cap" + except limits_mod.RateLimited as exc: + await self.say(channel, f"⏳ {exc}") + return "rate-limited" + + async def _turn_body(self, message, channel, tid, prompt, rec, model, cwd, + turn_id, user_id, timeout) -> str: + proc = self.runner.get(tid, cwd=cwd, model=model, sid=rec.get("sid")) + proc.cwd, proc.model = cwd, model + if proc.sid is None and rec.get("sid"): + proc.sid = rec["sid"] + + async with self.store.lock_for(tid): + self.store.set_inflight(tid, turn_id, user_id, str(_attr(message, "id", ""))) + + placeholder = await channel.send("⏳ lucrez…") + chunks: list[str] = [] + tools: list[str] = [] + loop = self.render.loop_for(str(tid)) + + async def on_event(ev) -> None: + if isinstance(ev, stream_mod.SystemInit) and ev.session_id: + proc.sid = ev.session_id + elif isinstance(ev, stream_mod.AssistantText): + chunks.append(ev.text) + elif isinstance(ev, stream_mod.ToolUse): + tools.append(_tool_line(ev)) + elif isinstance(ev, runner_mod.SessionRestarted): + chunks.append(f"_{ev.text}_\n") + else: + return + live = "".join(chunks)[-LIVE_TAIL:] + if tools: + live = (live + "\n" + tools[-1]).strip() + loop.queue(placeholder, live or "⏳ lucrez…") + + started = time.time() + result = None + outcome = None + try: + outcome = await proc.run_turn(prompt, on_event, timeout) + result = self.last_result = outcome.result + except runner_mod.TurnTimeout as exc: + await self._fail(channel, tid, f"⏱️ {exc}", "tur expirat", str(exc)) + return "timeout" + except runner_mod.TurnFailed as exc: + await self._fail(channel, tid, f"⛔ Turul a esuat: {exc}", "tur esuat", str(exc)) + return "failed" + except Exception as exc: # pragma: no cover - plasa de siguranta + log.exception("tur neasteptat esuat pe firul %s", tid) + await self._fail(channel, tid, f"⛔ Eroare neasteptata: {exc}", "eroare neasteptata", str(exc)) + return "error" + finally: + with contextlib.suppress(Exception): + await loop.stop() + self.render.loops.pop(str(tid), None) + async with self.store.lock_for(tid): + self.store.clear_inflight(tid) + if proc.sid: + self.store.update_thread(tid, sid=proc.sid) + + # ---- cost si subsol (T11) + cost = float(getattr(result, "total_cost_usd", 0.0) or 0.0) + duration = int(getattr(result, "duration_ms", 0) or (time.time() - started) * 1000) + self.limits.record_cost(cost, tid) + thread_total = float(self.store.thread(tid).get("cost_usd_total") or 0.0) + + text = "".join(chunks).strip() or (getattr(result, "text", "") or "").strip() + if getattr(result, "is_error", False): + text = (text + "\n\n⚠️ Claude a raportat o eroare pentru acest tur.").strip() + body = (text or "_(raspuns gol)_") + "\n" + render.footer(model, duration, cost, thread_total) + + out = render.split_message(body) + if isinstance(out, render.Attachment): + with contextlib.suppress(Exception): + await placeholder.edit(content=out.preview) + if discord is not None: + fh = io.BytesIO(out.content.encode("utf-8")) + await channel.send(file=discord.File(fh, filename=out.filename)) + else: + with contextlib.suppress(Exception): + await placeholder.edit(content=out.parts[0]) + for part in out.parts[1:]: + await channel.send(part) + + if self.limits.stopped(): + await self.say( + channel, + f"⛔ Plafonul de cost pe azi a fost atins " + f"(${self.limits.cost_today():.2f} / ${self.limits.cost_cap:.2f}). Ma opresc.", + ) + return "ok" + + async def _fail(self, channel, tid: str, text: str, subject: str, body: str) -> None: + with contextlib.suppress(Exception): + alerts.alert("WARN", subject, f"fir {tid}: {body}", f"turn-fail-{tid}") + with contextlib.suppress(Exception): + await self.say(channel, text) + + +# --------------------------------------------------------------- client real +def make_client(bridge: Bridge | None = None): # pragma: no cover - are nevoie de discord.py + if discord is None: + raise RuntimeError("discord.py nu e instalat (vezi requirements.txt)") + + intents = discord.Intents.default() + intents.message_content = True + + class BridgeClient(discord.Client): + """Invelis subtire: tot ce e logica sta in `Bridge`.""" + + def __init__(self): + super().__init__(intents=intents) + self.bridge = bridge or Bridge() + self.bridge.get_channel = self.get_channel + self._started = False + + async def on_ready(self): + self.bridge.self_id = str(self.user.id) if self.user else None + if not self._started: + self._started = True + await self.bridge.startup() + log.info("conectat ca %s", self.user) + + async def on_message(self, message): + await self.bridge.handle_message(message) + + async def close(self): + await self.bridge.shutdown() + await super().close() + + return BridgeClient() + + +def setup_logging() -> None: # pragma: no cover + config.ensure_dirs() + handlers: list[logging.Handler] = [logging.StreamHandler()] + with contextlib.suppress(OSError): + handlers.append(logging.FileHandler(pathlib.Path(config.LOG_DIR) / "bot.log")) + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s: %(message)s", + handlers=handlers, + ) + + +def main() -> int: # pragma: no cover + setup_logging() + token = config.get("DISCORD_TOKEN", "") + if not token: + log.error("DISCORD_TOKEN lipseste din %s", config.ENV_FILE) + return 2 + if not (guild_ids() and channel_ids() and user_ids()): + log.error("allowlist incompleta in %s: botul nu ar accepta pe nimeni", config.ENV_FILE) + return 2 + client = make_client() + client.run(token, log_handler=None) + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/proxmox/lxc171-claude-agent/discord-bridge/cleanup.py b/proxmox/lxc171-claude-agent/discord-bridge/cleanup.py new file mode 100644 index 0000000..0404205 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/cleanup.py @@ -0,0 +1,435 @@ +#!/usr/bin/env python3 +"""cleanup.py — comanda `!cleanup`: procese lasate in urma de tururi anterioare (T13). + +De ce exista (Codex #8): `KillMode=control-group` opreste arborele serviciului la +restart, dar NU prinde procesele care s-au desprins — daemoni pornite cu `&` sau +`nohup`, servere de dezvoltare lansate intr-un tur si uitate acolo, joburi lungi +reparentate la init. Fiecare proces `claude` are ~406 MB RSS masurat, iar +containerul are istoric de OOM: acumularea lor nu e cosmetica. + +Contract (INTERFACES.md, granita A <-> C): + + find_orphans(state) -> [{"pid", "cmdline", "age_s", "rss_mb"}] + kill_orphans(orphans, dry_run=True) -> [{...}] + +`dry_run=True` e implicit si e intentionat: omul vede intai ce s-ar omori. + +Ce NU e considerat orfan: + * procesele `claude` inregistrate in state.json si toti descendentii lor + (sunt turul care ruleaza chiar acum); + * procesul curent, parintii lui si botul insusi; + * orice proces al altui utilizator; + * infrastructura sesiunii (systemd --user, sshd, tmux, code-server, dbus...). +""" + +from __future__ import annotations + +import os +import pathlib +import signal +import sys +import time + +try: # pragma: no cover - config.py apartine Lane A, poate lipsi la momentul asta + import config as _config # type: ignore +except Exception: # pragma: no cover + _config = None + +PROC = pathlib.Path("/proc") +CLOCK_TICKS = float(os.sysconf("SC_CLK_TCK")) +PAGE_SIZE = float(os.sysconf("SC_PAGE_SIZE")) + +# Cgroup-ul serviciului: procesele reparentate la init care raman inauntru sunt +# aproape sigur resturi ale unui tur. Numele e fix, vezi ops/claude-discord.service. +SERVICE_CGROUP = "claude-discord.service" + +# Numele executabilului CLI-ului. Se compara pe BASENAME-ul fiecarui argument, nu ca +# subsir: utilizatorul containerului se numeste tot `claude`, deci orice cale din +# home-ul lui contine cuvantul si un match pe subsir ar da fals pozitiv pe tot. +CLAUDE_BASENAMES = ("claude", "claude.exe") +CLAUDE_PATH_MARKERS = ("claude-code/bin/claude", "node_modules/.bin/claude") + +# Procese care nu se ating niciodata, chiar daca sunt in cgroup si reparentate. +NEVER_KILL = ( + "systemd", "sshd", "dbus-daemon", "tmux", "code-server", "vscode-server", + "bot.py", "claude-discord", "login", "bash -l", "(sd-pam)", "pytest", +) + +GRACE_S = 3.0 # cat asteptam intre SIGTERM si SIGKILL + + +# --- citire /proc ---------------------------------------------------------- + +def _read(path: pathlib.Path) -> str: + try: + return path.read_text(errors="replace") + except Exception: + return "" + + +def _boot_epoch() -> float: + """Momentul (epoch) fata de care e masurat `starttime` din /proc//stat. + + ATENTIE, capcana de container: pe LXC 171 /proc/uptime e virtualizat de lxcfs + (arata uptime-ul CONTAINERULUI), in timp ce `starttime` ramane raportat la boot-ul + GAZDEI. Scaderea celor doua da varste negative (verificat: -561s pentru un proces + pornit acum 10 minute; si `ps -o etimes` arata acolo 4123168064). `btime` din + /proc/stat e coerent cu `starttime`, deci ea e referinta corecta. + """ + for line in _read(PROC / "stat").splitlines(): + if line.startswith("btime"): + try: + return float(line.split()[1]) + except Exception: + break + # ultima varianta: boot dedus din uptime (poate fi gresit in container) + try: + return time.time() - float(_read(PROC / "uptime").split()[0]) + except Exception: + return 0.0 + + +def _parse_stat(pid: int) -> tuple[int, float] | None: + """(ppid, starttime_ticks) din /proc//stat. + + comm-ul e intre paranteze si poate contine spatii, deci se taie dupa ultimul ')'. + """ + raw = _read(PROC / str(pid) / "stat") + if not raw: + return None + try: + rest = raw[raw.rindex(")") + 2:].split() + # dupa comm, campul 1 e state; ppid e campul 4 global = rest[1] + ppid = int(rest[1]) + starttime = float(rest[19]) # campul 22 global + return ppid, starttime + except Exception: + return None + + +def _is_zombie(pid: int) -> bool: + """Un proces terminat dar nereaped are inca /proc//stat; e mort, nu viu.""" + raw = _read(PROC / str(pid) / "stat") + try: + return raw[raw.rindex(")") + 2:].split()[0] in ("Z", "X", "x") + except Exception: + return False + + +def _rss_mb(pid: int) -> float: + try: + pages = float(_read(PROC / str(pid) / "statm").split()[1]) + return round(pages * PAGE_SIZE / (1024 * 1024), 1) + except Exception: + return 0.0 + + +def _cmdline(pid: int) -> str: + raw = _read(PROC / str(pid) / "cmdline") + if raw: + parts = [p for p in raw.split("\0") if p] + if parts: + return " ".join(parts) + # proces de kernel sau cmdline gol: cadem pe comm + comm = _read(PROC / str(pid) / "comm").strip() + return f"[{comm}]" if comm else "" + + +def _uid(pid: int) -> int | None: + for line in _read(PROC / str(pid) / "status").splitlines(): + if line.startswith("Uid:"): + try: + return int(line.split()[1]) + except Exception: + return None + return None + + +def _cgroup(pid: int) -> str: + return _read(PROC / str(pid) / "cgroup").strip() + + +def scan_processes() -> dict[int, dict]: + """Instantaneu al proceselor utilizatorului curent, indexat pe pid.""" + me = os.getuid() + boot = _boot_epoch() + now = time.time() + out: dict[int, dict] = {} + try: + entries = [e for e in os.listdir(PROC) if e.isdigit()] + except Exception: + return out + for entry in entries: + pid = int(entry) + st = _parse_stat(pid) + if st is None: + continue # procesul a disparut intre listare si citire + uid = _uid(pid) + if uid is None or uid != me: + continue + ppid, starttime = st + age = now - (boot + starttime / CLOCK_TICKS) if boot else 0.0 + out[pid] = { + "pid": pid, + "ppid": ppid, + "cmdline": _cmdline(pid), + "age_s": int(max(age, 0)), + "rss_mb": _rss_mb(pid), + "start_time": starttime, + "cgroup": _cgroup(pid), + } + return out + + +# --- clasificare ----------------------------------------------------------- + +def _known_pids(state: dict) -> set[int]: + """Pid-urile pe care state.json le declara vii, cu verificare de PID reuse.""" + known: set[int] = set() + threads = (state or {}).get("threads") or {} + if not isinstance(threads, dict): + return known + for entry in threads.values(): + if not isinstance(entry, dict): + continue + pid = entry.get("pid") + if not isinstance(pid, int) or pid <= 0: + continue + expected = entry.get("pid_start_time") + if expected is not None: + st = _parse_stat(pid) + # Pid reciclat: alt proces poarta acum acelasi numar. Nu-l protejam, + # dar nici nu-l omoram automat — intra pe filtrele obisnuite. + if st is None or abs(st[1] - float(expected)) > 1.0: + continue + known.add(pid) + return known + + +def _descendants(roots: set[int], procs: dict[int, dict]) -> set[int]: + """Toti descendentii pid-urilor date (turul care ruleaza acum e intocmai asta).""" + children: dict[int, list[int]] = {} + for pid, info in procs.items(): + children.setdefault(info["ppid"], []).append(pid) + seen: set[int] = set() + stack = list(roots) + while stack: + pid = stack.pop() + for child in children.get(pid, ()): + if child not in seen: + seen.add(child) + stack.append(child) + return seen + + +def _ancestors_of_self(procs: dict[int, dict]) -> set[int]: + out: set[int] = set() + pid = os.getpid() + while pid and pid not in out: + out.add(pid) + info = procs.get(pid) + if not info: + break + pid = info["ppid"] + return out + + +def _is_claude(cmdline: str) -> bool: + for token in cmdline.split(): + if token.rsplit("/", 1)[-1] in CLAUDE_BASENAMES: + return True + return any(m in cmdline for m in CLAUDE_PATH_MARKERS) + + +def _is_protected(cmdline: str) -> bool: + low = cmdline.lower() + return any(marker.lower() in low for marker in NEVER_KILL) + + +def find_orphans(state: dict, min_age_s: int = 0) -> list[dict]: + """Procese ramase in urma, care nu apar in state.json. + + Doua familii: + 1. procese `claude` care nu sunt inregistrate in state.json; + 2. copii reparentati la init (ppid == 1) ramasi in cgroup-ul serviciului — + serverele si joburile pornite intr-un tur anterior. + + Intoarce [{"pid", "cmdline", "age_s", "rss_mb", ...}], sortat descrescator + dupa RSS (ce doare cel mai tare la OOM apare primul). + """ + procs = scan_processes() + known = _known_pids(state or {}) + protected = set(known) | _descendants(known, procs) | _ancestors_of_self(procs) + + orphans: list[dict] = [] + for pid, info in procs.items(): + if pid in protected: + continue + cmdline = info["cmdline"] + if not cmdline or _is_protected(cmdline): + continue + if info["age_s"] < min_age_s: + continue + + if _is_zombie(pid): + continue # zombi: nu consuma memorie si nu se poate omori + if _is_claude(cmdline): + reason = "proces claude neinregistrat in state.json" + elif info["ppid"] == 1 and SERVICE_CGROUP in info["cgroup"]: + reason = "copil reparentat la init, ramas in cgroup-ul serviciului" + else: + continue + + orphans.append({ + "pid": pid, + "cmdline": cmdline, + "age_s": info["age_s"], + "rss_mb": info["rss_mb"], + "ppid": info["ppid"], + "start_time": info["start_time"], + "reason": reason, + }) + + orphans.sort(key=lambda o: (-o["rss_mb"], -o["age_s"])) + return orphans + + +# --- oprire ---------------------------------------------------------------- + +def _still_same_process(orphan: dict) -> bool: + """Aparare impotriva PID reuse intre `find_orphans` si `kill_orphans`.""" + pid = orphan.get("pid") + if not isinstance(pid, int) or pid <= 0: + return False + st = _parse_stat(pid) + if st is None or _is_zombie(pid): + return False + expected = orphan.get("start_time") + if expected is None: + return True + return abs(st[1] - float(expected)) <= 1.0 + + +def kill_orphans(orphans: list[dict], dry_run: bool = True, grace_s: float = GRACE_S) -> list[dict]: + """Opreste orfanii. Implicit NU omoara nimic (dry_run=True). + + SIGTERM, apoi SIGKILL dupa `grace_s` daca procesul inca traieste. + Intoarce cate un rezultat per intrare: {"pid", "cmdline", "action", "detail"}. + action: "dry-run" | "terminated" | "killed" | "gone" | "skipped" | "error" + """ + results: list[dict] = [] + for orphan in orphans or []: + pid = orphan.get("pid") + entry = {"pid": pid, "cmdline": orphan.get("cmdline", ""), "action": "", "detail": ""} + + if not isinstance(pid, int) or not _still_same_process(orphan): + entry["action"] = "gone" + entry["detail"] = "procesul nu mai exista sau pid-ul a fost reciclat" + results.append(entry) + continue + + if pid == os.getpid(): + entry["action"] = "skipped" + entry["detail"] = "e chiar procesul curent" + results.append(entry) + continue + + if dry_run: + entry["action"] = "dry-run" + entry["detail"] = ( + f"s-ar trimite SIGTERM (varsta {orphan.get('age_s')}s, " + f"{orphan.get('rss_mb')}MB)" + ) + results.append(entry) + continue + + try: + os.kill(pid, signal.SIGTERM) + except ProcessLookupError: + entry["action"] = "gone" + entry["detail"] = "disparut inainte de SIGTERM" + results.append(entry) + continue + except Exception as exc: + entry["action"] = "error" + entry["detail"] = f"SIGTERM a esuat: {exc}" + results.append(entry) + continue + + deadline = time.time() + grace_s + while time.time() < deadline: + if not _still_same_process(orphan): + break + time.sleep(0.1) + + if not _still_same_process(orphan): + entry["action"] = "terminated" + entry["detail"] = "oprit cu SIGTERM" + results.append(entry) + continue + + try: + os.kill(pid, signal.SIGKILL) + entry["action"] = "killed" + entry["detail"] = f"nu a raspuns la SIGTERM in {grace_s:.0f}s, SIGKILL" + except ProcessLookupError: + entry["action"] = "terminated" + entry["detail"] = "oprit cu SIGTERM" + except Exception as exc: + entry["action"] = "error" + entry["detail"] = f"SIGKILL a esuat: {exc}" + results.append(entry) + + return results + + +# --- randare pentru Discord ------------------------------------------------ + +def format_report(orphans: list[dict], results: list[dict] | None = None) -> str: + """Text scurt pentru raspunsul comenzii `!cleanup` (sub 2000 caractere).""" + if not orphans: + return "Niciun proces orfan. Nimic de curatat." + + total_mb = sum(o.get("rss_mb", 0) for o in orphans) + lines = [f"**{len(orphans)} procese orfane** (~{total_mb:.0f} MB RSS in total)", "```"] + by_pid = {r.get("pid"): r for r in (results or [])} + for orphan in orphans[:15]: + cmd = str(orphan.get("cmdline", ""))[:70] + line = ( + f"pid={orphan.get('pid'):<7} {orphan.get('rss_mb'):>7} MB " + f"{orphan.get('age_s'):>7}s {cmd}" + ) + res = by_pid.get(orphan.get("pid")) + if res: + line += f"\n -> {res.get('action')}: {res.get('detail')}" + lines.append(line) + if len(orphans) > 15: + lines.append(f"... si inca {len(orphans) - 15}") + lines.append("```") + if not results: + lines.append("Rulare seaca. `!cleanup --force` le opreste efectiv.") + return "\n".join(lines) + + +def _load_state() -> dict: + """Citeste state.json pentru rularea din linia de comanda. Tolerant la lipsa.""" + if _config is not None and getattr(_config, "STATE_FILE", None): + path = pathlib.Path(_config.STATE_FILE) + else: + path = pathlib.Path(os.path.expanduser("~/.claude-discord/state.json")) + try: + import json + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +if __name__ == "__main__": + # Uz manual: python3 cleanup.py -> doar raporteaza + # python3 cleanup.py --force -> opreste efectiv + force = "--force" in sys.argv[1:] + found = find_orphans(_load_state()) + res = kill_orphans(found, dry_run=not force) + print(format_report(found, res)) diff --git a/proxmox/lxc171-claude-agent/discord-bridge/config.py b/proxmox/lxc171-claude-agent/discord-bridge/config.py new file mode 100644 index 0000000..f3d9c6a --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/config.py @@ -0,0 +1,123 @@ +"""Configuratie comuna a puntii Discord -> Claude Code. + +Citeste ~/.claude-discord/env (KEY=value, tolerant la comentarii si ghilimele). +Lane B si Lane C importa acest modul pentru cai si setari. +""" + +from __future__ import annotations + +import os +import pathlib + +# Directorul de baza poate fi mutat in teste prin CLAUDE_DISCORD_DIR. +_DEFAULT_DIR = pathlib.Path.home() / ".claude-discord" + +STATE_DIR: pathlib.Path = pathlib.Path(os.environ.get("CLAUDE_DISCORD_DIR") or _DEFAULT_DIR) +APPROVALS_DIR: pathlib.Path = STATE_DIR / "approvals" +STATE_FILE: pathlib.Path = STATE_DIR / "state.json" +LOG_DIR: pathlib.Path = STATE_DIR / "logs" +ENV_FILE: pathlib.Path = STATE_DIR / "env" +SETTINGS_FILE: pathlib.Path = STATE_DIR / "bot-settings.json" + +_env: dict[str, str] = {} + +# Valori implicite pentru cheile pe care le foloseste nucleul. +DEFAULTS: dict[str, str] = { + "MODEL_DEFAULT": "sonnet", + "COST_CAP_USD_DAY": "10.0", + "MAX_PROCS": "4", + "TURN_TIMEOUT_S": "900", + "IDLE_REAP_S": "1200", + "RATE_PER_USER_PER_MIN": "10", + "CLAUDE_BIN": "claude", + "DEFAULT_CWD": "/workspace", +} + + +def parse_env(text: str) -> dict[str, str]: + """Parseaza un fisier de tip KEY=value. Nu arunca niciodata.""" + out: dict[str, str] = {} + for raw in text.splitlines(): + line = raw.strip() + if not line or line.startswith("#"): + continue + if line.startswith("export "): + line = line[len("export "):].strip() + if "=" not in line: + continue + key, _, val = line.partition("=") + key = key.strip() + if not key: + continue + val = val.strip() + # taie comentariul de la finalul liniei doar daca valoarea nu e in ghilimele + if val[:1] not in ("'", '"'): + cut = val.find(" #") + if cut >= 0: + val = val[:cut].rstrip() + if len(val) >= 2 and val[0] == val[-1] and val[0] in ("'", '"'): + val = val[1:-1] + out[key] = val + return out + + +def reload(base_dir: str | os.PathLike | None = None) -> dict[str, str]: + """Recalculeaza caile si reciteste env-ul. Returneaza dictionarul incarcat.""" + global STATE_DIR, APPROVALS_DIR, STATE_FILE, LOG_DIR, ENV_FILE, SETTINGS_FILE, _env + if base_dir is None: + base_dir = os.environ.get("CLAUDE_DISCORD_DIR") or _DEFAULT_DIR + STATE_DIR = pathlib.Path(base_dir) + APPROVALS_DIR = STATE_DIR / "approvals" + STATE_FILE = STATE_DIR / "state.json" + LOG_DIR = STATE_DIR / "logs" + ENV_FILE = STATE_DIR / "env" + SETTINGS_FILE = STATE_DIR / "bot-settings.json" + try: + _env = parse_env(ENV_FILE.read_text(encoding="utf-8")) + except OSError: + _env = {} + return _env + + +def get(key: str, default=None): + """Env-ul de pe disc, apoi mediul procesului, apoi DEFAULTS, apoi `default`.""" + if key in _env: + return _env[key] + if key in os.environ: + return os.environ[key] + if default is not None: + return default + return DEFAULTS.get(key, default) + + +def get_float(key: str, default: float) -> float: + try: + return float(get(key, str(default))) + except (TypeError, ValueError): + return default + + +def get_int(key: str, default: int) -> int: + try: + return int(float(get(key, str(default)))) + except (TypeError, ValueError): + return default + + +def get_list(key: str, default=()) -> list[str]: + """Lista separata prin virgula sau spatiu (allowlist-uri).""" + raw = get(key, "") + if not raw: + return list(default) + return [p for p in raw.replace(",", " ").split() if p] + + +def ensure_dirs() -> None: + for d in (STATE_DIR, APPROVALS_DIR, LOG_DIR): + try: + d.mkdir(parents=True, exist_ok=True) + except OSError: + pass + + +reload() diff --git a/proxmox/lxc171-claude-agent/discord-bridge/limits.py b/proxmox/lxc171-claude-agent/discord-bridge/limits.py new file mode 100644 index 0000000..125cbdc --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/limits.py @@ -0,0 +1,164 @@ +"""Limite: procese vii, coada per fir, timeout de tur, rate limit per user, plafon de cost. + +T8. Motiv: 406 MB RSS per proces claude, pe un container cu istoric de OOM. +""" + +from __future__ import annotations + +import asyncio +import collections +import contextlib +import logging +import time +from typing import Callable + +import config + +log = logging.getLogger("discord-bridge.limits") + +try: # pragma: no cover - Lane C poate lipsi + import alerts # type: ignore +except ImportError: # pragma: no cover + class _NoAlerts: + @staticmethod + def alert(level: str, subject: str, body: str, dedup_key: str | None = None) -> None: + log.warning("alerta (%s) %s: %s", level, subject, body) + + alerts = _NoAlerts() # type: ignore + + +class LimitError(RuntimeError): + """Baza pentru refuzurile de limita (mesajul e afisabil in Discord).""" + + +class RateLimited(LimitError): + def __init__(self, retry_after: float): + self.retry_after = retry_after + super().__init__(f"prea multe mesaje; mai asteapta {retry_after:.0f}s") + + +class CostCapReached(LimitError): + def __init__(self, spent: float, cap: float): + self.spent, self.cap = spent, cap + super().__init__(f"plafonul de cost pe zi a fost atins ({spent:.2f} / {cap:.2f} USD)") + + +class Limits: + def __init__( + self, + store=None, + *, + max_procs: int | None = None, + turn_timeout: float | None = None, + rate_per_min: int | None = None, + cost_cap: float | None = None, + clock: Callable[[], float] = time.monotonic, + alerter: Callable | None = None, + ): + self.store = store + self.max_procs = max_procs if max_procs is not None else config.get_int("MAX_PROCS", 4) + self.turn_timeout = ( + turn_timeout if turn_timeout is not None else config.get_float("TURN_TIMEOUT_S", 900.0) + ) + self.rate_per_min = ( + rate_per_min if rate_per_min is not None else config.get_int("RATE_PER_USER_PER_MIN", 10) + ) + self.cost_cap = cost_cap if cost_cap is not None else config.get_float("COST_CAP_USD_DAY", 10.0) + self.clock = clock + self._alert = alerter or alerts.alert + self._slots = asyncio.Semaphore(self.max_procs) + self._thread_locks: dict[str, asyncio.Lock] = {} + self._hits: dict[str, collections.deque[float]] = {} + self._local_cost = 0.0 # folosit doar daca nu avem store + self._cap_alerted = False + + # ------------------------------------------------------------- procese + @property + def free_slots(self) -> int: + return self._slots._value # noqa: SLF001 - diagnostic pentru !status + + @contextlib.asynccontextmanager + async def process_slot(self): + """Maxim `max_procs` procese claude vii simultan; al 5-lea fir asteapta.""" + await self._slots.acquire() + try: + yield + finally: + self._slots.release() + + def thread_lock(self, thread_id: str) -> asyncio.Lock: + """Coada per fir: un singur tur odata pe acelasi fir.""" + key = str(thread_id) + lock = self._thread_locks.get(key) + if lock is None: + lock = self._thread_locks[key] = asyncio.Lock() + return lock + + def queued(self, thread_id: str) -> bool: + return self.thread_lock(thread_id).locked() + + # ---------------------------------------------------------- rate limit + def check_rate(self, user_id: str, now: float | None = None) -> None: + """Fereastra glisanta de 60s per utilizator. Ridica RateLimited.""" + now = self.clock() if now is None else now + q = self._hits.setdefault(str(user_id), collections.deque()) + while q and now - q[0] >= 60.0: + q.popleft() + if len(q) >= self.rate_per_min: + raise RateLimited(60.0 - (now - q[0])) + q.append(now) + + # ---------------------------------------------------------------- cost + def cost_today(self) -> float: + if self.store is not None: + with contextlib.suppress(Exception): + return float(self.store.cost_today()) + return self._local_cost + + def stopped(self) -> bool: + """Plafonul e evaluat pe ziua curenta; reset-ul zilnic vine din `cost.day`.""" + return self.cost_today() >= self.cost_cap + + def cost_remaining(self) -> float: + return max(0.0, self.cost_cap - self.cost_today()) + + def check_cost(self) -> None: + if self.stopped(): + raise CostCapReached(self.cost_today(), self.cost_cap) + + def record_cost(self, usd: float, thread_id: str | None = None) -> float: + try: + usd = float(usd) + except (TypeError, ValueError): + usd = 0.0 + if self.store is not None: + total = self.store.add_cost(thread_id, usd) + else: + self._local_cost = round(self._local_cost + usd, 6) + total = self._local_cost + if total >= self.cost_cap and not self._cap_alerted: + self._cap_alerted = True + with contextlib.suppress(Exception): + self._alert( + "CRITICAL", + "plafon de cost atins", + f"Cheltuit azi: {total:.2f} USD, plafon {self.cost_cap:.2f}. Puntea nu mai accepta tururi.", + "cost-cap", + ) + elif total < self.cost_cap: + self._cap_alerted = False + return total + + # --------------------------------------------------------------- admit + def admit(self, user_id: str) -> None: + """Verificarile ieftine, inainte de a pune firul in coada. Ridica LimitError.""" + self.check_cost() + self.check_rate(user_id) + + @contextlib.asynccontextmanager + async def turn(self, thread_id: str, user_id: str): + """Un tur complet: admis -> coada firului -> slot de proces.""" + self.admit(user_id) + async with self.thread_lock(thread_id): + async with self.process_slot(): + yield self.turn_timeout diff --git a/proxmox/lxc171-claude-agent/discord-bridge/ops/claude-discord.service b/proxmox/lxc171-claude-agent/discord-bridge/ops/claude-discord.service new file mode 100644 index 0000000..a206f47 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/ops/claude-discord.service @@ -0,0 +1,61 @@ +# Unit systemd de UTILIZATOR pentru puntea Discord -> Claude Code (LXC 171). +# +# Se instaleaza in ~/.config/systemd/user/claude-discord.service (vezi install.sh). +# Rulare ca utilizatorul `claude`, NU ca root: puntea are exact accesul pe care il +# are omul la terminal — decizie de arhitectura, nu scapare (vezi README, Securitate). +# +# systemctl --user daemon-reload +# systemctl --user enable --now claude-discord +# systemctl --user status claude-discord +# journalctl --user -u claude-discord -f + +[Unit] +Description=Punte Discord -> Claude Code (LXC 171 claude-agent) +Documentation=file:///workspace/romfastsql/proxmox/lxc171-claude-agent/discord-bridge/README.md +After=network-online.target +Wants=network-online.target + +# Crash loop: dupa 5 porniri esuate in 300s systemd renunta si lasa unitul in +# `failed`, in loc sa se invarta la nesfarsit. Botul trimite alerta pe email +# inainte sa moara (alerts.alert_crash_loop). +StartLimitIntervalSec=300 +StartLimitBurst=5 + +[Service] +Type=simple +WorkingDirectory=%h/.claude-discord + +# Token-ul Discord si allowlist-ul stau in fisier 0600, nu in unit. +EnvironmentFile=%h/.claude-discord/env +Environment=PYTHONUNBUFFERED=1 +# CLI-ul `claude` e instalat prin nvm; PATH-ul unui unit de utilizator nu-l contine. +Environment=PATH=%h/.nvm/versions/node/v20.19.6/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin + +ExecStartPre=/bin/mkdir -p %h/.claude-discord/logs +ExecStart=%h/.claude-discord/venv/bin/python /workspace/romfastsql/proxmox/lxc171-claude-agent/discord-bridge/bot.py + +# ESENTIAL: la stop/restart omoara TOT cgroup-ul, nu doar procesul principal. +# Fara asta procesele `claude` (406 MB RSS masurat) raman in urma si umplu +# containerul. Nu prinde totusi ce s-a desprins din cgroup — pentru aia exista +# comanda `!cleanup` (cleanup.py). +KillMode=control-group +KillSignal=SIGTERM +TimeoutStopSec=30 + +Restart=on-failure +RestartSec=5 +# Backoff exponential: 5s, 10s, 20s, ... plafonat la 120s (systemd >= 254). +RestartSteps=5 +RestartMaxDelaySec=120 + +# Containerul are istoric de OOM (incident 2026-06-24). MemoryHigh strange +# procesul prin reclaim inainte sa se ajunga la OOM-kill la MemoryMax. +MemoryHigh=3G +MemoryMax=6G + +StandardOutput=append:%h/.claude-discord/logs/bot.log +StandardError=append:%h/.claude-discord/logs/bot.log +SyslogIdentifier=claude-discord + +[Install] +WantedBy=default.target diff --git a/proxmox/lxc171-claude-agent/discord-bridge/ops/env.example b/proxmox/lxc171-claude-agent/discord-bridge/ops/env.example new file mode 100644 index 0000000..2d39847 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/ops/env.example @@ -0,0 +1,45 @@ +# Configurarea puntii Discord -> Claude Code. +# +# install.sh copiaza acest fisier in ~/.claude-discord/env cu drepturi 0600. +# Formatul e cel cerut de systemd EnvironmentFile: CHEIE=valoare, fara `export`, +# fara ghilimele in jurul valorii, fara expandare de variabile. +# +# DUPA COPIERE COMPLETEAZA MANUAL cel putin DISCORD_TOKEN si allowlist-ul. + +# --- Discord --------------------------------------------------------------- +# Token-ul botului: Discord Developer Portal -> aplicatia ta -> Bot -> Reset Token. +# Se vede o singura data. Daca ajunge in git, invalideaza-l imediat din portal. +DISCORD_TOKEN= + +# Allowlist. Liste separate prin virgula, cu ID-uri numerice (Discord -> Advanced -> +# Developer Mode, apoi click dreapta -> Copy ID). Gol = nimic permis (fail-closed). +DISCORD_GUILD_IDS= +DISCORD_CHANNEL_IDS= +DISCORD_USER_IDS= + +# --- Model si cost --------------------------------------------------------- +# Modelul implicit al firelor noi. `!model opus` il schimba per fir. +CLAUDE_MODEL=sonnet +# Plafon de cost pe zi, in USD. La atingere botul refuza tururi noi si alerteaza. +# Reper masurat: un tur banal pe opus a costat $0.1547. +COST_CAP_USD_DAY=5.00 + +# --- Limite ---------------------------------------------------------------- +# Maxim de procese `claude` vii simultan (406 MB RSS fiecare, masurat). +MAX_LIVE_PROCESSES=4 +# Timeout per tur, secunde. +TURN_TIMEOUT_S=900 +# Reaper: dupa cate secunde de inactivitate se opreste procesul unui fir. +IDLE_REAP_S=1200 +# Cat asteapta hook-ul de confirmare o apasare pe Allow/Deny, secunde. +# CONSTRANGERE: trebuie sa ramana MAI MIC decat `timeout` din bot-settings.json +# (acum 330), altfel CLI-ul taie hook-ul inainte ca acesta sa refuze curat. +CLAUDE_DISCORD_APPROVAL_TIMEOUT=300 + +# --- Alerte (alerts.py) ---------------------------------------------------- +# Destinatarul emailurilor de alerta. Aceeasi conventie ca restul repo-ului +# (vezi proxmox/vm109-windows-dr/scripts/pveelite-down-alert.sh): default root. +ALERT_RECIPIENT=root + +# --- Directorul de lucru implicit ----------------------------------------- +DEFAULT_CWD=/workspace diff --git a/proxmox/lxc171-claude-agent/discord-bridge/ops/install.sh b/proxmox/lxc171-claude-agent/discord-bridge/ops/install.sh new file mode 100755 index 0000000..8b4e44e --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/ops/install.sh @@ -0,0 +1,151 @@ +#!/usr/bin/env bash +# install.sh — instaleaza puntea Discord -> Claude Code pe LXC 171 (claude-agent). +# +# IDEMPOTENT: se poate rula de cate ori vrei. Nu suprascrie niciodata +# ~/.claude-discord/env (acolo sta token-ul completat de om). +# +# ./ops/install.sh # instaleaza / actualizeaza, NU porneste serviciul +# ./ops/install.sh --start # in plus porneste serviciul (cere DISCORD_TOKEN completat) +# +# Ce face: +# 1. ~/.claude-discord/ cu 0700 si logs/ +# 2. env 0600 din ops/env.example (doar daca lipseste) +# 3. venv + dependinte din requirements.txt +# 4. loginctl enable-linger (serviciul de utilizator trebuie sa supravietuiasca logout-ului) +# 5. symlink unit -> ~/.config/systemd/user/claude-discord.service +# 6. intrare de crontab pentru logrotate +# 7. systemd-analyze verify + enable +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SRC="$(dirname "$HERE")" # .../discord-bridge +STATE_DIR="$HOME/.claude-discord" +UNIT_DIR="$HOME/.config/systemd/user" +UNIT_NAME="claude-discord.service" +DO_START=0 +[ "${1:-}" = "--start" ] && DO_START=1 + +info() { printf ' \033[32m*\033[0m %s\n' "$*"; } +warn() { printf ' \033[33m!\033[0m %s\n' "$*"; } +die() { printf ' \033[31mX\033[0m %s\n' "$*" >&2; exit 1; } + +[ "$(id -u)" -eq 0 ] && die "NU rula ca root. Puntea e un serviciu de utilizator (claude)." + +echo "== Punte Discord -> Claude Code: instalare ==" +echo " sursa: $SRC" + +# --- 1. director de stare -------------------------------------------------- +mkdir -p "$STATE_DIR/logs" "$STATE_DIR/approvals" +chmod 0700 "$STATE_DIR" +info "director de stare: $STATE_DIR (0700)" + +# --- 2. env ---------------------------------------------------------------- +if [ -f "$STATE_DIR/env" ]; then + chmod 0600 "$STATE_DIR/env" + info "env exista deja, nu se atinge" +else + cp "$HERE/env.example" "$STATE_DIR/env" + chmod 0600 "$STATE_DIR/env" + info "env creat din env.example (0600) — COMPLETEAZA-L MANUAL" +fi +if ! grep -qE '^DISCORD_TOKEN=.+' "$STATE_DIR/env" 2>/dev/null; then + warn "DISCORD_TOKEN e gol in $STATE_DIR/env — botul nu poate porni" +fi +# Sunt fisiere versionate langa cod: env-ul real NU trebuie sa ajunga in git. +if [ -e "$SRC/env" ]; then + warn "exista $SRC/env in arborele de cod — muta-l in $STATE_DIR/env si sterge-l" +fi + +# --- 3. venv --------------------------------------------------------------- +if [ ! -x "$STATE_DIR/venv/bin/python" ]; then + python3 -m venv "$STATE_DIR/venv" || die "crearea venv a esuat" + info "venv creat: $STATE_DIR/venv" +else + info "venv exista deja" +fi +"$STATE_DIR/venv/bin/python" -m pip install --quiet --upgrade pip >/dev/null 2>&1 +if [ -f "$SRC/requirements.txt" ]; then + if "$STATE_DIR/venv/bin/python" -m pip install --quiet -r "$SRC/requirements.txt"; then + info "dependinte instalate din requirements.txt" + else + warn "instalarea dependintelor a esuat (retea?) — reia cu: $STATE_DIR/venv/bin/pip install -r $SRC/requirements.txt" + fi +else + warn "requirements.txt lipseste inca (il aduce Lane A) — sari peste dependinte" +fi + +# --- 4. linger ------------------------------------------------------------- +# Fara linger, serviciul de utilizator moare la ultimul logout si nu porneste la boot. +if [ "$(loginctl show-user "$USER" -p Linger --value 2>/dev/null)" = "yes" ]; then + info "linger deja activ pentru $USER" +else + if loginctl enable-linger "$USER" 2>/dev/null; then + info "linger activat pentru $USER" + elif sudo -n loginctl enable-linger "$USER" 2>/dev/null; then + info "linger activat pentru $USER (prin sudo)" + else + warn "nu am putut activa linger; ruleaza manual: sudo loginctl enable-linger $USER" + fi +fi + +# --- 5. unit --------------------------------------------------------------- +mkdir -p "$UNIT_DIR" +# symlink: unitul ramane versionat in repo, actualizarile vin cu `git pull` +ln -sfn "$HERE/$UNIT_NAME" "$UNIT_DIR/$UNIT_NAME" +info "unit legat: $UNIT_DIR/$UNIT_NAME -> $HERE/$UNIT_NAME" +systemctl --user daemon-reload 2>/dev/null || warn "daemon-reload a esuat (sesiune fara systemd de utilizator?)" + +if systemd-analyze verify "$UNIT_DIR/$UNIT_NAME" 2>&1 | grep -vE 'Unknown key|^$' | grep -q .; then + systemd-analyze verify "$UNIT_DIR/$UNIT_NAME" 2>&1 | sed 's/^/ /' + warn "systemd-analyze verify a raportat probleme (vezi mai sus)" +else + info "systemd-analyze verify: curat" +fi + +# --- 6. logrotate ---------------------------------------------------------- +CRON_LINE="10 4 * * * /usr/sbin/logrotate -s $STATE_DIR/logrotate.state $HERE/logrotate.conf" +if crontab -l 2>/dev/null | grep -Fq "$HERE/logrotate.conf"; then + info "crontab logrotate exista deja" +else + # Prin fisier temporar, nu prin pipe: cu `pipefail` un SIGPIPE de la `crontab -` + # ar face pasul sa para esuat chiar cand a reusit. + TMP_CRON="$(mktemp)" + crontab -l 2>/dev/null > "$TMP_CRON" + echo "$CRON_LINE" >> "$TMP_CRON" + if crontab "$TMP_CRON"; then + info "crontab logrotate adaugat (zilnic 04:10)" + else + warn "nu am putut scrie crontab-ul; adauga manual: $CRON_LINE" + fi + rm -f "$TMP_CRON" +fi + +# --- 7. mail --------------------------------------------------------------- +if command -v mail >/dev/null 2>&1; then + info "binarul mail: $(command -v mail)" +else + warn "binarul \`mail\` lipseste — alertele se degradeaza la scriere in log." + warn "instaleaza-l cu: sudo apt-get install -y bsd-mailx" +fi + +# --- 8. enable / start ----------------------------------------------------- +systemctl --user enable "$UNIT_NAME" >/dev/null 2>&1 \ + && info "serviciu enabled (porneste la boot)" \ + || warn "enable a esuat" + +if [ "$DO_START" -eq 1 ]; then + if [ ! -f "$SRC/bot.py" ]; then + die "bot.py lipseste in $SRC — nu pornesc nimic" + fi + if ! grep -qE '^DISCORD_TOKEN=.+' "$STATE_DIR/env"; then + die "DISCORD_TOKEN gol — completeaza $STATE_DIR/env inainte de --start" + fi + systemctl --user restart "$UNIT_NAME" && info "serviciu pornit" + systemctl --user --no-pager status "$UNIT_NAME" | sed 's/^/ /' +else + echo + echo " Serviciul NU a fost pornit (intentionat)." + echo " Dupa ce completezi $STATE_DIR/env: $0 --start" +fi + +echo "== gata ==" diff --git a/proxmox/lxc171-claude-agent/discord-bridge/ops/logrotate.conf b/proxmox/lxc171-claude-agent/discord-bridge/ops/logrotate.conf new file mode 100644 index 0000000..7851ca2 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/ops/logrotate.conf @@ -0,0 +1,26 @@ +# Rotatie pentru logurile puntii Discord -> Claude Code. +# +# Sunt loguri de UTILIZATOR (~/.claude-discord/logs/), nu /var/log, deci NU intra +# in /etc/logrotate.d — logrotate-ul de sistem ruleaza ca root si ar schimba +# proprietarul fisierelor. Se ruleaza cu starea in home-ul utilizatorului: +# +# logrotate -s ~/.claude-discord/logrotate.state \ +# /workspace/romfastsql/proxmox/lxc171-claude-agent/discord-bridge/ops/logrotate.conf +# +# install.sh il pune in crontab-ul utilizatorului, zilnic la 04:10. +# +# Nota: unitul scrie cu `append:`, adica tine descriptorul deschis. De aceea +# `copytruncate` — fara el, dupa rotatie botul ar continua sa scrie in inodul +# vechi si logul nou ar ramane gol. + +/home/claude/.claude-discord/logs/*.log { + daily + rotate 14 + size 20M + missingok + notifempty + compress + delaycompress + copytruncate + create 0640 claude claude +} diff --git a/proxmox/lxc171-claude-agent/discord-bridge/pytest.ini b/proxmox/lxc171-claude-agent/discord-bridge/pytest.ini new file mode 100644 index 0000000..8ca0b1d --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/pytest.ini @@ -0,0 +1,9 @@ +[pytest] +testpaths = tests +addopts = -m "not e2e" -q +markers = + e2e: atinge CLI-ul `claude` real (lent, exclus implicit) +asyncio_mode = auto +asyncio_default_fixture_loop_scope = function +filterwarnings = + ignore::DeprecationWarning diff --git a/proxmox/lxc171-claude-agent/discord-bridge/render.py b/proxmox/lxc171-claude-agent/discord-bridge/render.py new file mode 100644 index 0000000..9b08e6e --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/render.py @@ -0,0 +1,209 @@ +"""Randare pentru Discord: chunker + un SINGUR loop de editare per CANAL. + +T10. Bucket-ul de rate limit al Discord e per canal, deci loop-ul e per canal, coalescent, +cu interval adaptiv 1s -> 5s. Modulul NU importa discord.py: I/O-ul il face adaptorul. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import re +import time +from dataclasses import dataclass, field +from typing import Any, Awaitable, Callable + +log = logging.getLogger("discord-bridge.render") + +MAX_MSG = 2000 +ATTACH_OVER = 6000 +_FENCE = re.compile(r"^```([A-Za-z0-9_+.\-]*)\s*$") + + +@dataclass +class Attachment: + filename: str + content: str + preview: str + + kind: str = "attachment" + + +@dataclass +class Chunks: + parts: list[str] = field(default_factory=list) + kind: str = "chunks" + + +def split_message(text: str, limit: int = MAX_MSG, attach_over: int = ATTACH_OVER, + filename: str = "raspuns.md") -> Chunks | Attachment: + """Chunker-ul, intr-o singura functie. + + - peste `attach_over` caractere -> obiect Attachment (adaptorul il urca in Discord) + - altfel bucati de cel mult `limit` caractere, fara sa rupa un fence ``` peste granita: + fence-ul se inchide la finalul bucatii si se redeschide cu acelasi limbaj in urmatoarea. + """ + text = text or "" + if len(text) > attach_over: + head = text[: limit - 200] + return Attachment( + filename=filename, + content=text, + preview=head + "\n...\n(raspuns lung, atasat integral)", + ) + + parts: list[str] = [] + cur: list[str] = [] + cur_len = 0 + fence_lang: str | None = None # limbajul fence-ului deschis in bucata curenta + + def flush(reopen: bool) -> None: + nonlocal cur, cur_len + if not cur: + return + body = "\n".join(cur) + if fence_lang is not None: + body += "\n```" + parts.append(body) + cur = [f"```{fence_lang}"] if (reopen and fence_lang is not None) else [] + cur_len = sum(len(x) + 1 for x in cur) + + for line in text.split("\n"): + # o linie singura mai lunga decat limita se taie dur + pieces = [line] if len(line) <= limit - 10 else [ + line[i: i + limit - 10] for i in range(0, len(line), limit - 10) + ] + for piece in pieces: + need = len(piece) + 1 + (4 if fence_lang is not None else 0) + if cur and cur_len + need > limit: + flush(reopen=True) + cur.append(piece) + cur_len += len(piece) + 1 + m = _FENCE.match(piece.strip()) + if m: + fence_lang = None if fence_lang is not None else (m.group(1) or "") + flush(reopen=False) + if not parts: + parts = [""] + return Chunks(parts=parts) + + +class ChannelEditLoop: + """Un loop de editare per canal. Coalescent: conteaza doar ultimul text per tinta.""" + + def __init__( + self, + channel_id: str, + edit_fn: Callable[[Any, str], Awaitable[None]], + *, + min_interval: float = 1.0, + max_interval: float = 5.0, + clock: Callable[[], float] = time.monotonic, + ): + self.channel_id = str(channel_id) + self.edit_fn = edit_fn + self.min_interval = min_interval + self.max_interval = max_interval + self.interval = min_interval + self.clock = clock + self.pending: dict[Any, str] = {} + self.edits = 0 + self.coalesced = 0 + self.errors = 0 + self._wake = asyncio.Event() + self._task: asyncio.Task | None = None + self._stop = False + + # ------------------------------------------------------------ interfata + def queue(self, target: Any, text: str) -> None: + """Ultima versiune castiga; actualizarile intermediare se pierd intentionat.""" + if target in self.pending: + self.coalesced += 1 + self.pending[target] = text + self._wake.set() + + def note_rate_limited(self) -> None: + """Adaptorul semnaleaza un 429: urcam direct la intervalul maxim.""" + self.interval = self.max_interval + + def start(self) -> asyncio.Task: + if self._task is None or self._task.done(): + self._stop = False + self._task = asyncio.create_task(self._run()) + return self._task + + async def stop(self, flush: bool = True) -> None: + self._stop = True + self._wake.set() + if self._task is not None: + with contextlib.suppress(asyncio.CancelledError, Exception): + await asyncio.wait_for(self._task, 5.0) + self._task = None + if flush: + await self.flush() + + async def flush(self) -> None: + """Trimite imediat tot ce e in asteptare (finalul unui tur).""" + while self.pending: + target, text = next(iter(self.pending.items())) + del self.pending[target] + await self._edit(target, text) + + # -------------------------------------------------------------- interne + async def _edit(self, target: Any, text: str) -> None: + try: + await self.edit_fn(target, text) + self.edits += 1 + except Exception: + self.errors += 1 + log.warning("editare esuata pe canalul %s", self.channel_id, exc_info=True) + + async def _run(self) -> None: + while not self._stop: + if not self.pending: + self._wake.clear() + with contextlib.suppress(asyncio.TimeoutError): + await asyncio.wait_for(self._wake.wait(), self.max_interval) + # liniste -> coboram spre intervalul minim + self.interval = max(self.min_interval, self.interval / 1.5) + continue + batch = self.pending + self.pending = {} + for target, text in batch.items(): + await self._edit(target, text) + if len(batch) > 1: + self.interval = min(self.max_interval, self.interval * 1.5) + await asyncio.sleep(self.interval) + + +class RenderManager: + """Un singur ChannelEditLoop per canal.""" + + def __init__(self, edit_fn: Callable[[Any, str], Awaitable[None]], **kw): + self.edit_fn = edit_fn + self.kw = kw + self.loops: dict[str, ChannelEditLoop] = {} + + def loop_for(self, channel_id: str) -> ChannelEditLoop: + key = str(channel_id) + loop = self.loops.get(key) + if loop is None: + loop = self.loops[key] = ChannelEditLoop(key, self.edit_fn, **self.kw) + loop.start() + return loop + + def queue(self, channel_id: str, target: Any, text: str) -> None: + self.loop_for(channel_id).queue(target, text) + + async def stop_all(self) -> None: + for loop in list(self.loops.values()): + await loop.stop() + self.loops.clear() + + +def footer(model: str, duration_ms: int, cost_usd: float, thread_total: float) -> str: + """Subsolul cerut la T11: model, durata, cost tur, cost cumulat pe fir.""" + return ( + f"-# {model} · {duration_ms / 1000:.1f}s · ${cost_usd:.4f} tur · ${thread_total:.4f} fir" + ) diff --git a/proxmox/lxc171-claude-agent/discord-bridge/requirements-dev.txt b/proxmox/lxc171-claude-agent/discord-bridge/requirements-dev.txt new file mode 100644 index 0000000..7df8cd2 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest>=8.0 +pytest-asyncio>=0.24 diff --git a/proxmox/lxc171-claude-agent/discord-bridge/requirements.txt b/proxmox/lxc171-claude-agent/discord-bridge/requirements.txt new file mode 100644 index 0000000..dabc559 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/requirements.txt @@ -0,0 +1,2 @@ +# Puntea Discord -> Claude Code. Nucleul e stdlib; discord.py e doar pentru adaptor. +discord.py==2.4.0 diff --git a/proxmox/lxc171-claude-agent/discord-bridge/runner.py b/proxmox/lxc171-claude-agent/discord-bridge/runner.py new file mode 100644 index 0000000..788957b --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/runner.py @@ -0,0 +1,398 @@ +"""Proces `claude` PERSISTENT per fir, alimentat pe stdin cu --input-format stream-json. + +De ce persistent (T4): asa se poate face steering mid-tur -- un mesaj trimis in timpul +unui tool call lung ajunge la model si schimba raspunsul final (verificat in plan). +Totul e asyncio: NICIODATA readline blocant. stderr are task propriu si buffer circular. +""" + +from __future__ import annotations + +import asyncio +import collections +import contextlib +import json +import logging +import os +import time +import uuid +from dataclasses import dataclass, field +from typing import Any, AsyncIterator, Awaitable, Callable + +import config +import stream as stream_mod + +log = logging.getLogger("discord-bridge.runner") + +# linii mari (tool results) -> limita generoasa pe StreamReader +_READ_LIMIT = 16 * 1024 * 1024 +STDERR_TAIL = 50 + + +class TurnFailed(RuntimeError): + """Turul nu s-a putut duce la capat (proces mort, EOF inainte de result).""" + + +class TurnTimeout(TurnFailed): + """Turul a depasit timpul maxim.""" + + +@dataclass +class SessionRestarted: + """Eveniment sintetic: procesul murise, l-am repornit cu --resume.""" + thread_id: str + sid: str | None + reason: str = "procesul claude murise" + + @property + def text(self) -> str: + return f"sesiune repornita ({self.reason})" + + +@dataclass +class TurnOutcome: + result: stream_mod.Result | None + restarted: bool = False + events: list[Any] = field(default_factory=list) + + +def build_cmd( + claude_bin: str | list[str], + model: str, + sid: str | None = None, + settings: str | os.PathLike | None = None, + extra: tuple[str, ...] = (), +) -> list[str]: + """Comanda exacta din plan; `--resume ` doar la respawn.""" + base = [claude_bin] if isinstance(claude_bin, str) else list(claude_bin) + cmd = base + [ + "-p", + "--input-format", "stream-json", + "--output-format", "stream-json", + "--verbose", + "--permission-mode", "bypassPermissions", + ] + if settings: + cmd += ["--settings", os.fspath(settings)] + cmd += ["--model", model, "--autocompact", "auto"] + if sid: + cmd += ["--resume", sid] + cmd += list(extra) + return cmd + + +def user_message(text: str) -> str: + return json.dumps( + {"type": "user", "message": {"role": "user", "content": [{"type": "text", "text": text}]}}, + ensure_ascii=False, + ) + + +class ClaudeProcess: + """Un proces claude viu, legat de un fir Discord.""" + + def __init__( + self, + thread_id: str, + cwd: str, + model: str, + sid: str | None = None, + *, + claude_bin: str | list[str] | None = None, + settings: str | os.PathLike | None = None, + env: dict[str, str] | None = None, + on_pid: Callable[[str, int | None], Any] | None = None, + ): + self.thread_id = str(thread_id) + self.cwd = cwd + self.model = model + self.sid = sid + self.claude_bin = claude_bin or config.get("CLAUDE_BIN", "claude") + self.settings = settings + self.env = env + self.on_pid = on_pid + self.proc: asyncio.subprocess.Process | None = None + self.stderr_buf: collections.deque[str] = collections.deque(maxlen=STDERR_TAIL) + self.last_active = time.monotonic() + self.inflight = False + self.started_turns = 0 + self.restarts = 0 + self._stderr_task: asyncio.Task | None = None + self._parser = stream_mod.StreamParser() + self._start_lock = asyncio.Lock() + + # --------------------------------------------------------------- proces + @property + def alive(self) -> bool: + return self.proc is not None and self.proc.returncode is None + + @property + def pid(self) -> int | None: + return self.proc.pid if self.proc is not None else None + + async def start(self) -> None: + async with self._start_lock: + if self.alive: + return + cmd = build_cmd(self.claude_bin, self.model, self.sid, self.settings) + log.info("pornesc claude pentru firul %s: %s", self.thread_id, " ".join(cmd)) + env = dict(os.environ) + # Lane B: hook-ul PreToolUse afla firul Discord din mediul procesului claude + env["CLAUDE_DISCORD_THREAD_ID"] = self.thread_id + if self.sid: + env["CLAUDE_DISCORD_SESSION_ID"] = self.sid + if self.env: + env.update(self.env) + self.proc = await asyncio.create_subprocess_exec( + *cmd, + stdin=asyncio.subprocess.PIPE, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=self.cwd, + env=env, + limit=_READ_LIMIT, + ) + self.last_active = time.monotonic() + self._stderr_task = asyncio.create_task(self._pump_stderr()) + if self.on_pid: + res = self.on_pid(self.thread_id, self.proc.pid) + if asyncio.iscoroutine(res): + await res + + async def _pump_stderr(self) -> None: + assert self.proc is not None and self.proc.stderr is not None + try: + async for raw in self.proc.stderr: + line = raw.decode("utf-8", "replace").rstrip("\n") + if line: + self.stderr_buf.append(line) + except (asyncio.CancelledError, ValueError): + raise + except Exception as exc: # pragma: no cover + log.debug("stderr pump s-a oprit: %s", exc) + + def stderr_tail(self, n: int = STDERR_TAIL) -> list[str]: + """Ultimele linii de stderr, pentru `!status`.""" + return list(self.stderr_buf)[-n:] + + async def stop(self, grace: float = 5.0) -> None: + task, self._stderr_task = self._stderr_task, None + proc, self.proc = self.proc, None + if proc is not None and proc.returncode is None: + with contextlib.suppress(ProcessLookupError): + if proc.stdin and not proc.stdin.is_closing(): + proc.stdin.close() + proc.terminate() + try: + await asyncio.wait_for(proc.wait(), grace) + except (asyncio.TimeoutError, ProcessLookupError): + with contextlib.suppress(ProcessLookupError): + proc.kill() + with contextlib.suppress(Exception): + await proc.wait() + if task is not None: + task.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await task + if self.on_pid: + res = self.on_pid(self.thread_id, None) + if asyncio.iscoroutine(res): + await res + + # ------------------------------------------------------------- mesaje + async def send(self, text: str) -> None: + """Trimite un mesaj de utilizator pe stdin (si mid-tur: asta e steering-ul).""" + if not self.alive or self.proc is None or self.proc.stdin is None: + raise TurnFailed("procesul claude nu e viu") + self.proc.stdin.write((user_message(text) + "\n").encode("utf-8")) + await self.proc.stdin.drain() + self.last_active = time.monotonic() + + async def _stdout_lines(self) -> AsyncIterator[str]: + assert self.proc is not None and self.proc.stdout is not None + reader = self.proc.stdout + while True: + try: + raw = await reader.readline() + except ValueError: # linie peste limita: nu doboram turul + log.warning("linie de stdout peste limita, ignorata") + continue + if not raw: + return + yield raw.decode("utf-8", "replace") + + async def run_turn( + self, + prompt: str, + on_event: Callable[[Any], Awaitable[None]] | None = None, + timeout: float | None = None, + ) -> TurnOutcome: + """Un tur complet: (re)porneste procesul daca trebuie, trimite promptul, + consuma stream-ul pana la `result`.""" + restarted = False + if not self.alive: + if self.started_turns: + restarted = True + self.restarts += 1 + await self.start() + if timeout is None: + timeout = config.get_float("TURN_TIMEOUT_S", 900.0) + self.started_turns += 1 + self.inflight = True + events: list[Any] = [] + try: + if restarted: + ev = SessionRestarted(self.thread_id, self.sid) + events.append(ev) + if on_event: + await on_event(ev) + await self.send(prompt) + outcome = await asyncio.wait_for(self._consume(on_event, events), timeout) + return TurnOutcome(result=outcome, restarted=restarted, events=events) + except asyncio.TimeoutError: + await self.stop() + raise TurnTimeout(f"turul a depasit {timeout:.0f}s si a fost oprit") from None + except stream_mod.StreamEOFError as exc: + tail = "\n".join(self.stderr_tail(10)) + await self.stop() + raise TurnFailed(f"{exc}; stderr:\n{tail}") from None + finally: + self.inflight = False + self.last_active = time.monotonic() + + async def _consume(self, on_event, events: list) -> stream_mod.Result: + parser = stream_mod.StreamParser() + self._parser = parser + async for ev in parser.aiter_events(self._stdout_lines()): + if isinstance(ev, stream_mod.SystemInit) and ev.session_id: + self.sid = ev.session_id + events.append(ev) + if on_event: + await on_event(ev) + if isinstance(ev, stream_mod.Result): + return ev + raise stream_mod.StreamEOFError("stream inchis fara result") # pragma: no cover + + +class RunnerManager: + """Registrul de procese vii + reaper-ul de inactivitate (20 min implicit).""" + + def __init__( + self, + store=None, + *, + claude_bin: str | list[str] | None = None, + settings: str | os.PathLike | None = None, + idle_s: float | None = None, + poll_s: float = 30.0, + is_inflight: Callable[[str], bool] | None = None, + ): + self.store = store + self.claude_bin = claude_bin or config.get("CLAUDE_BIN", "claude") + self.settings = settings + self.idle_s = idle_s if idle_s is not None else config.get_float("IDLE_REAP_S", 1200.0) + self.poll_s = poll_s + self._is_inflight = is_inflight + self.procs: dict[str, ClaudeProcess] = {} + self._reaper: asyncio.Task | None = None + + # ------------------------------------------------------------ procese + def _on_pid(self, thread_id: str, pid: int | None): + if self.store is not None: + with contextlib.suppress(Exception): + self.store.set_pid(thread_id, pid) + + def get(self, thread_id: str, cwd: str | None = None, model: str | None = None, + sid: str | None = None) -> ClaudeProcess: + key = str(thread_id) + proc = self.procs.get(key) + if proc is None: + proc = self.procs[key] = ClaudeProcess( + key, + cwd or config.get("DEFAULT_CWD", "/workspace"), + model or config.get("MODEL_DEFAULT", "sonnet"), + sid=sid, + claude_bin=self.claude_bin, + settings=self.settings, + on_pid=self._on_pid, + ) + return proc + + async def set_options(self, thread_id: str, cwd: str | None = None, model: str | None = None) -> bool: + """`!cd` / `!model`: procesul curent se opreste, urmatorul tur il reia cu --resume.""" + proc = self.get(thread_id) + changed = False + if cwd and cwd != proc.cwd: + proc.cwd, changed = cwd, True + if model and model != proc.model: + proc.model, changed = model, True + if changed and proc.alive: + await proc.stop() + return changed + + async def reset(self, thread_id: str, fork: bool = False) -> None: + """`!new`: sesiune noua (fork=True pastreaza sid-ul pentru un fir nou).""" + proc = self.procs.get(str(thread_id)) + if proc is not None: + await proc.stop() + if not fork: + proc.sid = None + proc.started_turns = 0 + + def inflight(self, thread_id: str) -> bool: + proc = self.procs.get(str(thread_id)) + if proc is not None and proc.inflight: + return True + if self._is_inflight is not None: + with contextlib.suppress(Exception): + return bool(self._is_inflight(str(thread_id))) + if self.store is not None: + with contextlib.suppress(Exception): + return bool(self.store.is_inflight(str(thread_id))) + return False + + def live_count(self) -> int: + return sum(1 for p in self.procs.values() if p.alive) + + # ------------------------------------------------------------- reaper + async def reap_once(self, now: float | None = None) -> list[str]: + """Omoara procesele inactive. NICIODATA un fir cu tur in zbor.""" + now = time.monotonic() if now is None else now + killed: list[str] = [] + for key, proc in list(self.procs.items()): + if not proc.alive: + continue + if self.inflight(key): + continue + if now - proc.last_active >= self.idle_s: + log.info("reaper: opresc claude pentru firul %s (inactiv)", key) + await proc.stop() + killed.append(key) + return killed + + async def _reaper_loop(self) -> None: + while True: + try: + await asyncio.sleep(self.poll_s) + await self.reap_once() + except asyncio.CancelledError: + raise + except Exception: # pragma: no cover + log.exception("reaper a esuat, continui") + + def start_reaper(self) -> asyncio.Task: + if self._reaper is None or self._reaper.done(): + self._reaper = asyncio.create_task(self._reaper_loop()) + return self._reaper + + async def stop_all(self) -> None: + if self._reaper is not None: + self._reaper.cancel() + with contextlib.suppress(asyncio.CancelledError, Exception): + await self._reaper + self._reaper = None + for proc in list(self.procs.values()): + await proc.stop() + + +def new_turn_id() -> str: + return uuid.uuid4().hex[:12] diff --git a/proxmox/lxc171-claude-agent/discord-bridge/security/README.md b/proxmox/lxc171-claude-agent/discord-bridge/security/README.md new file mode 100644 index 0000000..d75a7b4 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/security/README.md @@ -0,0 +1,282 @@ +# Securitatea puntii Discord -> Claude Code (Lane B) + +Botul ruleaza CLI-ul `claude` cu `--permission-mode bypassPermissions`. Asta e o decizie +deliberata: accesul la nodurile Proxmox, la LXC-uri si la VM-uri este **functionalitate ceruta**, +nu accident. S-a verificat empiric ca regulile `deny` din settings **nu sunt o bariera** +(`/usr/bin/ssh -V` si `bash -c "ssh -V"` trec pe langa ele) — raman doar strat cosmetic. + +Straturile reale sunt: + +| Strat | Unde | Ce face | +|---|---|---| +| 1. Control de acces | `bot.py` (Lane A) | cine poate scrie in canal | +| 2. Confirmare pentru operatiuni ireversibile | `confirm_hook.py` + `approvals.py` | hook PreToolUse care blocheaza si asteapta un buton in Discord | +| 4. Poarta spre infrastructura | `infra` + token Proxmox cu ACL | hosturi dintr-o lista explicita, fiecare apel jurnalizat | + +Stratul 3 (audit append-only pe branch dedicat) a fost respins constient de utilizator. + +--- + +## 1. Fluxul de confirmare + +``` +claude (bypassPermissions) + | PreToolUse (JSON pe stdin) + v +confirm_hook.py --- clasificator ---> nepericuloasa ---> exit 0, fara iesire (flux normal) + | + | ireversibila + v +~/.claude-discord/approvals/.json (status: pending) + | ^ + | (bot.py vede cererea prin | submit_decision("allow"|"deny") + | set_on_request si posteaza | + | butoanele in fir) | + v | +polling pe disc, pana la 300s -----------+ + | + v +{"hookSpecificOutput": {"permissionDecision": "allow"|"deny", ...}} +``` + +Hook-ul si botul sunt **procese diferite** (hook-ul e pornit de CLI-ul `claude`), de aceea +canalul dintre ele e un director pe disc si nu memoria botului. + +### Formatul fisierului de cerere + +`~/.claude-discord/approvals/.json`, scris atomic (tmp + `os.replace`): + +```json +{ + "request_id": "3f9a1c2b7d4e5f60", + "thread_id": "1234567890", + "session_id": "b1c2...", + "tool_name": "Bash", + "command": "rm -rf /var/lib/vz/dump", + "rule": "rm_recursiv", + "reason": "stergere recursiva (rm -r)", + "cwd": "/workspace/romfastsql", + "created_at": 1756512000.0, + "expires_at": 1756512300.0, + "status": "pending", + "decision": null, + "decided_at": null, + "decided_by": null +} +``` + +- `status`: `pending` -> `allow` / `deny`. Botul schimba doar `status`, `decision`, `decided_at`. +- `thread_id` vine din variabila de mediu `CLAUDE_DISCORD_THREAD_ID`, pe care Lane A o pune in + mediul procesului `claude` al firului respectiv. Lipsa ei inseamna `null` si cererea ajunge + in canalul principal. +- Dupa decizie, hook-ul muta fisierul in `approvals/done/.json` (cu `finished_at`), + ca `pending_requests()` sa nu-l mai vada. `cleanup_stale()` sterge ce e mai vechi de o zi. + +### API-ul consumat de bot (contract INTERFACES.md) + +```python +await approvals.wait_for_decision(request_id, timeout) # "allow" | "deny" (timeout => deny) +approvals.submit_decision(request_id, "allow") # True daca cererea exista +await approvals.pending_requests() # cereri in asteptare +approvals.set_on_request(callback) # callback async la fiecare cerere noua +``` + +`set_on_request` porneste un watcher pe directorul de cereri (poll 0.5s) daca exista o bucla +asyncio activa; `set_on_request(None)` il opreste. Un callback care arunca nu opreste watcher-ul. + +### Fail-closed + +Orice abatere inseamna **deny**, cu motiv explicit trimis inapoi in CLI: + +- JSON invalid sau payload care nu e obiect; +- `~/.claude-discord` lipseste (hook-ul nu improvizeaza un director nou); +- cererea nu poate fi scrisa pe disc; +- fisierul cererii dispare sau devine JSON corupt in timpul asteptarii; +- niciun raspuns in `CLAUDE_DISCORD_APPROVAL_TIMEOUT` secunde (implicit 300); +- orice alta exceptie, prinsa de plasa finala din `main()`. + +Toate cazurile de mai sus au test in `tests/test_confirm_hook.py`. + +--- + +## 2. Ce prinde clasificatorul + +Analizeaza doar tool-ul `Bash`. Comanda e tokenizata cu `shlex` (operatorii `;`, `&&`, `||`, `|` +raman token-uri separate), impartita in segmente, iar fiecare segment e curatat de wrappere +(`sudo`, `env FOO=1`, `timeout 30`, `nohup`, `nice`, atribuiri `VAR=val`) inainte de a fi +clasificat pe numele de baza al executabilului (deci `/bin/rm` = `rm`). Intra recursiv in +`bash -c "..."`, `sh -c "..."`, `ssh host "..."`, `pct exec ... -- ...`, `docker exec ... ...` +(maxim 5 niveluri). + +Reguli: `rm -r`, `rm -f` pe cai de sistem, `find -delete`, `shred`, `dd`, `mkfs*`, `wipefs`, +`fdisk`/`parted`/`sgdisk`, redirectare in `/dev/...` (mai putin `/dev/null|stdout|stderr|tty`), +`shutdown`/`reboot`/`halt`/`poweroff`/`init 0|6`, `pct|qm destroy|restore`, `pvesh delete`, +`pvesm remove|free`, `pveceph destroy*|purge`, `zfs destroy|rollback`, `zpool destroy|labelclear`, +`lvremove`/`vgremove`/`pvremove`, `systemctl stop|disable|mask|kill` pe servicii de infra, +`systemctl -H`, `git push --force`, `git clean -f`, `git reset --hard`, `docker system prune`, +`docker volume rm`, `docker rm -f`, `chmod|chown -R` pe cai de sistem, `DROP`/`TRUNCATE` pe +obiecte Oracle, si orice `ssh`/`scp`/`rsync`/`infra` catre un host de productie +(10.0.20.36, .37, .200, .201, .202, `pve1`, `pvemini`, `pveelite`, `roacentral`). + +## 3. Ce NU prinde (limitele asumate) + +Acesta e un strat impotriva **accidentelor**, nu impotriva unui atacator. Cine controleaza +promptul poate ocoli detectia banal. Concret, NU sunt prinse: + +- **Ofuscarea**: `echo cm0gLXJmIC8= | base64 -d | sh`, `R=rm; $R -rf /tmp/x`, + `python3 -c "import shutil; shutil.rmtree('/x')"`, `perl -e 'unlink...'`, `eval "$CMD"`. + Hook-ul vede text, nu semantica. +- **Comenzi dintr-un fisier**: `./cleanup.sh`, `make clean`, `npm run reset`, un hook git — + continutul scriptului nu e citit. La fel `sqlplus @drop_all.sql`: numele fisierului nu spune + ce e inauntru. +- **Alte tool-uri**: `Write`, `Edit`, `NotebookEdit`, tool-urile MCP nu trec prin clasificator. + Un `.sh` distructiv poate fi scris fara confirmare (rularea lui, insa, se vede ca `Bash`). +- **Variabile si expansiuni**: `rm -rf "$DIR"` cere confirmare (regula `rm -r`), dar + `rm -f "$FILES"` nu, fiindca nu stim ce contine variabila. +- **API in loc de CLI**: `curl -X DELETE https://10.0.20.201:8006/api2/json/nodes/...` nu e + clasificat ca stergere (doar hostul de productie ar fi prins daca executabilul ar fi ssh/rsync; + cu `curl` nu e). +- **`ssh` cu optiuni cu valoare**: `ssh -p 2222 host cmd` poate incurca detectarea hostului + (valoarea `2222` e luata drept host), deci comanda remota nu mai e analizata. +- **Falsuri pozitive acceptate**: orice `rm -r`, chiar in `/tmp`, cere confirmare. E zgomot + deliberat: un clic e mai ieftin decat un director sters gresit. +- **Auto-aprobarea**: hook-ul si botul ruleaza sub acelasi utilizator, deci un agent care vrea + neaparat isi poate scrie singur `"status": "allow"` in fisierul cererii. Consecinta directa a + respingerii stratului 3 (user separat / audit independent), asumata explicit. + +--- + +## 4. Wrapper `infra` + +```bash +infra --list # hosturile permise +infra pvemini pct list # ruleaza comanda pe nodul Proxmox +infra oracle docker ps +INFRA_DRY_RUN=1 infra pvemini uptime # arata comanda ssh, nu o executa +``` + +- Hostul e cautat intr-o lista **explicita**. Un host absent e refuzat imediat, fara DNS: + `exit 3`. Fara comanda: `exit 2`. Fisier de hosturi corupt: `exit 4`. Altfel, codul de iesire + este cel al comenzii remote. +- Lista implicita e in `infra` (`DEFAULT_HOSTS`) si poate fi inlocuita integral cu + `~/.claude-discord/infra-hosts.json`: + + ```json + { + "pvemini": {"addr": "10.0.20.201", "user": "root", "prod": true, "desc": "nod principal"}, + "oracle": {"addr": "10.0.20.121", "user": "root"}, + "oracle-prod": {"addr": "10.0.20.36", "user": "romfast", "prod": true} + } + ``` + + Daca fisierul exista, **inlocuieste** lista implicita (nu se adauga la ea). +- Fiecare apel — inclusiv refuzurile — se scrie pe o linie in `~/.claude-discord/logs/infra.log`: + + ``` + 2026-08-30T11:20:41 host=pvemini target=root@10.0.20.201 rc=0 dur=0.42s cmd=pct list + 2026-08-30T11:21:03 host=router.local target=- rc=refuzat dur=0.00s cmd=reboot note=host in afara listei + ``` + + Jurnalul e un ajutor de depanare, nu un audit: ruleaza sub acelasi user si poate fi rescris. + +--- + +## 5. Instalare + +```bash +mkdir -p ~/.claude-discord/{approvals/done,logs} +chmod 700 ~/.claude-discord + +# settings pasat botului cu --settings +cp proxmox/lxc171-claude-agent/discord-bridge/security/bot-settings.json.example \ + ~/.claude-discord/bot-settings.json +# ajusteaza calea absoluta a hook-ului daca repo-ul nu e in /workspace/romfastsql + +# wrapper-ul in PATH +ln -s /workspace/romfastsql/proxmox/lxc171-claude-agent/discord-bridge/security/infra ~/bin/infra +``` + +Variabile de mediu (puse de Lane A in mediul procesului `claude`): + +| Variabila | Rol | Implicit | +|---|---|---| +| `CLAUDE_DISCORD_DIR` | muta `~/.claude-discord` (teste) | `~/.claude-discord` | +| `CLAUDE_DISCORD_APPROVAL_TIMEOUT` | cat asteapta hook-ul o decizie, in secunde | `300` | +| `CLAUDE_DISCORD_THREAD_ID` | firul in care se posteaza butoanele | — | +| `INFRA_DRY_RUN` | `infra` doar tipareste comanda ssh | — | + +Atentie: `timeout` din `bot-settings.json` (330s) trebuie sa ramana **mai mare** decat +`CLAUDE_DISCORD_APPROVAL_TIMEOUT`, altfel CLI-ul taie hook-ul inainte sa apuce sa refuze curat. + +--- + +## 6. Token Proxmox cu ACL restrans (pasi manuali) + +**Nu a fost creat nimic pe cluster.** Comenzile de mai jos se ruleaza de om, ca `root` pe +`pvemini` (10.0.20.201). Tokenul acopera operatiile de *citire si control de alimentare* pe care +le vrea puntea; `VM.Allocate` (crearea/distrugerea de guest-uri) este **intentionat lasat afara**. + +```bash +# 1. utilizator dedicat pentru punte +pveum user add claude-bridge@pve --comment "punte Discord -> Claude Code (LXC 171)" + +# 2. rol cu strictul necesar +# - audit/monitorizare: sa poata raspunde la "ce mai face clusterul" +# - PowerMgmt + Console: start/stop/reboot pe guest si `pct exec`-uri prin API +pveum role add ClaudeBridge -privs "\ +Datastore.Audit,\ +Sys.Audit,Sys.Console,Sys.Syslog,\ +VM.Audit,VM.Monitor,VM.Console,VM.PowerMgmt" + +# 3. legarea rolului de utilizator (pe tot arborele; restrange la /vms/ daca vrei mai putin) +pveum acl modify / --users claude-bridge@pve --roles ClaudeBridge + +# 4. tokenul propriu-zis, cu separare de privilegii activa +pveum user token add claude-bridge@pve discord --privsep 1 +# ^ afiseaza SECRETUL O SINGURA DATA. Copiaza-l acum. + +# 5. ACL explicit pentru token (necesar cand privsep=1) +pveum acl modify / --tokens 'claude-bridge@pve!discord' --roles ClaudeBridge + +# 6. verificare +pveum acl list +pveum user token list claude-bridge@pve +``` + +Pe LXC 171, secretul se pune in `~/.claude-discord/env` (fisier `0600`, deja folosit de Lane A): + +``` +PVE_API_URL=https://10.0.20.201:8006/api2/json +PVE_TOKEN_ID=claude-bridge@pve!discord +PVE_TOKEN_SECRET= +``` + +Test rapid (citeste, nu schimba nimic): + +```bash +curl -sk -H "Authorization: PVEAPIToken=${PVE_TOKEN_ID}=${PVE_TOKEN_SECRET}" \ + "${PVE_API_URL}/nodes" | jq '.data[].node' +``` + +Pentru revocare: `pveum user token remove claude-bridge@pve discord`. + +**Ce ramane in sarcina omului:** pasii 1-6 de mai sus pe `pvemini`, copierea secretului in +`~/.claude-discord/env`, `chmod 600` pe acel fisier si decizia daca ACL-ul ramane pe `/` sau se +restrange la un subset de guest-uri. Puntea nu creeaza si nu roteste tokenul singura. + +Tokenul **nu inlocuieste** cheile SSH existente din `~/.ssh` — retragerea lor a fost respinsa +deliberat, fiindca accesul SSH la infrastructura e functionalitate ceruta. Tokenul e o cale +alternativa, cu drepturi mai mici, pentru operatiile care se pot face prin API. + +--- + +## 7. Teste + +```bash +cd proxmox/lxc171-claude-agent/discord-bridge +python3 -m pytest tests/test_confirm_hook.py tests/test_infra.py -q +``` + +Fara retea, fara Discord, fara cluster. `tests/test_infra.py` ruleaza totul cu `INFRA_DRY_RUN=1`, +iar `tests/test_confirm_hook.py` include si un test in care hook-ul e pornit ca proces separat si +aprobat din exterior — exact granita reala dintre hook si bot. diff --git a/proxmox/lxc171-claude-agent/discord-bridge/security/__init__.py b/proxmox/lxc171-claude-agent/discord-bridge/security/__init__.py new file mode 100644 index 0000000..07524b1 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/security/__init__.py @@ -0,0 +1,5 @@ +"""Stratul de securitate al puntii Discord -> Claude Code (Lane B). + +Contine canalul de aprobari pe disc (approvals.py), hook-ul PreToolUse +(confirm_hook.py) si wrapper-ul `infra` pentru accesul la infrastructura. +""" diff --git a/proxmox/lxc171-claude-agent/discord-bridge/security/approvals.py b/proxmox/lxc171-claude-agent/discord-bridge/security/approvals.py new file mode 100644 index 0000000..16f1463 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/security/approvals.py @@ -0,0 +1,350 @@ +"""Canal de aprobari pe disc intre hook-ul PreToolUse si botul Discord. + +Hook-ul `confirm_hook.py` ruleaza in alt proces decat botul (il porneste CLI-ul +`claude`), deci canalul dintre ele este un director de cereri: + + ~/.claude-discord/approvals/.json cerere in asteptare + ~/.claude-discord/approvals/done/.json cerere incheiata + +Regula de baza: FAIL-CLOSED. Orice eroare, timeout, fisier corupt sau director +lipsa inseamna "deny". Modulul nu atinge reteaua si nu stie nimic despre Discord. +""" + +from __future__ import annotations + +import asyncio +import json +import os +import pathlib +import time +import uuid + +# config.py apartine Lane A si poate lipsi in unele contexte (hook rulat izolat). +# Import tolerant, cu acelasi fallback pe ~/.claude-discord. +try: # pragma: no cover - depinde de contextul de import + from .. import config as _config # type: ignore +except Exception: # pragma: no cover + try: + import config as _config # type: ignore + except Exception: + _config = None # type: ignore + +ALLOW = "allow" +DENY = "deny" +PENDING = "pending" + +_POLL_S = 0.2 # cat de des verificam decizia pe disc +_WATCH_S = 0.5 # cat de des verificam cereri noi pentru bot + + +# ---------------------------------------------------------------- cai pe disc + +def state_dir() -> pathlib.Path: + """~/.claude-discord, cu CLAUDE_DISCORD_DIR ca override (folosit in teste).""" + override = os.environ.get("CLAUDE_DISCORD_DIR") + if override: + return pathlib.Path(override) + if _config is not None: + try: + return pathlib.Path(_config.STATE_DIR) + except Exception: + pass + return pathlib.Path.home() / ".claude-discord" + + +def approvals_dir() -> pathlib.Path: + return state_dir() / "approvals" + + +def done_dir() -> pathlib.Path: + return approvals_dir() / "done" + + +def log_dir() -> pathlib.Path: + return state_dir() / "logs" + + +def ensure_dirs() -> None: + """Creeaza subdirectoarele de aprobari. + + Nu creeaza directorul de baza: daca ~/.claude-discord lipseste inseamna ca + puntea nu e instalata, iar hook-ul trebuie sa refuze (fail-closed), nu sa + improvizeze un director nou. + """ + base = state_dir() + if not base.is_dir(): + raise FileNotFoundError(f"directorul de stare lipseste: {base}") + approvals_dir().mkdir(parents=True, exist_ok=True) + done_dir().mkdir(parents=True, exist_ok=True) + + +# ------------------------------------------------------------ scriere atomica + +def _write_atomic(path: pathlib.Path, payload: dict) -> None: + """tmp + os.replace, ca un cititor sa nu vada niciodata JSON pe jumatate.""" + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.parent / f".{path.name}.{os.getpid()}.{uuid.uuid4().hex[:8]}.tmp" + data = json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + try: + with open(tmp, "w", encoding="utf-8") as fh: + fh.write(data) + fh.flush() + os.fsync(fh.fileno()) + os.replace(tmp, path) + finally: + try: + tmp.unlink() + except OSError: + pass + + +def _read(path: pathlib.Path) -> dict | None: + """Citeste o cerere. JSON corupt sau fisier disparut => None.""" + try: + raw = path.read_text(encoding="utf-8") + except OSError: + return None + try: + obj = json.loads(raw) + except (ValueError, TypeError): + return None + return obj if isinstance(obj, dict) else None + + +# --------------------------------------------------------------- API interna +# (folosita de confirm_hook.py; botul nu are nevoie de ea) + +def new_request_id() -> str: + return uuid.uuid4().hex[:16] + + +def create_request( + *, + tool_name: str, + command: str, + reason: str = "", + rule: str = "", + thread_id: str | None = None, + session_id: str | None = None, + cwd: str | None = None, + timeout: float = 300.0, + request_id: str | None = None, +) -> dict: + """Scrie o cerere de confirmare si o returneaza. Arunca daca nu poate scrie.""" + ensure_dirs() + rid = request_id or new_request_id() + now = time.time() + req = { + "request_id": rid, + "thread_id": thread_id, + "session_id": session_id, + "tool_name": tool_name, + "command": command, + "rule": rule, + "reason": reason, + "cwd": cwd, + "created_at": now, + "expires_at": now + float(timeout), + "status": PENDING, + "decision": None, + "decided_at": None, + "decided_by": None, + } + _write_atomic(approvals_dir() / f"{rid}.json", req) + return req + + +def request_path(request_id: str) -> pathlib.Path: + return approvals_dir() / f"{_safe_id(request_id)}.json" + + +def _safe_id(request_id: str) -> str: + """Nu lasam un id sa evadeze din director prin `../`.""" + rid = str(request_id) + if not rid or "/" in rid or "\\" in rid or rid.startswith("."): + raise ValueError(f"request_id invalid: {rid!r}") + return rid + + +def read_decision(request_id: str) -> str: + """`allow` / `deny` / `pending`. Orice problema => `deny` (fail-closed).""" + try: + req = _read(request_path(request_id)) + except Exception: + return DENY + if req is None: + return DENY + status = req.get("status") + if status == ALLOW: + return ALLOW + if status == PENDING: + return PENDING + return DENY + + +def finish_request(request_id: str, status: str, note: str = "") -> None: + """Muta cererea in `done/`, ca `pending_requests()` sa nu o mai vada.""" + try: + src = request_path(request_id) + req = _read(src) or {"request_id": request_id} + req["status"] = status if status in (ALLOW, DENY) else DENY + req["finished_at"] = time.time() + if note: + req["note"] = note + _write_atomic(done_dir() / f"{_safe_id(request_id)}.json", req) + try: + src.unlink() + except OSError: + pass + except Exception: + pass + + +def cleanup_stale(max_age_s: float = 86400.0) -> int: + """Sterge cereri incheiate mai vechi de o zi. Returneaza cate a sters.""" + n = 0 + now = time.time() + try: + for p in done_dir().glob("*.json"): + try: + if now - p.stat().st_mtime > max_age_s: + p.unlink() + n += 1 + except OSError: + pass + except Exception: + pass + return n + + +# ---------------------------------------------------- API publica (Lane A/bot) + +async def wait_for_decision(request_id: str, timeout: float) -> str: + """Asteapta decizia pentru o cerere. La timeout sau eroare returneaza `deny`.""" + deadline = time.monotonic() + max(0.0, float(timeout or 0)) + while True: + decision = read_decision(request_id) + if decision in (ALLOW, DENY): + return decision + if time.monotonic() >= deadline: + return DENY + await asyncio.sleep(_POLL_S) + + +def wait_for_decision_sync(request_id: str, timeout: float) -> str: + """Varianta blocanta, pentru hook (proces separat, fara bucla asyncio).""" + deadline = time.monotonic() + max(0.0, float(timeout or 0)) + while True: + decision = read_decision(request_id) + if decision in (ALLOW, DENY): + return decision + if time.monotonic() >= deadline: + return DENY + time.sleep(_POLL_S) + + +def submit_decision(request_id: str, decision: str) -> bool: + """Apelata de bot.py cand utilizatorul apasa butonul. + + True daca cererea exista si a fost marcata. O decizie nerecunoscuta este + tratata ca `deny` si returneaza False. + """ + try: + path = request_path(request_id) + except Exception: + return False + req = _read(path) + if req is None: + return False + valid = decision in (ALLOW, DENY) + req["status"] = decision if valid else DENY + req["decision"] = req["status"] + req["decided_at"] = time.time() + try: + _write_atomic(path, req) + except Exception: + return False + return valid + + +async def pending_requests() -> list[dict]: + """Cererile inca in asteptare, cele mai vechi intai. Nu arunca niciodata.""" + out: list[dict] = [] + try: + paths = sorted(approvals_dir().glob("*.json")) + except Exception: + return out + for p in paths: + req = _read(p) + if not req or req.get("status") != PENDING: + continue + out.append( + { + "request_id": req.get("request_id") or p.stem, + "thread_id": req.get("thread_id"), + "tool_name": req.get("tool_name") or "", + "command": req.get("command") or "", + "created_at": req.get("created_at") or 0.0, + "reason": req.get("reason") or "", + } + ) + out.sort(key=lambda r: r["created_at"]) + return out + + +_on_request = None +_watch_task = None +_seen: set[str] = set() + + +def set_on_request(callback) -> None: + """Inregistreaza un callback async apelat cand apare o cerere noua. + + Botul posteaza atunci butoanele in firul Discord. Un callback `None` + opreste urmarirea. + """ + global _on_request, _watch_task + _on_request = callback + if callback is None: + stop_watcher() + return + try: + loop = asyncio.get_running_loop() + except RuntimeError: + return # fara bucla activa nu pornim nimic; se reapeleaza din bot + if _watch_task is None or _watch_task.done(): + _watch_task = loop.create_task(_watch_loop()) + + +def stop_watcher() -> None: + global _watch_task + if _watch_task is not None and not _watch_task.done(): + _watch_task.cancel() + _watch_task = None + + +async def _watch_loop() -> None: + """Urmareste directorul de cereri si anunta botul o singura data per cerere.""" + while True: + try: + for req in await pending_requests(): + rid = req["request_id"] + if rid in _seen: + continue + _seen.add(rid) + cb = _on_request + if cb is None: + continue + try: + res = cb(req) + if asyncio.iscoroutine(res): + await res + except Exception: + pass # un callback care crapa nu are voie sa opreasca botul + if len(_seen) > 5000: + _seen.clear() + except asyncio.CancelledError: + raise + except Exception: + pass + await asyncio.sleep(_WATCH_S) diff --git a/proxmox/lxc171-claude-agent/discord-bridge/security/bot-settings.json.example b/proxmox/lxc171-claude-agent/discord-bridge/security/bot-settings.json.example new file mode 100644 index 0000000..3803e6c --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/security/bot-settings.json.example @@ -0,0 +1,27 @@ +{ + "_comentariu": "Sablon pentru ~/.claude-discord/bot-settings.json, pasat cu --settings. Copiaza-l si ajusteaza calea absoluta a hook-ului daca repo-ul nu e in /workspace/romfastsql.", + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python3 /workspace/romfastsql/proxmox/lxc171-claude-agent/discord-bridge/security/confirm_hook.py", + "timeout": 330 + } + ] + } + ] + }, + "permissions": { + "_comentariu": "STRAT COSMETIC, NU BARIERA: sub --permission-mode bypassPermissions regulile deny nu opresc /usr/bin/ssh sau bash -c \"ssh\". Bariera reala e hook-ul PreToolUse de mai sus.", + "deny": [ + "Bash(ssh:*)", + "Bash(scp:*)", + "Bash(pct destroy:*)", + "Bash(qm destroy:*)", + "Bash(zfs destroy:*)" + ] + } +} diff --git a/proxmox/lxc171-claude-agent/discord-bridge/security/confirm_hook.py b/proxmox/lxc171-claude-agent/discord-bridge/security/confirm_hook.py new file mode 100644 index 0000000..60b195d --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/security/confirm_hook.py @@ -0,0 +1,461 @@ +#!/usr/bin/env python3 +"""Hook PreToolUse: cere confirmare in Discord pentru operatiuni ireversibile. + +Contract Claude Code: primeste pe stdin un JSON de forma + + {"session_id": "...", "cwd": "...", "hook_event_name": "PreToolUse", + "tool_name": "Bash", "tool_input": {"command": "..."}} + +si raspunde pe stdout cu + + {"hookSpecificOutput": {"hookEventName": "PreToolUse", + "permissionDecision": "allow"|"deny", + "permissionDecisionReason": "..."}} + +Comenzile nepericuloase nu produc nicio iesire (exit 0) si urmeaza fluxul normal. +Cele periculoase produc o cerere in ~/.claude-discord/approvals/ si asteapta +decizia botului. + +FAIL-CLOSED: orice exceptie, timeout, director lipsa sau JSON corupt => deny. +""" + +from __future__ import annotations + +import json +import os +import pathlib +import re +import shlex +import sys + +# Ca `import config` (Lane A) sa functioneze si cand hook-ul e pornit ca script. +_HERE = pathlib.Path(__file__).resolve().parent +for _p in (str(_HERE), str(_HERE.parent)): + if _p not in sys.path: + sys.path.insert(0, _p) + +DEFAULT_TIMEOUT_S = 300.0 + +# ------------------------------------------------------------------ constante + +# Prefixe care doar impacheteaza alta comanda; le desfacem inainte de analiza. +_WRAPPERS = { + "sudo", "doas", "nohup", "nice", "ionice", "time", "command", "exec", + "stdbuf", "setsid", "env", "eatmydata", +} +# Wrappere care au un argument numeric/optiune proprie de sarit. +_WRAPPER_OPT_ARG = {"timeout": 1, "nice": 0, "ionice": 0} + +_SHELLS = {"bash", "sh", "zsh", "dash", "ksh", "ash", "busybox"} + +# Servicii de infrastructura: oprirea lor rupe ceva ce altcineva foloseste. +_INFRA_SERVICES = ( + "pve", "pvedaemon", "pveproxy", "pvestatd", "pve-cluster", "pve-firewall", + "corosync", "ceph", "zfs", "oracle", "oracle-xe", "flowise", "gitea", + "docker", "containerd", "nginx", "apache2", "ttyd", "ssh", "sshd", + "postgresql", "mysql", "mariadb", "tailscaled", "smbd", "nfs-server", + "claude-discord", "nut-server", "nut-monitor", +) + +# Hosturi de productie: orice comanda remote catre ele cere confirmare. +_PROD_HOSTS = ( + "10.0.20.36", "10.0.20.37", "10.0.20.200", "10.0.20.201", "10.0.20.202", + "pve1", "pvemini", "pveelite", "oracle-prod", "dr", "roacentral", +) +_REMOTE_EXEC = {"ssh", "scp", "rsync", "sftp", "infra", "ansible", "ansible-playbook"} + +_SQL_DESTRUCTIVE = re.compile( + r"\b(drop|truncate)\s+" + r"(table|user|tablespace|schema|database|index|view|sequence|materialized|" + r"package|body|procedure|function|trigger|type|synonym|directory)\b", + re.IGNORECASE, +) + +_SENSITIVE_ROOTS = ("/", "/etc", "/usr", "/boot", "/var", "/bin", "/sbin", "/lib", "/opt") + + +# ------------------------------------------------------------- tokenizare + +def _tokenize(cmd: str) -> list[str]: + """Imparte comanda in token-uri, cu `;`, `&&`, `||`, `|`, `>` separate. + + Daca lexerul esueaza (ghilimele neinchise), cadem pe o impartire naiva -- + scopul e detectia, nu executia. + """ + try: + lex = shlex.shlex(cmd, posix=True, punctuation_chars=True) + lex.whitespace_split = True + return list(lex) + except Exception: + return cmd.replace(";", " ; ").replace("|", " | ").split() + + +_SEPARATORS = {";", "&&", "||", "|", "&", "\n"} + + +def _segments(tokens: list[str]) -> list[list[str]]: + """Grupeaza token-urile in comenzi separate de operatori de shell.""" + out: list[list[str]] = [] + cur: list[str] = [] + for t in tokens: + if t in _SEPARATORS: + if cur: + out.append(cur) + cur = [] + else: + cur.append(t) + if cur: + out.append(cur) + return out + + +def _strip_wrappers(seg: list[str]) -> list[str]: + """Scoate `sudo`, `env FOO=1`, `timeout 30`, atribuiri VAR=val etc.""" + i = 0 + n = len(seg) + while i < n: + tok = seg[i] + base = os.path.basename(tok) + if "=" in tok and not tok.startswith("-") and re.match(r"^[A-Za-z_][A-Za-z0-9_]*=", tok): + i += 1 + continue + if base in _WRAPPERS or base in _WRAPPER_OPT_ARG: + i += 1 + # sarim optiunile wrapper-ului si eventualul argument (ex. timeout 30) + while i < n and seg[i].startswith("-"): + if base == "sudo" and seg[i] in ("-u", "-g", "-U"): + i += 2 + continue + i += 1 + if base in _WRAPPER_OPT_ARG and _WRAPPER_OPT_ARG[base] and i < n: + if re.match(r"^[0-9]+(\.[0-9]+)?[smhd]?$", seg[i]): + i += 1 + continue + break + return seg[i:] + + +def _has_flag(args: list[str], short: str, *longs: str) -> bool: + for a in args: + if a in longs: + return True + if a.startswith("--"): + continue + if short and a.startswith("-") and len(a) > 1 and short in a[1:]: + return True + return False + + +def _mentions_prod(tokens: list[str]) -> str | None: + for t in tokens: + low = t.lower() + for host in _PROD_HOSTS: + if low == host or low.endswith("@" + host) or low.startswith(host + ":"): + return host + if host[0].isdigit() and host in low: + return host + return None + + +# ------------------------------------------------------------- clasificator + +def classify_command(cmd: str, depth: int = 0) -> tuple[str, str] | None: + """Returneaza `(regula, motiv)` daca cere confirmare, altfel None.""" + if not cmd or not cmd.strip(): + return None + if depth > 5: + return ("prea_adanc", "comanda impachetata pe prea multe niveluri") + + # SQL distructiv: cautam in textul brut, indiferent de shell. + m = _SQL_DESTRUCTIVE.search(cmd) + if m: + return ("sql_destructiv", f"SQL ireversibil: {m.group(0).upper()}") + + tokens = _tokenize(cmd) + + # Redirectare catre /dev/... (suprascrie un disc sau un dispozitiv). + for i, t in enumerate(tokens): + if t in (">", ">>") and i + 1 < len(tokens) and tokens[i + 1].startswith("/dev/"): + if not tokens[i + 1].startswith(("/dev/null", "/dev/stdout", "/dev/stderr", "/dev/tty")): + return ("redirect_dev", f"scriere directa in {tokens[i + 1]}") + + for seg in _segments(tokens): + seg = _strip_wrappers(seg) + if not seg: + continue + verdict = _classify_segment(seg, depth) + if verdict: + return verdict + return None + + +def _classify_segment(seg: list[str], depth: int) -> tuple[str, str] | None: + exe = os.path.basename(seg[0]) + args = seg[1:] + sub = args[0] if args else "" + + # --- shell-uri si executii la distanta: intram in comanda dinauntru + if exe in _SHELLS and "-c" in args: + idx = args.index("-c") + if idx + 1 < len(args): + inner = classify_command(args[idx + 1], depth + 1) + if inner: + return inner + if exe in ("ssh", "infra"): + host = _mentions_prod(seg) + if host: + return ("host_productie", f"comanda catre hostul de productie {host}") + # restul argumentelor formeaza comanda remote + rest = [a for a in args if not a.startswith("-")][1:] + if rest: + inner = classify_command(" ".join(rest), depth + 1) + if inner: + return inner + if exe in _REMOTE_EXEC: + host = _mentions_prod(seg) + if host: + return ("host_productie", f"comanda catre hostul de productie {host}") + if exe == "pct" and sub == "exec": + rest = args[2:] + if rest and rest[0] == "--": + rest = rest[1:] + if rest: + inner = classify_command(" ".join(rest), depth + 1) + if inner: + return inner + if exe == "docker" and sub == "exec": + rest = [a for a in args[1:] if not a.startswith("-")][1:] + if rest: + inner = classify_command(" ".join(rest), depth + 1) + if inner: + return inner + + # --- stergeri + if exe == "rm": + if _has_flag(args, "r", "--recursive") or _has_flag(args, "R"): + return ("rm_recursiv", "stergere recursiva (rm -r)") + if _has_flag(args, "f", "--force"): + for a in args: + if not a.startswith("-") and (a in ("/",) or a.rstrip("/") in _SENSITIVE_ROOTS): + return ("rm_sistem", f"stergere in cale de sistem: {a}") + if exe == "find" and ("-delete" in args or "-exec" in args and "rm" in args): + return ("find_delete", "find cu stergere (-delete / -exec rm)") + if exe == "shred": + return ("shred", "suprascriere ireversibila (shred)") + + # --- discuri si filesysteme + if exe == "dd": + return ("dd", "scriere directa pe bloc (dd)") + if exe.startswith("mkfs") or exe in ("wipefs", "sgdisk", "sfdisk", "fdisk", "parted", "cfdisk", "mkswap"): + return ("disc", f"operatie pe partitii/filesystem ({exe})") + + # --- oprire/repornire + if exe in ("shutdown", "reboot", "halt", "poweroff"): + return ("oprire", f"oprirea sau repornirea masinii ({exe})") + if exe == "init" and sub in ("0", "6"): + return ("oprire", f"schimbare runlevel ({sub})") + + # --- Proxmox / ZFS / LVM + if exe in ("pct", "qm") and sub in ("destroy", "restore"): + return ("proxmox_destroy", f"{exe} {sub} distruge/suprascrie un guest") + if exe == "pvesm" and sub in ("remove", "free"): + return ("proxmox_storage", f"pvesm {sub} pe un storage") + if exe == "pvesh" and sub == "delete": + return ("pvesh_delete", "apel API Proxmox de stergere (pvesh delete)") + if exe == "pveceph" and sub in ("destroypool", "purge", "destroymon", "destroyosd"): + return ("proxmox_ceph", f"pveceph {sub}") + if exe == "zfs" and sub in ("destroy", "rollback"): + return ("zfs_destroy", f"zfs {sub} este ireversibil") + if exe == "zpool" and sub in ("destroy", "labelclear"): + return ("zfs_destroy", f"zpool {sub} este ireversibil") + if exe in ("lvremove", "vgremove", "pvremove"): + return ("lvm", f"stergere LVM ({exe})") + + # --- servicii + if exe == "systemctl": + if any(a in ("-H", "--host") for a in args): + return ("systemctl_remote", "systemctl catre alt host") + verb = "" + targets: list[str] = [] + for a in args: + if a.startswith("-"): + continue + if not verb: + verb = a + else: + targets.append(a) + if verb in ("poweroff", "reboot", "halt", "kexec", "emergency", "rescue"): + return ("oprire", f"systemctl {verb}") + if verb in ("stop", "disable", "mask", "kill"): + for t in targets: + name = t.split(".")[0].lower() + if any(name == s or name.startswith(s) for s in _INFRA_SERVICES): + return ("serviciu_infra", f"systemctl {verb} pe serviciul de infra {t}") + + # --- git + if exe == "git": + verbs = [a for a in args if not a.startswith("-")] + verb = verbs[0] if verbs else "" + if verb == "push" and ( + _has_flag(args, "f", "--force", "--force-with-lease") + or any(a.startswith("--force") for a in args) + ): + return ("git_push_force", "git push --force rescrie istoria pe remote") + if verb == "clean" and _has_flag(args, "f", "--force"): + return ("git_clean", "git clean sterge fisiere neversionate") + if verb == "reset" and "--hard" in args: + return ("git_reset_hard", "git reset --hard arunca modificarile locale") + + # --- docker + if exe == "docker": + if sub == "system" and "prune" in args: + return ("docker_prune", "docker system prune") + if sub == "volume" and "rm" in args: + return ("docker_volume", "stergere volum docker") + if sub in ("rm", "rmi") and _has_flag(args, "f", "--force"): + return ("docker_rm", f"docker {sub} -f") + + # --- permisiuni pe cai de sistem + if exe in ("chmod", "chown", "chgrp") and _has_flag(args, "R", "--recursive"): + for a in args: + if a.startswith("/") and (a.rstrip("/") in _SENSITIVE_ROOTS or a == "/"): + return ("perm_sistem", f"{exe} -R pe {a}") + + return None + + +def classify(tool_name: str, tool_input: dict) -> tuple[str, str] | None: + """Punctul de intrare al clasificatorului. Doar Bash e analizat in v1.""" + if tool_name != "Bash": + return None + cmd = tool_input.get("command") if isinstance(tool_input, dict) else None + if not isinstance(cmd, str): + return None + return classify_command(cmd) + + +# ------------------------------------------------------------------ raspunsuri + +def _emit(decision: str, reason: str) -> None: + print( + json.dumps( + { + "hookSpecificOutput": { + "hookEventName": "PreToolUse", + "permissionDecision": decision, + "permissionDecisionReason": reason, + } + }, + ensure_ascii=False, + ) + ) + + +def _deny(reason: str) -> int: + _emit("deny", reason) + return 0 + + +def _allow(reason: str) -> int: + _emit("allow", reason) + return 0 + + +def _log(msg: str) -> None: + try: + d = pathlib.Path(os.environ.get("CLAUDE_DISCORD_DIR") or (pathlib.Path.home() / ".claude-discord")) / "logs" + d.mkdir(parents=True, exist_ok=True) + import time as _t + + with open(d / "confirm_hook.log", "a", encoding="utf-8") as fh: + fh.write(f"{_t.strftime('%Y-%m-%d %H:%M:%S')} {msg}\n") + except Exception: + pass + + +def _timeout_s() -> float: + raw = os.environ.get("CLAUDE_DISCORD_APPROVAL_TIMEOUT") + try: + if raw: + return max(1.0, float(raw)) + except (TypeError, ValueError): + pass + return DEFAULT_TIMEOUT_S + + +def run(payload_text: str) -> int: + """Logica hook-ului, separata de I/O ca sa poata fi testata.""" + try: + payload = json.loads(payload_text) + if not isinstance(payload, dict): + raise ValueError("payload-ul nu e obiect JSON") + except Exception as exc: + return _deny(f"hook de confirmare: intrare invalida ({exc}); refuz din principiu") + + tool_name = payload.get("tool_name") or "" + tool_input = payload.get("tool_input") or {} + verdict = classify(tool_name, tool_input) + if verdict is None: + return 0 # nepericuloasa: fara iesire, flux normal + + rule, reason = verdict + command = tool_input.get("command", "") if isinstance(tool_input, dict) else "" + + try: + import approvals # noqa: PLC0415 - import tarziu, ca eroarea sa cada in deny + except Exception as exc: + return _deny(f"hook de confirmare: modulul de aprobari lipseste ({exc})") + + timeout = _timeout_s() + try: + req = approvals.create_request( + tool_name=tool_name, + command=command, + reason=reason, + rule=rule, + thread_id=os.environ.get("CLAUDE_DISCORD_THREAD_ID"), + session_id=payload.get("session_id"), + cwd=payload.get("cwd"), + timeout=timeout, + ) + except Exception as exc: + _log(f"DENY (cerere neputincioasa: {exc}) rule={rule} cmd={command[:120]}") + return _deny( + f"hook de confirmare: nu pot cere aprobarea ({exc}); " + f"operatiune blocata ({reason})" + ) + + rid = req["request_id"] + _log(f"PENDING {rid} rule={rule} cmd={command[:160]}") + try: + decision = approvals.wait_for_decision_sync(rid, timeout) + except Exception as exc: + decision = "deny" + _log(f"DENY {rid} exceptie la asteptare: {exc}") + + if decision == "allow": + approvals.finish_request(rid, "allow") + _log(f"ALLOW {rid} rule={rule}") + return _allow(f"aprobat in Discord (cerere {rid}, {reason})") + + approvals.finish_request(rid, "deny") + _log(f"DENY {rid} rule={rule}") + return _deny( + f"neaprobat in Discord in {int(timeout)}s (cerere {rid}, {reason}). " + "Cere confirmarea si reia comanda." + ) + + +def main() -> int: + try: + payload_text = sys.stdin.read() + except Exception as exc: # pragma: no cover - stdin rupt + return _deny(f"hook de confirmare: nu pot citi stdin ({exc})") + try: + return run(payload_text) + except Exception as exc: # plasa de siguranta finala + return _deny(f"hook de confirmare: eroare interna ({exc}); refuz din principiu") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/proxmox/lxc171-claude-agent/discord-bridge/security/infra b/proxmox/lxc171-claude-agent/discord-bridge/security/infra new file mode 100755 index 0000000..7367a73 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/security/infra @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""infra -- singura poarta prin care puntea Discord atinge infrastructura. + + infra ruleaza comanda pe hostul din lista + infra --list arata hosturile permise + infra --help + +Hosturile sunt o lista EXPLICITA. Un host care nu e in lista este refuzat, fara +incercare de rezolvare DNS. Fiecare apel este jurnalizat in +~/.claude-discord/logs/infra.log cu data, host, comanda completa si rezultat. + +Lista implicita poate fi inlocuita cu ~/.claude-discord/infra-hosts.json: + + {"pvemini": {"addr": "10.0.20.201", "user": "root", "prod": true, + "desc": "nod Proxmox principal"}} + +Coduri de iesire proprii wrapper-ului (comenzile remote isi pastreaza codul lor): + 2 utilizare gresita (lipseste hostul sau comanda) + 3 host in afara listei + 4 eroare de configurare (fisier de hosturi corupt) +""" + +from __future__ import annotations + +import datetime +import json +import os +import pathlib +import shlex +import subprocess +import sys +import time + +EXIT_USAGE = 2 +EXIT_UNKNOWN_HOST = 3 +EXIT_CONFIG = 4 + +# Lista implicita, din tabelul de retea al repo-ului (CLAUDE.md, proxmox/README.md). +DEFAULT_HOSTS: dict[str, dict] = { + # noduri Proxmox + "pve1": {"addr": "10.0.20.200", "user": "root", "prod": True, "desc": "nod Proxmox pve1"}, + "pvemini": {"addr": "10.0.20.201", "user": "root", "prod": True, "desc": "nod Proxmox principal"}, + "pveelite": {"addr": "10.0.20.202", "user": "root", "prod": True, "desc": "nod Proxmox pveelite"}, + # servicii interne + "oracle": {"addr": "10.0.20.121", "user": "root", "prod": False, "desc": "LXC 108 Oracle XE 21c/18c"}, + "flowise": {"addr": "10.0.20.161", "user": "root", "prod": False, "desc": "LXC 104 Flowise"}, + "gitea": {"addr": "10.0.20.165", "user": "root", "prod": False, "desc": "LXC Gitea"}, + "docker": {"addr": "10.0.20.113", "user": "root", "prod": False, "desc": "LXC 102 Docker + Portainer"}, + "moltbot": {"addr": "10.0.20.173", "user": "root", "prod": False, "desc": "LXC 110 MoltBot"}, + "dokploy": {"addr": "10.0.20.167", "user": "root", "prod": False, "desc": "LXC 103 Dokploy"}, + # productie / clienti + "oracle-prod": {"addr": "10.0.20.36", "user": "romfast", "prod": True, "desc": "server Oracle de PRODUCTIE"}, + "oracle-dr": {"addr": "10.0.20.37", "user": "romfast", "prod": True, "desc": "server Oracle DR"}, + "roacentral": {"addr": "10.0.20.122", "user": "romfast", "prod": True, "desc": "VM 201 Windows, IIS reverse proxy"}, + "oracle-test": {"addr": "10.0.20.130", "user": "romfast", "prod": False, "desc": "VM 302 mediu de test"}, +} + +SSH_OPTS = [ + "-o", "BatchMode=yes", + "-o", "StrictHostKeyChecking=accept-new", + "-o", "ConnectTimeout=10", +] + + +def state_dir() -> pathlib.Path: + override = os.environ.get("CLAUDE_DISCORD_DIR") + if override: + return pathlib.Path(override) + return pathlib.Path.home() / ".claude-discord" + + +def hosts_file() -> pathlib.Path: + return state_dir() / "infra-hosts.json" + + +def log_file() -> pathlib.Path: + return state_dir() / "logs" / "infra.log" + + +def load_hosts() -> dict[str, dict]: + """Lista de hosturi: fisierul de pe disc daca exista, altfel cea implicita.""" + path = hosts_file() + if not path.is_file(): + return dict(DEFAULT_HOSTS) + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError) as exc: + raise SystemExit(_fail(EXIT_CONFIG, f"infra: {path} nu e JSON valid ({exc})")) + if not isinstance(data, dict) or not data: + raise SystemExit(_fail(EXIT_CONFIG, f"infra: {path} nu contine hosturi")) + out: dict[str, dict] = {} + for name, spec in data.items(): + if isinstance(spec, str): + spec = {"addr": spec} + if not isinstance(spec, dict) or not spec.get("addr"): + raise SystemExit(_fail(EXIT_CONFIG, f"infra: intrare invalida pentru '{name}'")) + out[str(name)] = { + "addr": str(spec["addr"]), + "user": str(spec.get("user") or "root"), + "prod": bool(spec.get("prod", False)), + "desc": str(spec.get("desc") or ""), + } + return out + + +def _fail(code: int, msg: str) -> int: + sys.stderr.write(msg + "\n") + return code + + +def log(host: str, target: str, cmd: list[str], rc, dur_s: float, note: str = "") -> None: + """Jurnal pe o linie: data, host, comanda completa, rezultat.""" + line = ( + f"{datetime.datetime.now().isoformat(timespec='seconds')}\t" + f"host={host}\ttarget={target}\trc={rc}\tdur={dur_s:.2f}s\t" + f"cmd={shlex.join(cmd) if cmd else ''}" + ) + if note: + line += f"\tnote={note}" + try: + path = log_file() + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "a", encoding="utf-8") as fh: + fh.write(line + "\n") + except OSError: + pass # un jurnal care nu se poate scrie nu opreste comanda + + +def usage(hosts: dict[str, dict]) -> str: + lines = ["infra ", "", "Hosturi permise:"] + for name, spec in sorted(hosts.items()): + flag = " [PRODUCTIE]" if spec.get("prod") else "" + lines.append(f" {name:<13} {spec['user']}@{spec['addr']:<13} {spec.get('desc','')}{flag}") + lines.append("") + lines.append(f"Lista se poate inlocui prin {hosts_file()}") + return "\n".join(lines) + + +def main(argv: list[str]) -> int: + hosts = load_hosts() + + if not argv or argv[0] in ("-h", "--help"): + print(usage(hosts)) + return 0 if argv else EXIT_USAGE + if argv[0] in ("-l", "--list"): + print(usage(hosts)) + return 0 + + host = argv[0] + cmd = argv[1:] + + if host not in hosts: + log(host, "-", cmd, "refuzat", 0.0, note="host in afara listei") + return _fail( + EXIT_UNKNOWN_HOST, + f"infra: host necunoscut '{host}'. Hosturi permise: " + + ", ".join(sorted(hosts)), + ) + if not cmd: + return _fail(EXIT_USAGE, f"infra: lipseste comanda pentru '{host}'") + + spec = hosts[host] + target = f"{spec['user']}@{spec['addr']}" + ssh_argv = ["ssh", *SSH_OPTS, target, "--", *cmd] + + if os.environ.get("INFRA_DRY_RUN"): + log(host, target, cmd, "dry-run", 0.0, note="INFRA_DRY_RUN") + print(shlex.join(ssh_argv)) + return 0 + + t0 = time.monotonic() + try: + proc = subprocess.run(ssh_argv) + rc = proc.returncode + except FileNotFoundError: + log(host, target, cmd, "eroare", time.monotonic() - t0, note="ssh lipseste") + return _fail(EXIT_CONFIG, "infra: `ssh` nu exista in PATH") + except KeyboardInterrupt: + log(host, target, cmd, "intrerupt", time.monotonic() - t0) + return 130 + log(host, target, cmd, rc, time.monotonic() - t0) + return rc + + +if __name__ == "__main__": + try: + sys.exit(main(sys.argv[1:])) + except SystemExit: + raise + except Exception as exc: # nimic nu iese neraportat + sys.stderr.write(f"infra: eroare interna: {exc}\n") + sys.exit(1) diff --git a/proxmox/lxc171-claude-agent/discord-bridge/session_store.py b/proxmox/lxc171-claude-agent/discord-bridge/session_store.py new file mode 100644 index 0000000..b9eb7f3 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/session_store.py @@ -0,0 +1,329 @@ +"""Starea sesiunilor: state.json, scriere atomica, lock per fir, PID reuse, recovery. + +Autoritatea pe schema din INTERFACES.md o are acest modul (T7 + partea de nucleu a T5). +""" + +from __future__ import annotations + +import asyncio +import contextlib +import datetime as _dt +import fcntl +import json +import logging +import os +import time +from typing import Any, Callable + +import config + +log = logging.getLogger("discord-bridge.state") + +# Lane C poate lipsi cand rulam nucleul singur; import tolerant, fara varianta proprie. +try: # pragma: no cover - depinde de ordinea de merge a lane-urilor + import alerts # type: ignore +except ImportError: # pragma: no cover + class _NoAlerts: + @staticmethod + def alert(level: str, subject: str, body: str, dedup_key: str | None = None) -> None: + log.warning("alerta (%s) %s: %s", level, subject, body) + + alerts = _NoAlerts() # type: ignore + +VERSION = 1 +_CLOCK_TICKS = os.sysconf("SC_CLK_TCK") if hasattr(os, "sysconf") else 100.0 + + +# ------------------------------------------------------------------ procese +def pid_start_time(pid: int) -> float | None: + """Campul 22 din /proc//stat (starttime, in secunde de la boot). + + Il folosim ca semnatura a procesului: un PID reciclat are alt starttime. + """ + try: + with open(f"/proc/{int(pid)}/stat", "rb") as fh: + data = fh.read().decode("utf-8", "replace") + except (OSError, ValueError): + return None + # comm poate contine spatii si paranteze -> taiem dupa ultima ')' + close = data.rfind(")") + if close < 0: + return None + fields = data[close + 2:].split() + # dupa comm, fields[0] este campul 3 (state); campul 22 e fields[19] + if len(fields) < 20: + return None + try: + return int(fields[19]) / float(_CLOCK_TICKS) + except (TypeError, ValueError): + return None + + +def pid_alive(pid: int | None, start_time: float | None = None) -> bool: + """True doar daca PID-ul traieste SI (optional) are acelasi starttime.""" + if not pid: + return False + cur = pid_start_time(int(pid)) + if cur is None: + return False + if start_time is None: + return True + return abs(cur - float(start_time)) < 0.05 + + +def today() -> str: + return _dt.date.today().isoformat() + + +def new_thread_record(cwd: str | None = None, model: str | None = None) -> dict[str, Any]: + return { + "sid": None, + "cwd": cwd or config.get("DEFAULT_CWD", "/workspace"), + "model": model or config.get("MODEL_DEFAULT", "sonnet"), + "pid": None, + "pid_start_time": None, + "inflight": None, + "cost_usd_total": 0.0, + "last_active": 0.0, + } + + +def empty_state() -> dict[str, Any]: + return {"version": VERSION, "threads": {}, "cost": {"day": today(), "usd": 0.0}} + + +# ------------------------------------------------------------------- store +class SessionStore: + """state.json cu scriere atomica, lock de fisier intre procese si lock per fir.""" + + def __init__(self, path: os.PathLike | str | None = None, alerter: Callable | None = None): + self.path = os.fspath(path) if path else os.fspath(config.STATE_FILE) + self.lock_path = self.path + ".lock" + self._state: dict[str, Any] = empty_state() + self._thread_locks: dict[str, asyncio.Lock] = {} + self._write_lock = asyncio.Lock() + self._alert = alerter or alerts.alert + self.loaded = False + + # ------------------------------------------------------------ interne + def _dir(self) -> str: + return os.path.dirname(os.path.abspath(self.path)) or "." + + @contextlib.contextmanager + def _file_lock(self): + os.makedirs(self._dir(), exist_ok=True) + fh = open(self.lock_path, "a+") + try: + fcntl.flock(fh.fileno(), fcntl.LOCK_EX) + yield + finally: + with contextlib.suppress(OSError): + fcntl.flock(fh.fileno(), fcntl.LOCK_UN) + fh.close() + + def _quarantine(self, reason: str) -> None: + stamp = time.strftime("%Y%m%d-%H%M%S") + dest = f"{self.path}.corrupt-{stamp}" + try: + os.replace(self.path, dest) + except OSError as exc: + dest = f"(nu am putut muta: {exc})" + log.error("state.json corupt (%s), salvat ca %s", reason, dest) + try: + self._alert( + "CRITICAL", + "state.json corupt", + f"Motiv: {reason}\nFisierul stricat: {dest}\nPuntea reporneste de la stare goala.", + "state-corrupt", + ) + except Exception: # o alerta esuata nu are voie sa doboare botul + log.exception("alerta pentru state corupt a esuat") + + def _normalize(self, data: Any) -> dict[str, Any]: + if not isinstance(data, dict) or not isinstance(data.get("threads"), dict): + raise ValueError("structura neasteptata") + state = empty_state() + state["version"] = data.get("version", VERSION) + cost = data.get("cost") + if isinstance(cost, dict): + state["cost"] = { + "day": str(cost.get("day") or today()), + "usd": float(cost.get("usd") or 0.0), + } + for tid, rec in data["threads"].items(): + base = new_thread_record() + if isinstance(rec, dict): + base.update({k: v for k, v in rec.items() if k in base}) + state["threads"][str(tid)] = base + return state + + # ------------------------------------------------------------- publice + def load(self) -> dict[str, Any]: + """Incarca starea. La fisier corupt: quarantine + alerta + stare goala.""" + with self._file_lock(): + try: + raw = open(self.path, "rb").read() + except FileNotFoundError: + self._state = empty_state() + self.loaded = True + return self._state + except OSError as exc: + log.error("nu pot citi %s: %s", self.path, exc) + self._state = empty_state() + self.loaded = True + return self._state + try: + self._state = self._normalize(json.loads(raw.decode("utf-8"))) + except Exception as exc: + self._quarantine(str(exc)) + self._state = empty_state() + self._write_locked() + self.loaded = True + return self._state + + def _write_locked(self) -> None: + """Scriere atomica: tmp in acelasi director + fsync + os.replace + fsync dir.""" + d = self._dir() + os.makedirs(d, exist_ok=True) + tmp = os.path.join(d, f".state.{os.getpid()}.tmp") + payload = json.dumps(self._state, indent=2, sort_keys=True).encode("utf-8") + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + try: + os.write(fd, payload) + os.fsync(fd) + finally: + os.close(fd) + os.replace(tmp, self.path) + dfd = os.open(d, os.O_RDONLY) + try: + os.fsync(dfd) + finally: + os.close(dfd) + + def save(self) -> None: + with self._file_lock(): + self._write_locked() + + @property + def state(self) -> dict[str, Any]: + return self._state + + def lock_for(self, thread_id: str) -> asyncio.Lock: + """Lock per fir (in proces). Intre procese lucreaza flock-ul de fisier.""" + key = str(thread_id) + lock = self._thread_locks.get(key) + if lock is None: + lock = self._thread_locks[key] = asyncio.Lock() + return lock + + def thread(self, thread_id: str, create: bool = True) -> dict[str, Any] | None: + key = str(thread_id) + rec = self._state["threads"].get(key) + if rec is None and create: + rec = self._state["threads"][key] = new_thread_record() + return rec + + def update_thread(self, thread_id: str, **fields) -> dict[str, Any]: + """Modifica un fir si scrie atomic. Apeleaza-l sub `lock_for(thread_id)`.""" + rec = self.thread(thread_id) + assert rec is not None + rec.update(fields) + rec["last_active"] = fields.get("last_active", time.time()) + self.save() + return rec + + def set_pid(self, thread_id: str, pid: int | None) -> dict[str, Any]: + st = pid_start_time(pid) if pid else None + return self.update_thread(thread_id, pid=pid, pid_start_time=st) + + def thread_process_alive(self, thread_id: str) -> bool: + rec = self.thread(thread_id, create=False) + if not rec: + return False + return pid_alive(rec.get("pid"), rec.get("pid_start_time")) + + # ------------------------------------------------------------ inflight + def set_inflight(self, thread_id: str, turn_id: str, user_id: str, message_id: str) -> dict: + return self.update_thread( + thread_id, + inflight={ + "turn_id": turn_id, + "started_at": time.time(), + "user_id": str(user_id), + "message_id": str(message_id), + }, + ) + + def clear_inflight(self, thread_id: str) -> dict: + return self.update_thread(thread_id, inflight=None) + + def is_inflight(self, thread_id: str) -> bool: + rec = self.thread(thread_id, create=False) + return bool(rec and rec.get("inflight")) + + def sweep_lost_turns(self) -> list[dict[str, Any]]: + """T5: la pornire, orice fir cu `inflight` al carui proces nu mai e al nostru + primeste turul marcat drept PIERDUT. Fara reluare automata (dubla executie). + """ + lost: list[dict[str, Any]] = [] + changed = False + for tid, rec in self._state["threads"].items(): + inflight = rec.get("inflight") + if not inflight: + continue + if pid_alive(rec.get("pid"), rec.get("pid_start_time")): + continue # procesul nostru inca traieste: turul e viu + lost.append( + { + "thread_id": tid, + "turn_id": inflight.get("turn_id"), + "user_id": inflight.get("user_id"), + "message_id": inflight.get("message_id"), + "started_at": inflight.get("started_at"), + "warning": ( + "Turul anterior s-a pierdut (botul a fost repornit sau procesul a murit). " + "NU il reiau automat, ca sa nu se execute de doua ori. Trimite-l din nou daca mai e nevoie." + ), + } + ) + rec["inflight"] = None + rec["pid"] = None + rec["pid_start_time"] = None + changed = True + if changed: + self.save() + try: + self._alert( + "WARN", + "tururi pierdute la pornire", + "Fire afectate: " + ", ".join(x["thread_id"] for x in lost), + "sweep-lost", + ) + except Exception: + log.exception("alerta pentru sweep a esuat") + return lost + + # ---------------------------------------------------------------- cost + def roll_day(self) -> None: + cost = self._state.setdefault("cost", {"day": today(), "usd": 0.0}) + if cost.get("day") != today(): + cost["day"] = today() + cost["usd"] = 0.0 + + def add_cost(self, thread_id: str | None, usd: float) -> float: + """Adauga costul unui tur; returneaza totalul pe ziua curenta.""" + try: + usd = float(usd) + except (TypeError, ValueError): + usd = 0.0 + self.roll_day() + self._state["cost"]["usd"] = round(self._state["cost"]["usd"] + usd, 6) + if thread_id is not None: + rec = self.thread(thread_id) + rec["cost_usd_total"] = round(float(rec.get("cost_usd_total") or 0.0) + usd, 6) + self.save() + return self._state["cost"]["usd"] + + def cost_today(self) -> float: + self.roll_day() + return float(self._state["cost"]["usd"]) diff --git a/proxmox/lxc171-claude-agent/discord-bridge/stream.py b/proxmox/lxc171-claude-agent/discord-bridge/stream.py new file mode 100644 index 0000000..c752e13 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/stream.py @@ -0,0 +1,260 @@ +"""Parser tolerant pentru `claude --output-format stream-json --verbose`. + +Reguli (T6): +- tip necunoscut -> se logeaza O SINGURA DATA per tip, cu versiunea CLI, si se ignora +- linie non-JSON -> se logeaza si se ignora +- EOF inainte de `result` -> StreamEOFError, explicit, fara hang +Parser-ul NU arunca niciodata pe continut de stream; singura exceptie e EOF-ul de mai sus. +""" + +from __future__ import annotations + +import json +import logging +import subprocess +from dataclasses import dataclass, field +from typing import Any, AsyncIterator, Iterable + +log = logging.getLogger("discord-bridge.stream") + +_cli_version_cache: str | None = None + + +def cli_version() -> str: + """`claude --version`, cu cache si tolerant la orice esec.""" + global _cli_version_cache + if _cli_version_cache is None: + try: + out = subprocess.run( + ["claude", "--version"], capture_output=True, text=True, timeout=10 + ) + _cli_version_cache = (out.stdout or out.stderr).strip() or "necunoscuta" + except Exception: + _cli_version_cache = "necunoscuta" + return _cli_version_cache + + +class StreamEOFError(RuntimeError): + """Stream-ul s-a terminat inainte de evenimentul `result`.""" + + +# ---------------------------------------------------------------- evenimente +@dataclass(frozen=True) +class SystemInit: + session_id: str | None + model: str | None + cwd: str | None + tools: tuple[str, ...] = () + raw: dict = field(default_factory=dict, repr=False) + + +@dataclass(frozen=True) +class AssistantText: + text: str + session_id: str | None = None + raw: dict = field(default_factory=dict, repr=False) + + +@dataclass(frozen=True) +class ToolUse: + name: str + tool_id: str | None + input: dict = field(default_factory=dict) + session_id: str | None = None + raw: dict = field(default_factory=dict, repr=False) + + +@dataclass(frozen=True) +class ToolResult: + tool_id: str | None + text: str + is_error: bool = False + session_id: str | None = None + raw: dict = field(default_factory=dict, repr=False) + + +@dataclass(frozen=True) +class Result: + total_cost_usd: float + duration_ms: int + is_error: bool + num_turns: int + text: str = "" + session_id: str | None = None + raw: dict = field(default_factory=dict, repr=False) + + +Event = SystemInit | AssistantText | ToolUse | ToolResult | Result + + +def _blocks(msg: Any) -> list[dict]: + if not isinstance(msg, dict): + return [] + content = msg.get("content") + if isinstance(content, str): + return [{"type": "text", "text": content}] + if isinstance(content, list): + return [b for b in content if isinstance(b, dict)] + return [] + + +def _as_text(value: Any) -> str: + if isinstance(value, str): + return value + if isinstance(value, list): + parts = [] + for b in value: + if isinstance(b, dict) and isinstance(b.get("text"), str): + parts.append(b["text"]) + elif isinstance(b, str): + parts.append(b) + return "".join(parts) + if value is None: + return "" + return str(value) + + +class StreamParser: + """Transforma linii JSONL in evenimente normalizate. Stateful doar pentru logare.""" + + def __init__(self, version_fn=None, logger: logging.Logger | None = None): + # rezolvat la apel, ca sa poata fi inlocuit in teste + self._version_fn = version_fn or (lambda: cli_version()) + self._log = logger or log + self.unknown_types: set[str] = set() + self.bad_lines = 0 + self.saw_result = False + self.session_id: str | None = None + + # ---------------------------------------------------------------- feed + def feed_line(self, line: str) -> list[Event]: + """Returneaza 0..n evenimente pentru o linie. Nu arunca niciodata.""" + try: + return self._feed_line(line) + except Exception as exc: # plasa de siguranta: nimic din stream nu doboara botul + self._log.warning("stream: linie neasteptata ignorata: %r (%s)", line[:200], exc) + return [] + + def _feed_line(self, line: str) -> list[Event]: + text = line.strip() + if not text: + return [] + try: + obj = json.loads(text) + except (ValueError, TypeError): + self.bad_lines += 1 + self._log.warning("stream: linie non-JSON ignorata: %r", text[:200]) + return [] + if not isinstance(obj, dict): + self.bad_lines += 1 + self._log.warning("stream: JSON care nu e obiect, ignorat: %r", text[:200]) + return [] + + typ = obj.get("type") + sid = obj.get("session_id") + if isinstance(sid, str) and sid: + self.session_id = sid + + if typ == "system": + if obj.get("subtype") == "init": + tools = obj.get("tools") + return [ + SystemInit( + session_id=sid if isinstance(sid, str) else None, + model=obj.get("model"), + cwd=obj.get("cwd"), + tools=tuple(t for t in tools if isinstance(t, str)) if isinstance(tools, list) else (), + raw=obj, + ) + ] + # alte subtipuri de system (compact_boundary etc) -> zgomot, ignorat + return [] + + if typ == "assistant": + events: list[Event] = [] + for b in _blocks(obj.get("message")): + if b.get("type") == "text" and b.get("text"): + events.append(AssistantText(text=str(b["text"]), session_id=self.session_id, raw=obj)) + elif b.get("type") == "tool_use": + events.append( + ToolUse( + name=str(b.get("name") or "?"), + tool_id=b.get("id"), + input=b.get("input") if isinstance(b.get("input"), dict) else {}, + session_id=self.session_id, + raw=obj, + ) + ) + return events + + if typ == "user": + events = [] + for b in _blocks(obj.get("message")): + if b.get("type") == "tool_result": + events.append( + ToolResult( + tool_id=b.get("tool_use_id"), + text=_as_text(b.get("content")), + is_error=bool(b.get("is_error")), + session_id=self.session_id, + raw=obj, + ) + ) + return events + + if typ == "result": + self.saw_result = True + try: + cost = float(obj.get("total_cost_usd") or 0.0) + except (TypeError, ValueError): + cost = 0.0 + try: + dur = int(obj.get("duration_ms") or 0) + except (TypeError, ValueError): + dur = 0 + try: + turns = int(obj.get("num_turns") or 0) + except (TypeError, ValueError): + turns = 0 + return [ + Result( + total_cost_usd=cost, + duration_ms=dur, + is_error=bool(obj.get("is_error")) or obj.get("subtype") not in (None, "success"), + num_turns=turns, + text=_as_text(obj.get("result")), + session_id=self.session_id, + raw=obj, + ) + ] + + key = str(typ) + if key not in self.unknown_types: + self.unknown_types.add(key) + self._log.warning( + "stream: tip necunoscut %r ignorat (claude %s); exemplu: %r", + key, self._version_fn(), text[:200], + ) + return [] + + # ------------------------------------------------------------ iteratoare + def feed_lines(self, lines: Iterable[str]) -> list[Event]: + out: list[Event] = [] + for line in lines: + out.extend(self.feed_line(line)) + return out + + async def aiter_events(self, lines: AsyncIterator[str | bytes]) -> AsyncIterator[Event]: + """Consuma un iterator asincron de linii; ridica StreamEOFError daca + stream-ul se termina inainte de `result`.""" + self.saw_result = False + async for raw in lines: + if isinstance(raw, bytes): + raw = raw.decode("utf-8", "replace") + for ev in self.feed_line(raw): + yield ev + if isinstance(ev, Result): + return + raise StreamEOFError( + "stream-ul claude s-a terminat inainte de evenimentul `result`" + ) diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/__init__.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/conftest.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/conftest.py new file mode 100644 index 0000000..12bf79d --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/conftest.py @@ -0,0 +1,64 @@ +"""Fixturi comune: totul in tmp, zero retea, zero Discord, zero API.""" + +from __future__ import annotations + +import os +import pathlib +import sys + +import pytest + +ROOT = pathlib.Path(__file__).resolve().parent.parent +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import config # noqa: E402 + +FAKE_CLAUDE = str(pathlib.Path(__file__).resolve().parent / "fake_claude.py") + + +@pytest.fixture(autouse=True) +def state_dir(tmp_path, monkeypatch): + """Muta ~/.claude-discord in tmp pentru fiecare test.""" + d = tmp_path / "claude-discord" + d.mkdir() + monkeypatch.setenv("CLAUDE_DISCORD_DIR", str(d)) + config.reload(d) + config.ensure_dirs() + yield d + config.reload() + + +@pytest.fixture +def store(state_dir): + import session_store + + s = session_store.SessionStore(state_dir / "state.json", alerter=lambda *a, **k: None) + s.load() + return s + + +@pytest.fixture +def fake_bin(): + """Comanda care inlocuieste `claude` in teste.""" + return [sys.executable, FAKE_CLAUDE] + + +@pytest.fixture +def scenario(monkeypatch): + def _set(name: str, **env): + monkeypatch.setenv("FAKE_CLAUDE_SCENARIO", name) + for k, v in env.items(): + monkeypatch.setenv(k, str(v)) + return _set + + +@pytest.fixture(autouse=True) +def _no_real_claude(monkeypatch, request): + """Nicio suita implicita nu are voie sa cheme `claude --version`.""" + if "e2e" in request.keywords: + return + import stream + + monkeypatch.setattr(stream, "_cli_version_cache", "test-cli", raising=False) + monkeypatch.setattr(stream, "cli_version", lambda: "test-cli") diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/fake_claude.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/fake_claude.py new file mode 100755 index 0000000..e2f816b --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/fake_claude.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""CLI `claude` fals pentru teste: citeste JSONL pe stdin, emite JSONL pe stdout. + +Scenariul se alege din FAKE_CLAUDE_SCENARIO: + normal assistant + result pentru fiecare mesaj + unknown tip necunoscut + linie non-JSON inainte de assistant/result + tools tool_use + tool_result + text + result + eof se termina inainte de `result` + crash scrie pe stderr si iese cu cod 1 + env raporteaza CLAUDE_DISCORD_THREAD_ID / _SESSION_ID din mediul propriu + slow "lucreaza" FAKE_CLAUDE_DELAY secunde, colecteaza mesajele venite intre timp + (asa se testeaza steering-ul mid-tur) si le enumera in `result` +""" + +from __future__ import annotations + +import json +import os +import select +import sys +import time + + +def emit(obj) -> None: + sys.stdout.write(json.dumps(obj, ensure_ascii=False) + "\n") + sys.stdout.flush() + + +def parse_args(argv): + sid, model = None, "sonnet" + for i, a in enumerate(argv): + if a == "--resume" and i + 1 < len(argv): + sid = argv[i + 1] + elif a == "--model" and i + 1 < len(argv): + model = argv[i + 1] + return sid, model + + +def text_of(line: str) -> str: + try: + obj = json.loads(line) + except ValueError: + return line.strip() + msg = obj.get("message") or {} + content = msg.get("content") + if isinstance(content, list): + return "".join(b.get("text", "") for b in content if isinstance(b, dict)) + return str(content or "") + + +def assistant(sid: str, text: str) -> None: + emit({"type": "assistant", "session_id": sid, + "message": {"role": "assistant", "content": [{"type": "text", "text": text}]}}) + + +def result(sid: str, text: str, ms: int = 10) -> None: + emit({"type": "result", "subtype": "success", "session_id": sid, "is_error": False, + "duration_ms": ms, "num_turns": 1, "result": text, + "total_cost_usd": float(os.environ.get("FAKE_CLAUDE_COST", "0.0123"))}) + + +def drain(deadline: float) -> list[str]: + """Citeste orice mesaj suplimentar pana la deadline (steering mid-tur).""" + extra = [] + while True: + left = deadline - time.monotonic() + if left <= 0: + return extra + r, _, _ = select.select([sys.stdin], [], [], min(left, 0.05)) + if r: + line = sys.stdin.readline() + if not line: + return extra + extra.append(text_of(line)) + + +def main() -> int: + scenario = os.environ.get("FAKE_CLAUDE_SCENARIO", "normal") + sid, model = parse_args(sys.argv[1:]) + sid = sid or os.environ.get("FAKE_CLAUDE_SID", "sid-fake-0001") + emit({"type": "system", "subtype": "init", "session_id": sid, "model": model, + "cwd": os.getcwd(), "tools": ["Bash", "Read"]}) + if scenario == "crash": + sys.stderr.write("fake_claude: boom, ies cu 1\n") + sys.stderr.flush() + return 1 + + for line in sys.stdin: + prompt = text_of(line) + if scenario == "unknown": + emit({"type": "rate_limit_event", "session_id": sid, "detail": "test"}) + sys.stdout.write("asta nu e JSON\n") + sys.stdout.flush() + if scenario == "tools": + emit({"type": "assistant", "session_id": sid, "message": {"role": "assistant", "content": [ + {"type": "tool_use", "id": "tu_1", "name": "Bash", "input": {"command": "ls"}}]}}) + emit({"type": "user", "session_id": sid, "message": {"role": "user", "content": [ + {"type": "tool_result", "tool_use_id": "tu_1", "content": "fisier1\n"}]}}) + if scenario == "eof": + assistant(sid, "incep si mor") + return 0 + if scenario == "slow": + delay = float(os.environ.get("FAKE_CLAUDE_DELAY", "1.0")) + assistant(sid, "lucrez...") + extra = drain(time.monotonic() + delay) + msgs = [prompt] + extra + result(sid, "mesaje primite: " + " | ".join(msgs), int(delay * 1000)) + continue + if scenario == "env": + assistant(sid, "raportez mediul") + result(sid, "thread={} sesiune={} marker={}".format( + os.environ.get("CLAUDE_DISCORD_THREAD_ID", "-"), + os.environ.get("CLAUDE_DISCORD_SESSION_ID", "-"), + os.environ.get("MARKER_DE_TEST", "-"))) + continue + assistant(sid, f"ecou: {prompt}") + result(sid, f"ecou: {prompt}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_alerts.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_alerts.py new file mode 100644 index 0000000..2463a3c --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_alerts.py @@ -0,0 +1,281 @@ +"""Teste pentru alerts.py (T12). Fara retea, fara email real.""" + +import os +import pathlib +import sys +import time + +import pytest + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +import alerts # noqa: E402 + + +@pytest.fixture() +def sandbox(tmp_path, monkeypatch): + """Muta starea si logurile in tmp, si inlocuieste trimiterea reala de email.""" + monkeypatch.setattr(alerts, "STATE_DIR", tmp_path) + monkeypatch.setattr(alerts, "LOG_DIR", tmp_path / "logs") + monkeypatch.setattr(alerts, "DEDUP_FILE", tmp_path / "alerts-dedup.json") + monkeypatch.setattr(alerts, "LOG_FILE", tmp_path / "logs" / "alerts.log") + + sent = [] + real_send = alerts._send_mail + + def fake_send(subject_line, body): + sent.append((subject_line, body)) + return True, "trimis (fals)" + + monkeypatch.setattr(alerts, "_send_mail", fake_send) + return {"dir": tmp_path, "sent": sent, "real_send": real_send} + + +def _log_text(sandbox): + path = sandbox["dir"] / "logs" / "alerts.log" + return path.read_text() if path.exists() else "" + + +# --- format ---------------------------------------------------------------- + +def test_subiect_are_prefixul_de_nivel(sandbox): + alerts.alert("CRITICAL", "state.json corupt", "detalii") + assert sandbox["sent"][0][0] == "[CRITICAL] state.json corupt" + + +def test_nivel_necunoscut_devine_warn(sandbox): + alerts.alert("chestii", "ceva", "corp") + assert sandbox["sent"][0][0].startswith("[WARN] ") + + +def test_nivel_case_insensitive(sandbox): + alerts.alert("info", "ceva", "corp") + assert sandbox["sent"][0][0].startswith("[INFO] ") + + +def test_corpul_contine_contextul_de_host_si_sursa(sandbox): + alerts.alert("WARN", "subiect", "linia mea") + body = sandbox["sent"][0][1] + assert "linia mea" in body + assert "Host:" in body and "Sursa:" in body + + +def test_corp_urias_e_trunchiat(sandbox): + alerts.alert("WARN", "s", "x" * (alerts.MAX_BODY_CHARS + 5000)) + assert "corp trunchiat" in sandbox["sent"][0][1] + + +def test_subiect_multiline_e_pliat_pe_o_linie(sandbox): + alerts.alert("WARN", "prima\nlinia a doua", "corp") + assert "\n" not in sandbox["sent"][0][0] + + +# --- dedup ----------------------------------------------------------------- + +def test_dedup_blocheaza_a_doua_alerta_cu_aceeasi_cheie(sandbox): + alerts.alert("WARN", "proces mort", "a", dedup_key="proc-died:42") + alerts.alert("WARN", "proces mort", "b", dedup_key="proc-died:42") + assert len(sandbox["sent"]) == 1 + assert "SKIP(dedup" in _log_text(sandbox) + + +def test_dedup_e_pe_cheie_nu_pe_subiect(sandbox): + alerts.alert("WARN", "proces mort", "a", dedup_key="proc-died:1") + alerts.alert("WARN", "proces mort", "b", dedup_key="proc-died:2") + assert len(sandbox["sent"]) == 2 + + +def test_fara_dedup_key_se_trimite_de_fiecare_data(sandbox): + for _ in range(3): + alerts.alert("INFO", "acelasi subiect", "corp") + assert len(sandbox["sent"]) == 3 + + +def test_dedup_expira_dupa_o_ora(sandbox, monkeypatch): + alerts.alert("WARN", "cost", "a", dedup_key="cost-cap") + assert len(sandbox["sent"]) == 1 + + real_time = time.time + + monkeypatch.setattr(alerts.time, "time", lambda: real_time() + alerts.DEDUP_WINDOW_S + 10) + alerts.alert("WARN", "cost", "b", dedup_key="cost-cap") + assert len(sandbox["sent"]) == 2 + + +def test_dedup_persistat_pe_disc(sandbox): + alerts.alert("WARN", "x", "y", dedup_key="k") + assert (sandbox["dir"] / "alerts-dedup.json").exists() + + +def test_dedup_curata_intrarile_expirate(sandbox): + import json + vechi = {"expirat": time.time() - 2 * alerts.DEDUP_WINDOW_S} + (sandbox["dir"] / "alerts-dedup.json").write_text(json.dumps(vechi)) + alerts.alert("WARN", "x", "y", dedup_key="nou") + data = json.loads((sandbox["dir"] / "alerts-dedup.json").read_text()) + assert "expirat" not in data and "nou" in data + + +def test_dedup_corupt_nu_opreste_alerta(sandbox): + (sandbox["dir"] / "alerts-dedup.json").write_text("{{{ nu e json") + alerts.alert("CRITICAL", "important", "corp", dedup_key="k") + assert len(sandbox["sent"]) == 1 + + +# --- degradare si robustete ------------------------------------------------ + +def test_lipsa_binarului_mail_nu_arunca_si_ramane_in_log(sandbox, monkeypatch): + # aici vrem trimiterea REALA, ca sa vedem degradarea cand `mail` lipseste + monkeypatch.setattr(alerts, "_send_mail", sandbox["real_send"]) + monkeypatch.setattr(alerts, "_mail_binary", lambda: None) + + alerts.alert("CRITICAL", "fara mail", "corpul contine ceva important") + + text = _log_text(sandbox) + assert "NESENT" in text + assert "binarul `mail` lipseste" in text + assert "corpul contine ceva important" in text + + +def test_esecul_mail_nu_arunca(sandbox, monkeypatch): + monkeypatch.setattr(alerts, "_send_mail", lambda s, b: (False, "cod 1")) + alerts.alert("WARN", "esec", "corp") # nu trebuie sa arunce + assert "NESENT" in _log_text(sandbox) + + +def test_exceptie_interna_nu_scapa_din_alert(sandbox, monkeypatch): + def explodeaza(*args, **kwargs): + raise RuntimeError("boom") + + monkeypatch.setattr(alerts, "_send_mail", explodeaza) + alerts.alert("CRITICAL", "boom", "corp") # contractul: NU arunca niciodata + + +def test_alert_nu_arunca_nici_cand_logul_e_inaccesibil(sandbox, monkeypatch): + # LOG_DIR indica un fisier, deci mkdir si open esueaza amandoua + fisier = sandbox["dir"] / "blocaj" + fisier.write_text("x") + monkeypatch.setattr(alerts, "LOG_DIR", fisier / "logs") + monkeypatch.setattr(alerts, "LOG_FILE", fisier / "logs" / "alerts.log") + monkeypatch.setattr(alerts, "_send_mail", lambda s, b: (False, "nimic")) + alerts.alert("WARN", "x", "y") + + +def test_argumente_aiurea_nu_arunca(sandbox): + alerts.alert(None, None, None) # type: ignore[arg-type] + alerts.alert("", "", "", dedup_key="") + alerts.alert(123, 456, 789) # type: ignore[arg-type] + + +# --- destinatar ------------------------------------------------------------ + +def test_destinatar_implicit_root(monkeypatch): + monkeypatch.delenv("ALERT_RECIPIENT", raising=False) + monkeypatch.setattr(alerts, "_config", None) + assert alerts._recipient() == "root" + + +def test_destinatar_din_mediu(monkeypatch): + monkeypatch.setattr(alerts, "_config", None) + monkeypatch.setenv("ALERT_RECIPIENT", "ops@romfast.ro") + assert alerts._recipient() == "ops@romfast.ro" + + +def test_destinatar_din_config_daca_exista(monkeypatch): + class FakeConfig: + @staticmethod + def get(key, default=None): + return "din-config@romfast.ro" if key == "ALERT_RECIPIENT" else default + + monkeypatch.setattr(alerts, "_config", FakeConfig) + assert alerts._recipient() == "din-config@romfast.ro" + + +def test_config_care_arunca_nu_rupe_destinatarul(monkeypatch): + class BadConfig: + @staticmethod + def get(key, default=None): + raise RuntimeError("config stricat") + + monkeypatch.setattr(alerts, "_config", BadConfig) + monkeypatch.delenv("ALERT_RECIPIENT", raising=False) + assert alerts._recipient() == "root" + + +# --- ajutoarele pentru conditiile Lane A ---------------------------------- + +def test_ajutoarele_produc_alerte_cu_dedup(sandbox): + alerts.alert_process_died("123", 999, "SIGKILL") + alerts.alert_crash_loop("123", 5, 300) + alerts.alert_cost_cap(5.12, 5.00) + alerts.alert_state_corrupt("/tmp/state.json") + alerts.alert_orphans([{"pid": 1, "cmdline": "claude", "age_s": 10, "rss_mb": 406}]) + assert len(sandbox["sent"]) == 5 + niveluri = [s[0].split("]")[0] + "]" for s in sandbox["sent"]] + assert "[CRITICAL]" in niveluri and "[WARN]" in niveluri + + # a doua oara acelasi lucru nu se retrimite + alerts.alert_cost_cap(5.20, 5.00) + assert len(sandbox["sent"]) == 5 + + +def test_alert_orphans_cu_lista_goala_nu_arunca(sandbox): + alerts.alert_orphans([]) + assert len(sandbox["sent"]) == 1 + + +# --- integrare reala cu subprocess (fara retea) --------------------------- + +def test_send_mail_foloseste_argumentele_corecte(tmp_path, monkeypatch): + """Verifica linia de comanda construita, cu un `mail` fals scris pe disc.""" + fake = tmp_path / "mail" + marker = tmp_path / "primit.txt" + fake.write_text( + "#!/bin/sh\n" + f'{{ echo "ARGS: $@"; cat; }} > "{marker}"\n' + ) + fake.chmod(0o755) + + monkeypatch.setattr(alerts, "_mail_binary", lambda: str(fake)) + monkeypatch.setattr(alerts, "_config", None) + monkeypatch.setenv("ALERT_RECIPIENT", "test@local") + + ok, detail = alerts._send_mail("[WARN] subiect de test", "corp de test") + assert ok, detail + + text = marker.read_text() + assert "ARGS: -s [WARN] subiect de test test@local" in text + assert "corp de test" in text + + +def test_send_mail_cod_de_eroare_e_raportat(tmp_path, monkeypatch): + fake = tmp_path / "mail" + fake.write_text("#!/bin/sh\necho 'MTA indisponibil'\nexit 75\n") + fake.chmod(0o755) + monkeypatch.setattr(alerts, "_mail_binary", lambda: str(fake)) + ok, detail = alerts._send_mail("[WARN] x", "y") + assert not ok and "75" in detail and "MTA indisponibil" in detail + + +def test_send_mail_timeout_e_abandonat(tmp_path, monkeypatch): + fake = tmp_path / "mail" + fake.write_text("#!/bin/sh\nsleep 30\n") + fake.chmod(0o755) + monkeypatch.setattr(alerts, "_mail_binary", lambda: str(fake)) + monkeypatch.setattr(alerts, "MAIL_TIMEOUT_S", 0.5) + ok, detail = alerts._send_mail("[WARN] x", "y") + assert not ok and "depasit" in detail + + +def test_send_mail_binar_inexistent_nu_arunca(monkeypatch): + monkeypatch.setattr(alerts, "_mail_binary", lambda: "/nu/exista/mail") + ok, detail = alerts._send_mail("[WARN] x", "y") + assert not ok and "nu a putut fi lansat" in detail + + +def test_fisierele_de_stare_sunt_in_state_dir(sandbox): + alerts.alert("INFO", "x", "y", dedup_key="k") + nume = {p.name for p in sandbox["dir"].iterdir()} + assert "alerts-dedup.json" in nume + assert not any(n.endswith(".tmp") for n in nume), "fisier temporar ramas in urma" + assert os.path.exists(sandbox["dir"] / "logs" / "alerts.log") diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_allowlist.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_allowlist.py new file mode 100644 index 0000000..12e451c --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_allowlist.py @@ -0,0 +1,135 @@ +"""T1: filtrul de la intrare. Guild / canal / user + webhook + boti. + +Regula: refuzul e TACUT (nu raspundem, nu reactionam) si fail-closed +(allowlist lipsa sau goala = nimeni nu are voie). +""" + +from __future__ import annotations + +import pytest + +import bot + +try: # rulat ca pachet `tests` sau ca module libere + from tests.test_bot import (CHANNEL, GUILD, USER, FakeChannel, FakeGuild, + FakeMessage, FakeUser, allowed, bridge) +except ImportError: # pragma: no cover + from test_bot import (CHANNEL, GUILD, USER, FakeChannel, FakeGuild, + FakeMessage, FakeUser, allowed, bridge) + +__all__ = ["allowed", "bridge"] # fixturi reexportate + + +def ok(message) -> bool: + return bot.check_message(message, self_id="999").ok + + +def why(message) -> str: + return bot.check_message(message, self_id="999").reason + + +# ------------------------------------------------------------------ acceptare +def test_mesaj_permis(allowed): + assert ok(FakeMessage("salut")) + + +def test_fir_din_canalul_permis_e_acceptat(allowed): + """Un fir Discord are alt id decat canalul; conteaza si parintele.""" + thread = FakeChannel(cid="99999", parent_id=CHANNEL) + assert ok(FakeMessage("salut", channel=thread)) + + +# -------------------------------------------------------------------- refuzuri +def test_webhook_refuzat(allowed): + """Un webhook scurs care posteaza in canal NU comanda nimic.""" + msg = FakeMessage("!cleanup --force", webhook_id="55") + assert not ok(msg) + assert why(msg) == "webhook" + + +def test_webhook_refuzat_chiar_daca_autorul_pare_permis(allowed): + msg = FakeMessage("salut", author=FakeUser(USER), webhook_id="55") + assert why(msg) == "webhook" + + +def test_bot_refuzat(allowed): + msg = FakeMessage("salut", author=FakeUser("40", is_bot=True)) + assert why(msg) == "bot" + + +def test_propriul_mesaj_refuzat(allowed): + msg = FakeMessage("salut", author=FakeUser("999")) + assert why(msg) == "propriul mesaj" + + +def test_guild_strain_refuzat(allowed): + assert "guild" in why(FakeMessage("salut", guild=FakeGuild("777"))) + + +def test_mesaj_privat_refuzat(allowed): + msg = FakeMessage("salut") + msg.guild = None + assert "guild" in why(msg) + + +def test_canal_strain_refuzat(allowed): + assert "canal" in why(FakeMessage("salut", channel=FakeChannel("777"))) + + +def test_fir_cu_parinte_strain_refuzat(allowed): + thread = FakeChannel(cid="88888", parent_id="777") + assert "canal" in why(FakeMessage("salut", channel=thread)) + + +def test_utilizator_strain_refuzat(allowed): + assert "utilizator" in why(FakeMessage("salut", author=FakeUser("777"))) + + +# ----------------------------------------------------------------- fail-closed +@pytest.mark.parametrize("lipsa", ["DISCORD_GUILD_IDS", "DISCORD_CHANNEL_IDS", "DISCORD_USER_IDS"]) +def test_allowlist_incompleta_refuza_pe_toata_lumea(allowed, monkeypatch, lipsa): + monkeypatch.setenv(lipsa, "") + assert "fail-closed" in why(FakeMessage("salut")) + + +def test_fara_nicio_allowlist_nimeni_nu_are_voie(monkeypatch): + for key in ("DISCORD_GUILD_IDS", "DISCORD_CHANNEL_IDS", "DISCORD_USER_IDS", + "DISCORD_GUILD_ID", "DISCORD_CHANNEL_ID", "DISCORD_USER_ID"): + monkeypatch.delenv(key, raising=False) + assert not ok(FakeMessage("salut")) + + +def test_se_accepta_si_forma_singulara_a_cheilor(monkeypatch): + for key in ("DISCORD_GUILD_IDS", "DISCORD_CHANNEL_IDS", "DISCORD_USER_IDS"): + monkeypatch.delenv(key, raising=False) + monkeypatch.setenv("DISCORD_GUILD_ID", GUILD) + monkeypatch.setenv("DISCORD_CHANNEL_ID", CHANNEL) + monkeypatch.setenv("DISCORD_USER_ID", USER) + assert ok(FakeMessage("salut")) + + +def test_liste_cu_virgula_si_spatii(monkeypatch): + monkeypatch.setenv("DISCORD_GUILD_IDS", f" 1, {GUILD} ,2") + monkeypatch.setenv("DISCORD_CHANNEL_IDS", f"{CHANNEL},3") + monkeypatch.setenv("DISCORD_USER_IDS", f"4 {USER}") + assert ok(FakeMessage("salut")) + + +# --------------------------------------------------- refuzul e tacut si logat +async def test_refuzul_nu_raspunde_si_nu_reactioneaza(bridge, caplog): + ch = FakeChannel() + msg = FakeMessage("!status", channel=ch, author=FakeUser("777")) + with caplog.at_level("WARNING"): + assert await bridge.handle_message(msg) == "rejected" + assert ch.sent == [] # niciun raspuns + assert msg.reactions == [] # nicio reactie + assert bridge.rejected and "utilizator" in bridge.rejected[0] + assert "respins" in caplog.text + + +async def test_webhookul_nu_declanseaza_comenzi(bridge): + ch = FakeChannel() + msg = FakeMessage("!cleanup --force", channel=ch, webhook_id="55") + assert await bridge.handle_message(msg) == "rejected" + assert ch.sent == [] + assert bridge.runner.procs == {} diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_bot.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_bot.py new file mode 100644 index 0000000..8be3982 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_bot.py @@ -0,0 +1,269 @@ +"""Adaptorul Discord: falsuri pentru discord.py, zero retea, zero API. + +Fisierul contine si falsurile folosite de test_allowlist.py si test_commands.py. +""" + +from __future__ import annotations + +import asyncio + +import pytest + +import bot +import limits as limits_mod +import runner as runner_mod + +GUILD, CHANNEL, USER = "100", "200", "300" + + +# ------------------------------------------------------------------ falsuri +class FakeUser: + def __init__(self, uid=USER, is_bot=False): + self.id = uid + self.bot = is_bot + self.display_name = f"user-{uid}" + + +class FakeSent: + """Mesajul returnat de channel.send(), editabil ca in discord.py.""" + + def __init__(self, channel, content, **kw): + self.channel = channel + self.content = content + self.kwargs = kw + self.edits: list[str] = [] + self.id = f"sent-{id(self)}" + + async def edit(self, content=None, **kw): + if content is not None: + self.content = content + self.edits.append(content) + return self + + +class FakeChannel: + def __init__(self, cid=CHANNEL, parent_id=None): + self.id = cid + self.parent_id = parent_id + self.sent: list[FakeSent] = [] + + async def send(self, content=None, **kw): + msg = FakeSent(self, content, **kw) + self.sent.append(msg) + return msg + + @property + def texts(self) -> list[str]: + return [m.content or "" for m in self.sent] + + @property + def all_text(self) -> str: + return "\n".join(self.texts + [e for m in self.sent for e in m.edits]) + + +class FakeGuild: + def __init__(self, gid=GUILD): + self.id = gid + + +class FakeMessage: + def __init__(self, content="salut", *, author=None, channel=None, guild=None, + webhook_id=None, mid="m1"): + self.content = content + self.author = author if author is not None else FakeUser() + self.channel = channel if channel is not None else FakeChannel() + self.guild = guild if guild is not None else FakeGuild() + self.webhook_id = webhook_id + self.id = mid + self.reactions: list[str] = [] + + async def add_reaction(self, emoji): + self.reactions.append(emoji) + + +# ------------------------------------------------------------------ fixturi +@pytest.fixture +def allowed(monkeypatch): + """Allowlist completa in mediu (config.get cade pe os.environ).""" + monkeypatch.setenv("DISCORD_GUILD_IDS", GUILD) + monkeypatch.setenv("DISCORD_CHANNEL_IDS", CHANNEL) + monkeypatch.setenv("DISCORD_USER_IDS", USER) + return {"guild": GUILD, "channel": CHANNEL, "user": USER} + + +@pytest.fixture +async def bridge(store, fake_bin, allowed, monkeypatch): + monkeypatch.setenv("FAKE_CLAUDE_SCENARIO", "normal") + monkeypatch.setenv("FAKE_CLAUDE_COST", "0.0123") + mgr = runner_mod.RunnerManager( + store, claude_bin=fake_bin, is_inflight=store.is_inflight, poll_s=3600 + ) + lim = limits_mod.Limits( + store, cost_cap=10.0, rate_per_min=100, max_procs=2, + alerter=lambda *a, **k: None, + ) + br = bot.Bridge(store, mgr, lim, self_id="999") + br.render.kw = {"min_interval": 0.05, "max_interval": 0.2} # teste rapide + yield br + await br.shutdown() + + +# -------------------------------------------------------------------- teste +async def test_tur_normal_are_subsol_cu_model_durata_si_cost(bridge): + msg = FakeMessage("cat fac 2+2?") + assert await bridge.handle_message(msg) == "ok" + + final = msg.channel.sent[0].content + assert "ecou: cat fac 2+2?" in final + assert "sonnet" in final and "$0.0123 tur" in final and "$0.0123 fir" in final + + +async def test_costul_se_acumuleaza_pe_fir_si_pe_zi(bridge, store): + ch = FakeChannel() + for i in range(2): + await bridge.handle_message(FakeMessage(f"mesaj {i}", channel=ch, mid=f"m{i}")) + tid = str(ch.id) + assert store.thread(tid)["cost_usd_total"] == pytest.approx(0.0246) + assert store.cost_today() == pytest.approx(0.0246) + assert "$0.0246 fir" in ch.sent[1].content + + +async def test_inflight_curatat_si_sid_persistat(bridge, store): + msg = FakeMessage("salut") + await bridge.handle_message(msg) + tid = str(msg.channel.id) + assert store.is_inflight(tid) is False + assert store.thread(tid)["sid"] == "sid-fake-0001" + + +async def test_mesaj_in_timpul_turului_e_steering_nu_tur_nou(bridge, monkeypatch): + monkeypatch.setenv("FAKE_CLAUDE_SCENARIO", "slow") + monkeypatch.setenv("FAKE_CLAUDE_DELAY", "1.5") + ch = FakeChannel() + first = FakeMessage("prima", channel=ch, mid="m1") + task = asyncio.create_task(bridge.handle_message(first)) + + tid = str(ch.id) + for _ in range(200): # asteptam sa intre turul in zbor + proc = bridge.runner.procs.get(tid) + if proc is not None and proc.alive and proc.inflight: + break + await asyncio.sleep(0.02) + else: # pragma: no cover + pytest.fail("turul nu a pornit") + + await asyncio.sleep(0.25) # lasam CLI-ul fals sa consume primul mesaj + second = FakeMessage("steering", channel=ch, mid="m2") + assert await bridge.handle_message(second) == "steered" + assert second.reactions == ["➡️"] + assert bridge.steered == 1 + + assert await task == "ok" + # mesajul de steering a ajuns in acelasi tur, nu a deschis unul nou + assert "prima | steering" in bridge.last_result.text + assert len(bridge.runner.procs) == 1 + + +async def test_plafonul_de_cost_opreste_botul_si_o_spune_in_fir(bridge, store): + bridge.limits.cost_cap = 0.001 + store.add_cost(None, 0.5) + msg = FakeMessage("mai fa ceva") + assert await bridge.handle_message(msg) == "cost-cap" + assert "plafon" in msg.channel.all_text.lower() + assert bridge.runner.procs == {} + + +async def test_plafonul_atins_dupa_tur_e_anuntat(bridge): + bridge.limits.cost_cap = 0.005 # sub costul unui tur fals (0.0123) + msg = FakeMessage("un tur scump") + assert await bridge.handle_message(msg) == "ok" + assert "Ma opresc" in msg.channel.all_text + + +async def test_rate_limit_per_utilizator(bridge): + bridge.limits.rate_per_min = 1 + ch = FakeChannel() + assert await bridge.handle_message(FakeMessage("unu", channel=ch, mid="1")) == "ok" + assert await bridge.handle_message(FakeMessage("doi", channel=ch, mid="2")) == "rate-limited" + assert "prea multe mesaje" in ch.all_text + + +async def test_tur_esuat_raspunde_in_fir(bridge, monkeypatch): + monkeypatch.setenv("FAKE_CLAUDE_SCENARIO", "eof") + msg = FakeMessage("ceva") + assert await bridge.handle_message(msg) == "failed" + assert "esuat" in msg.channel.all_text + assert bridge.store.is_inflight(str(msg.channel.id)) is False + + +async def test_sweep_la_pornire_anunta_turul_pierdut_fara_reluare(bridge, store, monkeypatch): + ch = FakeChannel(cid="200") + store.update_thread("200", pid=999999, pid_start_time=1.0) + store.set_inflight("200", "t1", USER, "m1") + bridge.get_channel = lambda cid: ch if str(cid) == "200" else None + + lost = await bridge.startup() + assert [x["thread_id"] for x in lost] == ["200"] + assert "pierdut" in ch.all_text + assert store.is_inflight("200") is False + assert bridge.runner.procs == {} # niciun tur nu a fost repornit + + +async def test_raspuns_lung_devine_atasament(bridge, store, monkeypatch): + lung = "x" * 7000 + async def fake_turn(prompt, on_event=None, timeout=None): + await on_event(bot.stream_mod.AssistantText(text=lung)) + return runner_mod.TurnOutcome( + result=bot.stream_mod.Result(0.01, 100, False, 1, text=lung) + ) + proc = bridge.runner.get("200") + monkeypatch.setattr(proc, "run_turn", fake_turn) + msg = FakeMessage("da-mi mult text") + assert await bridge.handle_message(msg) == "ok" + assert "raspuns lung" in msg.channel.sent[0].content # previzualizarea + if bot.discord is not None: # atasamentul propriu-zis + assert any(m.kwargs.get("file") is not None for m in msg.channel.sent) + + +# ------------------------------------------------------------------ aprobari +def test_decizia_cere_allowlist(bridge, allowed): + assert "allowlist" in bridge.decide("777", "req-1", "allow") + + +def test_decizia_ajunge_la_lane_b(bridge, allowed, monkeypatch): + calls = [] + monkeypatch.setattr( + bot.approvals, "submit_decision", + lambda rid, dec: calls.append((rid, dec)) or True, + ) + out = bridge.decide(USER, "req-1", "allow") + assert calls == [("req-1", "allow")] + assert "Permis" in out + + +def test_cerere_inexistenta_nu_arunca(bridge, allowed): + assert "nu mai exista" in bridge.decide(USER, "req-inexistent", "deny") + + +async def test_botul_porneste_si_fara_modulele_lui_b_si_c(bridge, monkeypatch): + """Lane B/C absente: pornire normala, doar fara aprobari si fara !cleanup.""" + monkeypatch.setattr(bot, "approvals", None) + monkeypatch.setattr(bot, "cleanup", None) + assert bridge.wire_approvals() is False + ch = FakeChannel() + await bridge.handle_message(FakeMessage("!cleanup", channel=ch)) + assert "nu e disponibil" in ch.all_text + assert bridge.approval_view("req-1") is None + assert await bridge.handle_message(FakeMessage("salut", channel=ch)) == "ok" + + +async def test_cererea_de_aprobare_posteaza_butoane_in_fir(bridge, allowed): + ch = FakeChannel() + bridge.get_channel = lambda cid: ch + await bridge.on_approval_request( + {"request_id": "r1", "thread_id": "200", "tool_name": "Bash", "command": "rm -rf /tmp/x"} + ) + assert "Confirmare ceruta" in ch.texts[0] + assert "rm -rf /tmp/x" in ch.texts[0] + if bot.discord is not None: + assert ch.sent[0].kwargs.get("view") is not None diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_cleanup.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_cleanup.py new file mode 100644 index 0000000..331f60c --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_cleanup.py @@ -0,0 +1,313 @@ +"""Teste pentru cleanup.py (T13). + +Testele ating /proc-ul real, dar NUMAI cu procese pe care le pornesc ele insele: +copii ai lui `sleep` redenumiti `claude`. Nu se atinge niciodata un proces al +sistemului si nu se apeleaza `kill_orphans(dry_run=False)` decat pe acesti copii. +""" + +import os +import pathlib +import shutil +import subprocess +import sys +import time + +import pytest + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent)) + +import cleanup # noqa: E402 + + +# --- ajutoare: procese inofensive ------------------------------------------ + +@pytest.fixture() +def fake_claude(): + """Un binar `claude` fals (copie de sleep) + pornirea/oprirea proceselor. + + NU folosim `tmp_path`: calea lui contine "pytest", care e in NEVER_KILL, si + procesul de test ar iesi protejat din greseala. + """ + import tempfile + bindir = pathlib.Path(tempfile.mkdtemp(prefix="lanec-bin-")) + binar = bindir / "claude" + shutil.copy(shutil.which("sleep") or "/bin/sleep", binar) + binar.chmod(0o755) + + pornite = [] + + def porneste(secunde="60"): + proc = subprocess.Popen([str(binar), secunde]) + pornite.append(proc) + # asteptam sa apara in /proc cu cmdline complet + for _ in range(50): + if cleanup._cmdline(proc.pid): + break + time.sleep(0.01) + return proc + + yield porneste + + for proc in pornite: + try: + proc.kill() + proc.wait(timeout=5) + except Exception: + pass + shutil.rmtree(bindir, ignore_errors=True) + + +def _stare_cu(pid=None, start_time=None): + thread = {"sid": "x", "cwd": "/workspace", "model": "sonnet"} + if pid is not None: + thread["pid"] = pid + if start_time is not None: + thread["pid_start_time"] = start_time + return {"version": 1, "threads": {"111": thread}, "cost": {"day": "2026-08-30", "usd": 0.0}} + + +def _pids(orphans): + return {o["pid"] for o in orphans} + + +# --- citirea /proc --------------------------------------------------------- + +def test_parse_stat_pe_procesul_curent(): + ppid, starttime = cleanup._parse_stat(os.getpid()) + assert ppid == os.getppid() + assert starttime > 0 + + +def test_parse_stat_pid_inexistent(): + assert cleanup._parse_stat(4_000_000) is None + + +def test_parse_stat_suporta_comm_cu_spatii(tmp_path, monkeypatch): + """comm-ul e intre paranteze si poate contine spatii/paranteze.""" + fals = tmp_path / "777" + fals.mkdir() + # campurile 3..24: state, ppid, apoi umplutura pana la campul 22 = starttime + campuri = ["S", "42"] + ["0"] * 17 + ["987654"] + ["0", "0"] + (fals / "stat").write_text("777 (nume ciudat (x)) " + " ".join(campuri) + "\n") + monkeypatch.setattr(cleanup, "PROC", tmp_path) + assert cleanup._parse_stat(777) == (42, 987654.0) + + +def test_rss_si_cmdline_pentru_procesul_curent(): + assert cleanup._rss_mb(os.getpid()) > 0 + assert "python" in cleanup._cmdline(os.getpid()).lower() + + +def test_scan_processes_contine_procesul_curent(): + procs = cleanup.scan_processes() + assert os.getpid() in procs + info = procs[os.getpid()] + assert set(info) >= {"pid", "ppid", "cmdline", "age_s", "rss_mb", "start_time", "cgroup"} + + +def test_scan_processes_doar_uid_ul_curent(): + procs = cleanup.scan_processes() + # pid 1 apartine altui utilizator in acest container + assert all(p > 0 for p in procs) + assert cleanup._uid(os.getpid()) == os.getuid() + + +# --- clasificare ----------------------------------------------------------- + +def test_is_claude_recunoaste_variantele(): + assert cleanup._is_claude("claude -p --resume abc") + assert cleanup._is_claude("/home/claude/.nvm/versions/node/v20.19.6/bin/claude -p") + assert cleanup._is_claude("node /home/x/node_modules/@anthropic-ai/claude-code/bin/claude") + + +def test_is_claude_nu_confunda_home_ul_utilizatorului(): + # utilizatorul se numeste `claude`, deci caile lui contin cuvantul + assert not cleanup._is_claude("/usr/bin/python3 /home/claude/script.py") + assert not cleanup._is_claude("sleep 300") + + +def test_procese_protejate_nu_sunt_orfane(): + assert cleanup._is_protected("/usr/lib/systemd/systemd --user") + assert cleanup._is_protected("sshd: claude@pts/0") + assert cleanup._is_protected("python3 bot.py") + + +def test_gaseste_proces_claude_neinregistrat(fake_claude): + proc = fake_claude() + orfani = cleanup.find_orphans({"version": 1, "threads": {}}) + assert proc.pid in _pids(orfani) + entry = next(o for o in orfani if o["pid"] == proc.pid) + assert set(entry) >= {"pid", "cmdline", "age_s", "rss_mb"} + assert entry["age_s"] >= 0 + assert "neinregistrat" in entry["reason"] + + +def test_procesul_din_state_json_nu_e_orfan(fake_claude): + proc = fake_claude() + stare = _stare_cu(pid=proc.pid) + assert proc.pid not in _pids(cleanup.find_orphans(stare)) + + +def test_pid_start_time_care_nu_se_potriveste_nu_protejeaza(fake_claude): + """PID reuse: state.json crede ca stie pid-ul, dar e alt proces acum.""" + proc = fake_claude() + stare = _stare_cu(pid=proc.pid, start_time=1.0) # start_time vechi, gresit + assert proc.pid in _pids(cleanup.find_orphans(stare)) + + +def test_pid_start_time_corect_protejeaza(fake_claude): + proc = fake_claude() + _, start = cleanup._parse_stat(proc.pid) + stare = _stare_cu(pid=proc.pid, start_time=start) + assert proc.pid not in _pids(cleanup.find_orphans(stare)) + + +def test_descendentii_unui_proces_cunoscut_sunt_protejati(fake_claude): + """Copiii turului care ruleaza acum nu au voie sa fie declarati orfani.""" + proc = fake_claude() + # declaram procesul curent (pytest) ca fiind procesul firului; copilul lui + # `proc` e descendentul lui, deci protejat + stare = _stare_cu(pid=os.getpid()) + assert proc.pid not in _pids(cleanup.find_orphans(stare)) + + +def test_procesul_curent_nu_e_niciodata_orfan(): + orfani = cleanup.find_orphans({"version": 1, "threads": {}}) + assert os.getpid() not in _pids(orfani) + + +def test_min_age_filtreaza_procesele_proaspete(fake_claude): + proc = fake_claude() + orfani = cleanup.find_orphans({"version": 1, "threads": {}}, min_age_s=3600) + assert proc.pid not in _pids(orfani) + + +def test_state_aiurea_nu_arunca(): + for stare in ({}, {"threads": None}, {"threads": {"a": None}}, + {"threads": {"a": {"pid": "nu-i numar"}}}, {"threads": {"a": {"pid": -5}}}): + assert isinstance(cleanup.find_orphans(stare), list) + + +def test_orfanii_sunt_sortati_dupa_rss(fake_claude): + fake_claude() + fake_claude() + orfani = cleanup.find_orphans({"version": 1, "threads": {}}) + rss = [o["rss_mb"] for o in orfani] + assert rss == sorted(rss, reverse=True) + + +# --- oprire ---------------------------------------------------------------- + +def test_dry_run_e_implicit_si_nu_omoara_nimic(fake_claude): + proc = fake_claude() + orfani = [o for o in cleanup.find_orphans({"version": 1, "threads": {}}) if o["pid"] == proc.pid] + rez = cleanup.kill_orphans(orfani) # fara dry_run explicit + assert rez[0]["action"] == "dry-run" + time.sleep(0.2) + assert proc.poll() is None, "procesul a fost omorat desi era rulare seaca" + + +def test_kill_orphans_opreste_efectiv_cu_force(fake_claude): + proc = fake_claude() + orfani = [o for o in cleanup.find_orphans({"version": 1, "threads": {}}) if o["pid"] == proc.pid] + assert orfani, "procesul de test nu a fost gasit ca orfan" + rez = cleanup.kill_orphans(orfani, dry_run=False, grace_s=3.0) + assert rez[0]["action"] in ("terminated", "killed") + assert proc.wait(timeout=5) is not None + + +def test_proces_deja_disparut_e_raportat_gone(fake_claude): + proc = fake_claude() + _, start = cleanup._parse_stat(proc.pid) + orfan = {"pid": proc.pid, "cmdline": "claude", "age_s": 1, "rss_mb": 1, "start_time": start} + proc.kill() + proc.wait(timeout=5) + rez = cleanup.kill_orphans([orfan], dry_run=False) + assert rez[0]["action"] == "gone" + + +def test_pid_reuse_impiedica_omorarea_gresita(fake_claude): + """start_time nepotrivit => refuzam sa omoram, chiar cu dry_run=False.""" + proc = fake_claude() + orfan = {"pid": proc.pid, "cmdline": "claude", "age_s": 1, "rss_mb": 1, + "start_time": 1.0} # alt proces, evident + rez = cleanup.kill_orphans([orfan], dry_run=False) + assert rez[0]["action"] == "gone" + time.sleep(0.2) + assert proc.poll() is None, "am omorat un proces cu start_time nepotrivit" + + +def test_kill_orphans_refuza_procesul_curent(): + _, start = cleanup._parse_stat(os.getpid()) + orfan = {"pid": os.getpid(), "cmdline": "pytest", "age_s": 1, "rss_mb": 1, + "start_time": start} + rez = cleanup.kill_orphans([orfan], dry_run=False) + assert rez[0]["action"] == "skipped" + + +def test_kill_orphans_pe_lista_goala(): + assert cleanup.kill_orphans([]) == [] + assert cleanup.kill_orphans(None) == [] + + +def test_kill_orphans_intrare_aiurea_nu_arunca(): + rez = cleanup.kill_orphans([{"cmdline": "fara pid"}, {"pid": None}], dry_run=False) + assert all(r["action"] == "gone" for r in rez) + + +# --- raport ---------------------------------------------------------------- + +def test_raport_gol(): + assert "Niciun proces orfan" in cleanup.format_report([]) + + +def test_raport_cu_orfani(): + orfani = [{"pid": 1234, "cmdline": "claude -p --resume abc", "age_s": 900, "rss_mb": 406.0}] + text = cleanup.format_report(orfani) + assert "1 procese orfane" in text + assert "1234" in text and "406" in text + assert "--force" in text + + +def test_raport_cu_rezultate_si_sub_limita_discord(): + orfani = [{"pid": i, "cmdline": "claude -p " + "x" * 200, "age_s": i, "rss_mb": 406.0} + for i in range(1, 41)] + rez = [{"pid": o["pid"], "action": "killed", "detail": "SIGKILL"} for o in orfani] + text = cleanup.format_report(orfani, rez) + assert "si inca 25" in text + assert len(text) < 2000, "raportul depaseste limita de mesaj Discord" + assert "killed" in text + + +def test_zombie_nu_e_orfan_si_nu_se_omoara(fake_claude): + """Un copil terminat dar nereaped are inca /proc/, dar e mort.""" + proc = fake_claude() + proc.terminate() + for _ in range(200): + if cleanup._is_zombie(proc.pid): + break + time.sleep(0.01) + assert cleanup._is_zombie(proc.pid), "nu am reusit sa produc un zombi" + + assert proc.pid not in _pids(cleanup.find_orphans({"version": 1, "threads": {}})) + + _, start = cleanup._parse_stat(proc.pid) + rez = cleanup.kill_orphans( + [{"pid": proc.pid, "cmdline": "claude", "age_s": 1, "rss_mb": 0, "start_time": start}], + dry_run=False, + ) + assert rez[0]["action"] == "gone" + + +def test_varsta_e_calculata_corect_in_container(fake_claude): + """Regresie: /proc/uptime e virtualizat de lxcfs, `starttime` nu. + + Cu scaderea naiva, un proces pornit acum iesea cu varsta negativa (deci 0) + si filtrul `min_age_s` devenea inutil. Referinta corecta e `btime`. + """ + proc = fake_claude() + info = cleanup.scan_processes()[proc.pid] + assert 0 <= info["age_s"] < 60, f"varsta absurda: {info['age_s']}s" + + eu = cleanup.scan_processes()[os.getpid()] + assert eu["age_s"] < 3600, "procesul de test pare mai vechi de o ora" diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_commands.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_commands.py new file mode 100644 index 0000000..05ca041 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_commands.py @@ -0,0 +1,231 @@ +"""Comenzile puntii: !new (+ --fork), !cd, !model, !status, !stop, !cleanup, !help.""" + +from __future__ import annotations + +import pytest + +import bot + +try: + from tests.test_bot import (CHANNEL, USER, FakeChannel, FakeMessage, allowed, bridge) +except ImportError: # pragma: no cover + from test_bot import (CHANNEL, USER, FakeChannel, FakeMessage, allowed, bridge) + +__all__ = ["allowed", "bridge"] + + +async def run(bridge, text, ch=None): + ch = ch or FakeChannel() + msg = FakeMessage(text, channel=ch) + label = await bridge.handle_message(msg) + return label, ch + + +# ------------------------------------------------------------------- parser +def test_parse_command(): + cmd = bot.parse_command("!cd /workspace/romfastsql") + assert cmd.name == "cd" and cmd.rest == "/workspace/romfastsql" + assert bot.parse_command("!MODEL opus").name == "model" + assert bot.parse_command("salut") is None + assert bot.parse_command("!") is None + + +async def test_comanda_necunoscuta(bridge): + label, ch = await run(bridge, "!inexistenta") + assert label == "unknown-command" + assert "nu exista" in ch.all_text + + +async def test_help(bridge): + label, ch = await run(bridge, "!help") + assert label == "cmd:help" + for c in ("!new", "!cd", "!model", "!status", "!stop", "!cleanup"): + assert c in ch.all_text + + +# ---------------------------------------------------------------------- !new +async def test_new_sterge_sesiunea(bridge, store): + ch = FakeChannel() + await run(bridge, "salut", ch) # creeaza sesiunea + assert store.thread(str(ch.id))["sid"] == "sid-fake-0001" + label, _ = await run(bridge, "!new", ch) + assert label == "cmd:new" + assert store.thread(str(ch.id))["sid"] is None + assert bridge.runner.procs[str(ch.id)].alive is False + + +async def test_new_fork_pastreaza_contextul(bridge, store): + ch = FakeChannel() + await run(bridge, "salut", ch) + await run(bridge, "!new --fork", ch) + assert store.thread(str(ch.id))["sid"] == "sid-fake-0001" + assert "fork" in ch.all_text + assert bridge.runner.procs[str(ch.id)].sid == "sid-fake-0001" + + +# ----------------------------------------------------------------------- !cd +async def test_cd_oriunde_in_workspace(bridge, store, tmp_path): + target = tmp_path / "proiect-nou" + target.mkdir() + label, ch = await run(bridge, f"!cd {target}") + assert label == "cmd:cd" + assert store.thread(str(ch.id))["cwd"] == str(target) + assert str(target) in ch.all_text + + +async def test_cd_refuza_ce_nu_e_director(bridge, store): + label, ch = await run(bridge, "!cd /nu/exista/nicaieri") + assert "nu e un director" in ch.all_text + assert store.thread(str(ch.id))["cwd"] != "/nu/exista/nicaieri" + + +async def test_cd_fara_argument(bridge): + _, ch = await run(bridge, "!cd") + assert "Foloseste" in ch.all_text + + +async def test_cd_se_aplica_procesului_urmator(bridge, tmp_path): + ch = FakeChannel() + await run(bridge, "salut", ch) + target = tmp_path / "alt-proiect" + target.mkdir() + await run(bridge, f"!cd {target}", ch) + assert bridge.runner.procs[str(ch.id)].cwd == str(target) + assert bridge.runner.procs[str(ch.id)].alive is False # se reia cu --resume + + +# -------------------------------------------------------------------- !model +async def test_model_implicit_e_sonnet(bridge, store): + _, ch = await run(bridge, "salut") + assert store.thread(str(ch.id))["model"] == "sonnet" + assert "sonnet" in ch.all_text + + +async def test_model_opus_per_fir(bridge, store): + ch = FakeChannel() + label, _ = await run(bridge, "!model opus", ch) + assert label == "cmd:model" + assert store.thread(str(ch.id))["model"] == "opus" + # firul vecin (alt thread, acelasi canal-parinte) ramane pe sonnet + ch2 = FakeChannel("201", parent_id=CHANNEL) + await run(bridge, "!status", ch2) + assert "model : sonnet" in ch2.all_text + + +async def test_modelul_ales_ajunge_in_subsol(bridge): + ch = FakeChannel() + await run(bridge, "!model opus", ch) + await run(bridge, "salut", ch) + assert "opus" in ch.sent[-1].content + + +async def test_model_necunoscut_refuzat(bridge, store): + ch = FakeChannel() + await run(bridge, "!model gpt", ch) + assert "necunoscut" in ch.all_text + assert store.thread(str(ch.id))["model"] == "sonnet" + + +async def test_model_fara_argument_arata_curentul(bridge): + _, ch = await run(bridge, "!model") + assert "Model curent" in ch.all_text + + +# ------------------------------------------------------------------- !status +async def test_status_arata_campurile_cerute(bridge): + ch = FakeChannel() + await run(bridge, "salut", ch) + _, _ = await run(bridge, "!status", ch) + text = ch.sent[-1].content + for camp in ("fir", "sesiune", "director", "model", "proces", "tur in zbor", + "in coada", "cost fir", "cost azi"): + assert camp in text + assert "sid-fake-0001" in text + assert "$0.0123" in text + + +async def test_status_arata_stderr(bridge, monkeypatch): + ch = FakeChannel() + monkeypatch.setenv("FAKE_CLAUDE_SCENARIO", "crash") + await run(bridge, "salut", ch) # esueaza si lasa stderr in buffer + proc = bridge.runner.procs[str(ch.id)] + proc.stderr_buf.append("fake_claude: boom") + _, _ = await run(bridge, "!status", ch) + assert "boom" in ch.sent[-1].content + + +async def test_status_pe_fir_fara_proces(bridge): + _, ch = await run(bridge, "!status") + assert "proces : oprit" in ch.all_text + assert "(noua)" in ch.all_text + + +# --------------------------------------------------------------------- !stop +async def test_stop_opreste_procesul(bridge, store): + ch = FakeChannel() + await run(bridge, "salut", ch) + proc = bridge.runner.procs[str(ch.id)] + assert proc.alive is True # procesul ramane viu intre tururi + await run(bridge, "!stop", ch) + assert "oprit" in ch.all_text + assert proc.alive is False + + +async def test_stop_fara_proces(bridge): + _, ch = await run(bridge, "!stop") + assert "Nu ruleaza nimic" in ch.all_text + + +async def test_stop_curata_inflight(bridge, store, monkeypatch): + ch = FakeChannel() + store.set_inflight(str(ch.id), "t1", USER, "m1") + await run(bridge, "!stop", ch) + assert store.is_inflight(str(ch.id)) is False + + +# ------------------------------------------------------------------ !cleanup +async def test_cleanup_deleaga_in_lane_c(bridge, monkeypatch): + calls = {} + + class FakeCleanup: + @staticmethod + def find_orphans(state): + calls["state"] = state + return [{"pid": 1234, "cmdline": "claude -p", "age_s": 10, "rss_mb": 406.0}] + + @staticmethod + def kill_orphans(orphans, dry_run=True): + calls["dry_run"] = dry_run + return [{"pid": 1234, "action": "dry-run", "detail": "s-ar trimite SIGTERM"}] + + @staticmethod + def format_report(orphans, results=None): + calls["results"] = results + return f"{len(orphans)} orfani" + + monkeypatch.setattr(bot, "cleanup", FakeCleanup) + _, ch = await run(bridge, "!cleanup") + assert "1 orfani" in ch.all_text + assert calls["dry_run"] is True and calls["results"] is None + assert "threads" in calls["state"] + + _, ch2 = await run(bridge, "!cleanup --force") + assert calls["dry_run"] is False and calls["results"] is not None + + +async def test_cleanup_care_crapa_nu_doboara_botul(bridge, monkeypatch): + class Boom: + @staticmethod + def find_orphans(state): + raise RuntimeError("proc ilizibil") + + monkeypatch.setattr(bot, "cleanup", Boom) + _, ch = await run(bridge, "!cleanup") + assert "a esuat" in ch.all_text + + +async def test_cleanup_real_ruleaza_in_dry_run(bridge): + """Modulul real al lui Lane C: nu omoara nimic implicit.""" + cleanup = pytest.importorskip("cleanup") + _, ch = await run(bridge, "!cleanup") + assert ch.all_text.strip() != "" diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_confirm_hook.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_confirm_hook.py new file mode 100644 index 0000000..2dc218f --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_confirm_hook.py @@ -0,0 +1,408 @@ +"""Teste pentru hook-ul PreToolUse si canalul de aprobari (Lane B). + +Fara retea, fara Discord, fara CLI real. Toate cererile merg intr-un tmp_path. +""" + +from __future__ import annotations + +import asyncio +import importlib +import io +import json +import pathlib +import sys +import subprocess +import threading +import time + +import pytest + +SEC_DIR = pathlib.Path(__file__).resolve().parents[1] / "security" +if str(SEC_DIR) not in sys.path: + sys.path.insert(0, str(SEC_DIR)) + +import approvals # noqa: E402 +import confirm_hook as hook # noqa: E402 + + +@pytest.fixture() +def home(tmp_path, monkeypatch): + """Un ~/.claude-discord fals, izolat pe test.""" + base = tmp_path / ".claude-discord" + (base / "approvals" / "done").mkdir(parents=True) + (base / "logs").mkdir(parents=True) + monkeypatch.setenv("CLAUDE_DISCORD_DIR", str(base)) + monkeypatch.setenv("CLAUDE_DISCORD_APPROVAL_TIMEOUT", "1") + importlib.reload(approvals) + importlib.reload(hook) + return base + + +def payload(cmd: str, tool: str = "Bash") -> str: + return json.dumps( + { + "session_id": "s-1", + "cwd": "/workspace/romfastsql", + "hook_event_name": "PreToolUse", + "tool_name": tool, + "tool_input": {"command": cmd}, + } + ) + + +# --------------------------------------------------------------- clasificator + +PERICULOASE = [ + "rm -rf /tmp/ceva", + "rm -rf /tmp/ceva", # spatii multiple + "sudo rm -rf /tmp/ceva", # sudo in fata + "/bin/rm -rf /tmp/ceva", # cale absoluta + "rm -r -f /tmp/ceva", # optiuni separate + "rm -fr /tmp/ceva", # ordine inversa + 'bash -c "rm -rf /tmp/ceva"', # shell imbricat + 'sh -c "sudo /bin/rm --recursive --force /tmp/x"', + "echo hai && rm -rf /tmp/x", # al doilea segment + "ls /tmp; rm -rf /tmp/x", + "TERM=dumb rm -rf /tmp/x", # atribuire in fata + "timeout 30 rm -rf /tmp/x", + "dd if=/dev/zero of=/dev/sdb", + "mkfs.ext4 /dev/sdb1", + "wipefs -a /dev/sdb", + "cat imagine.img > /dev/sdb", + "shutdown -h now", + "reboot", + "systemctl poweroff", + "pct destroy 171", + "qm destroy 201", + "zfs destroy rpool/data/vm-201-disk-0", + "zpool destroy rpool", + "pvesm remove backup-nfs", + "lvremove /dev/pve/data", + "sqlplus / as sysdba @drop.sql; echo DROP TABLE ROMFAST.FACTURI", + "echo 'truncate table facturi;' | sqlplus -s user/parola@xe", + "git push --force origin master", + "git push -f", + "git clean -fdx", + "git reset --hard origin/master", + "systemctl stop oracle-xe", + "systemctl stop pveproxy", + "ssh root@10.0.20.36 uptime", + "ssh 10.0.20.201 pct list", + "find /tmp -name '*.log' -delete", + "docker volume rm date-oracle", + "shred -u secret.key", + "pvesh delete /nodes/pvemini/lxc/171", +] + +NEPERICULOASE = [ + "ls -la /workspace", + "git status", + "git push origin feat/discord-bridge", + "rm /tmp/un-fisier", + "rm -f /tmp/un-fisier", + "grep -rn 'rm -rf' docs/", # doar mentioneaza, nu ruleaza + "pytest tests/ -q", + "echo 'nu sterge nimic' > /tmp/nota.txt", + "cat /dev/null > /tmp/gol", + "python3 -c \"print('drop')\"", + "systemctl status oracle-xe", + "systemctl --user restart claude-discord", + "pct list", + "docker ps", + "df -h", + "echo salut > /dev/null", +] + + +@pytest.mark.parametrize("cmd", PERICULOASE) +def test_clasificator_prinde_periculoasele(cmd): + verdict = hook.classify("Bash", {"command": cmd}) + assert verdict is not None, f"nedetectat: {cmd}" + assert verdict[0] and verdict[1] + + +@pytest.mark.parametrize("cmd", NEPERICULOASE) +def test_clasificator_lasa_nepericuloasele(cmd): + assert hook.classify("Bash", {"command": cmd}) is None, f"fals pozitiv: {cmd}" + + +def test_alte_tooluri_trec(): + assert hook.classify("Read", {"file_path": "/etc/passwd"}) is None + assert hook.classify("Bash", {}) is None + + +def test_ghilimele_neinchise_nu_arunca(): + # lexerul cade, dar clasificatorul trebuie sa raspunda, nu sa crape + hook.classify("Bash", {"command": 'echo "neinchis'}) + + +# --------------------------------------------------------------- fail-closed + +def test_json_corupt_da_deny(home, capsys): + rc = hook.run("{asta nu e json") + out = json.loads(capsys.readouterr().out) + assert rc == 0 + assert out["hookSpecificOutput"]["permissionDecision"] == "deny" + assert "invalida" in out["hookSpecificOutput"]["permissionDecisionReason"] + + +def test_payload_care_nu_e_obiect_da_deny(home, capsys): + hook.run("[1, 2, 3]") + out = json.loads(capsys.readouterr().out) + assert out["hookSpecificOutput"]["permissionDecision"] == "deny" + + +def test_director_lipsa_da_deny(tmp_path, monkeypatch, capsys): + monkeypatch.setenv("CLAUDE_DISCORD_DIR", str(tmp_path / "nu-exista")) + monkeypatch.setenv("CLAUDE_DISCORD_APPROVAL_TIMEOUT", "1") + importlib.reload(approvals) + importlib.reload(hook) + hook.run(payload("rm -rf /tmp/x")) + out = json.loads(capsys.readouterr().out) + assert out["hookSpecificOutput"]["permissionDecision"] == "deny" + assert "nu pot cere aprobarea" in out["hookSpecificOutput"]["permissionDecisionReason"] + + +def test_timeout_fara_raspuns_da_deny(home, capsys): + t0 = time.monotonic() + hook.run(payload("rm -rf /tmp/x")) + dur = time.monotonic() - t0 + out = json.loads(capsys.readouterr().out) + assert out["hookSpecificOutput"]["permissionDecision"] == "deny" + assert "neaprobat in Discord" in out["hookSpecificOutput"]["permissionDecisionReason"] + assert 0.8 < dur < 5.0 # a asteptat secunda configurata, apoi a refuzat + + +def test_cerere_corupta_pe_disc_da_deny(home, capsys): + """Daca cineva strica fisierul cererii intre timp, decizia e deny.""" + + def strica(): + for _ in range(40): + for p in (home / "approvals").glob("*.json"): + p.write_text("}{ corupt") + return + time.sleep(0.05) + + th = threading.Thread(target=strica) + th.start() + hook.run(payload("pct destroy 171")) + th.join() + out = json.loads(capsys.readouterr().out) + assert out["hookSpecificOutput"]["permissionDecision"] == "deny" + + +def test_exceptie_neasteptata_in_main_da_deny(home, monkeypatch, capsys): + """Plasa finala din main(): orice exceptie devine deny, nu crash.""" + + def crapa(_text): + raise RuntimeError("boom") + + monkeypatch.setattr(hook, "run", crapa) + monkeypatch.setattr(sys, "stdin", io.StringIO(payload("ls"))) + rc = hook.main() + out = json.loads(capsys.readouterr().out) + assert rc == 0 + assert out["hookSpecificOutput"]["permissionDecision"] == "deny" + assert "eroare interna" in out["hookSpecificOutput"]["permissionDecisionReason"] + + +# ------------------------------------------------------- fluxul de aprobare + +def test_aprobare_externa_permite_executia(home, capsys): + """Botul aproba dupa ~0.5s; hook-ul trebuie sa raspunda allow.""" + + def aproba(): + for _ in range(60): + reqs = asyncio.run(approvals.pending_requests()) + if reqs: + assert approvals.submit_decision(reqs[0]["request_id"], "allow") is True + return + time.sleep(0.05) + + th = threading.Thread(target=aproba) + th.start() + hook.run(payload("rm -rf /tmp/directorul-meu")) + th.join() + out = json.loads(capsys.readouterr().out) + assert out["hookSpecificOutput"]["permissionDecision"] == "allow" + assert "aprobat in Discord" in out["hookSpecificOutput"]["permissionDecisionReason"] + # cererea a fost mutata in done/, nu mai apare ca pending + assert asyncio.run(approvals.pending_requests()) == [] + assert list((home / "approvals" / "done").glob("*.json")) + + +def test_refuz_explicit_din_discord(home, capsys): + def refuza(): + for _ in range(60): + reqs = asyncio.run(approvals.pending_requests()) + if reqs: + approvals.submit_decision(reqs[0]["request_id"], "deny") + return + time.sleep(0.05) + + th = threading.Thread(target=refuza) + th.start() + hook.run(payload("qm destroy 201")) + th.join() + out = json.loads(capsys.readouterr().out) + assert out["hookSpecificOutput"]["permissionDecision"] == "deny" + + +def test_formatul_cererii(home): + """Formatul fisierului de cerere e contract cu bot.py.""" + req = approvals.create_request( + tool_name="Bash", + command="rm -rf /tmp/x", + reason="stergere recursiva", + rule="rm_recursiv", + thread_id="123", + session_id="s-1", + cwd="/workspace", + timeout=300, + ) + path = home / "approvals" / f"{req['request_id']}.json" + disc = json.loads(path.read_text()) + for k in ("request_id", "thread_id", "tool_name", "command", "created_at", + "expires_at", "status", "rule", "reason", "cwd", "session_id"): + assert k in disc, k + assert disc["status"] == "pending" + + pending = asyncio.run(approvals.pending_requests()) + assert len(pending) == 1 + assert set(pending[0]) >= {"request_id", "thread_id", "tool_name", "command", "created_at"} + + +def test_comanda_nepericuloasa_nu_scrie_cerere(home, capsys): + rc = hook.run(payload("ls -la")) + assert rc == 0 + assert capsys.readouterr().out == "" # fara iesire = flux normal + assert list((home / "approvals").glob("*.json")) == [] + + +# ------------------------------------------------------------- approvals API + +def test_submit_pe_cerere_inexistenta(home): + assert approvals.submit_decision("nu-exista", "allow") is False + + +def test_submit_cu_decizie_invalida_devine_deny(home): + req = approvals.create_request(tool_name="Bash", command="rm -rf /x", timeout=5) + assert approvals.submit_decision(req["request_id"], "poate") is False + assert approvals.read_decision(req["request_id"]) == "deny" + + +def test_request_id_cu_traversare_e_respins(home): + assert approvals.submit_decision("../../etc/passwd", "allow") is False + assert approvals.read_decision("../evadare") == "deny" + + +def test_wait_for_decision_timeout_da_deny(home): + req = approvals.create_request(tool_name="Bash", command="rm -rf /x", timeout=5) + assert asyncio.run(approvals.wait_for_decision(req["request_id"], 0.3)) == "deny" + + +def test_wait_for_decision_allow(home): + req = approvals.create_request(tool_name="Bash", command="rm -rf /x", timeout=5) + approvals.submit_decision(req["request_id"], "allow") + assert asyncio.run(approvals.wait_for_decision(req["request_id"], 1)) == "allow" + + +def test_scrierea_e_atomica(home): + """Nu trebuie sa ramana fisiere temporare vizibile dupa scriere.""" + approvals.create_request(tool_name="Bash", command="rm -rf /x", timeout=5) + assert [p.name for p in (home / "approvals").glob(".*tmp")] == [] + + +def test_set_on_request_anunta_botul(home): + """Callback-ul lui Lane A primeste cererea scrisa de hook (alt proces).""" + primite: list[dict] = [] + + async def scenariu(): + async def cb(req): + primite.append(req) + + approvals.set_on_request(cb) + approvals.create_request(tool_name="Bash", command="pct destroy 171", timeout=5) + for _ in range(40): + if primite: + break + await asyncio.sleep(0.05) + approvals.set_on_request(None) + + asyncio.run(scenariu()) + assert primite and primite[0]["command"] == "pct destroy 171" + + +def test_callback_care_crapa_nu_opreste_urmarirea(home): + ok: list[str] = [] + + async def scenariu(): + async def cb(req): + if not ok: + ok.append("prima") + raise RuntimeError("boom") + ok.append(req["request_id"]) + + approvals.set_on_request(cb) + approvals.create_request(tool_name="Bash", command="rm -rf /a", timeout=5) + await asyncio.sleep(0.8) + approvals.create_request(tool_name="Bash", command="rm -rf /b", timeout=5) + for _ in range(40): + if len(ok) >= 2: + break + await asyncio.sleep(0.05) + approvals.set_on_request(None) + + asyncio.run(scenariu()) + assert len(ok) >= 2 + + +# ------------------------------------------------- hook rulat ca alt proces + +def test_hook_ca_proces_separat_asteapta_si_primeste_allow(home): + """Cazul real: hook-ul e alt proces, botul aproba prin directorul de pe disc.""" + env = { + "PATH": "/usr/bin:/bin", + "HOME": str(home.parent), + "CLAUDE_DISCORD_DIR": str(home), + "CLAUDE_DISCORD_APPROVAL_TIMEOUT": "20", + "CLAUDE_DISCORD_THREAD_ID": "999", + } + proc = subprocess.Popen( + [sys.executable, str(SEC_DIR / "confirm_hook.py")], + stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, + text=True, env=env, + ) + proc.stdin.write(payload("rm -rf /tmp/lane-b-test")) + proc.stdin.close() + + rid = None + for _ in range(100): + reqs = asyncio.run(approvals.pending_requests()) + if reqs: + rid = reqs[0]["request_id"] + assert reqs[0]["thread_id"] == "999" + approvals.submit_decision(rid, "allow") + break + time.sleep(0.05) + assert rid, "hook-ul nu a scris cererea pe disc" + + out = proc.stdout.read() + err = proc.stderr.read() + proc.wait(timeout=30) + assert proc.returncode == 0, err + decision = json.loads(out)["hookSpecificOutput"] + assert decision["permissionDecision"] == "allow" + assert rid in decision["permissionDecisionReason"] + + +def test_hook_ca_proces_separat_comanda_banala_tace(home): + env = {"PATH": "/usr/bin:/bin", "HOME": str(home.parent), + "CLAUDE_DISCORD_DIR": str(home), "CLAUDE_DISCORD_APPROVAL_TIMEOUT": "5"} + res = subprocess.run( + [sys.executable, str(SEC_DIR / "confirm_hook.py")], + input=payload("ls -la /workspace"), capture_output=True, text=True, + env=env, timeout=30, + ) + assert res.returncode == 0 + assert res.stdout.strip() == "" diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_e2e_steering.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_e2e_steering.py new file mode 100644 index 0000000..d58dfee --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_e2e_steering.py @@ -0,0 +1,62 @@ +"""E2E cu CLI-ul `claude` real. Exclus implicit (`addopts = -m "not e2e"`). + +Ruleaza-l explicit: pytest -m e2e tests/test_e2e_steering.py +Dureaza ~40s si consuma cota reala; verifica exact ipoteza pe care sta runner-ul: +un mesaj trimis pe stdin IN TIMPUL unui tur ajunge la model si schimba raspunsul final. +""" + +from __future__ import annotations + +import asyncio +import os +import shutil + +import pytest + +import runner + +pytestmark = [ + pytest.mark.e2e, + pytest.mark.skipif(shutil.which("claude") is None, reason="CLI-ul claude nu e instalat"), +] + + +async def test_steering_mid_tur_pe_cli_real(tmp_path): + p = runner.ClaudeProcess("e2e", str(tmp_path), "sonnet") + try: + prompt = ( + "Foloseste unealta Bash cu run_in_background=false si timeout=60000 pentru " + "comanda: python3 -c \"import time; time.sleep(25); print('terminat')\" . " + "Asteapta iesirea in acelasi apel, nu o porni in fundal. " + "Dupa ce ai iesirea, raspunde-mi cu un singur cuvant: ALFA" + ) + + async def steer(): + await asyncio.sleep(6) + await p.send("Schimbare de plan: la final raspunde cu cuvantul BETA, nu ALFA.") + + task = asyncio.create_task(steer()) + out = await p.run_turn(prompt, timeout=180) + await task + assert out.result is not None and not out.result.is_error + text = (out.result.text or "").upper() + assert "BETA" in text, f"steering-ul nu a ajuns; raspuns: {out.result.text!r}" + assert out.result.total_cost_usd > 0 + assert p.sid # session_id-ul vine din system/init + finally: + await p.stop() + + +async def test_tur_simplu_pe_cli_real(tmp_path): + """Verifica formatul stream-json real: init -> ... -> result cu cost.""" + p = runner.ClaudeProcess("e2e2", str(tmp_path), "sonnet") + try: + out = await p.run_turn("Raspunde cu un singur cuvant: MERGE", timeout=120) + assert out.result is not None and "MERGE" in (out.result.text or "").upper() + assert out.result.duration_ms > 0 and p.sid + sid = p.sid + out2 = await p.run_turn("Repeta ultimul cuvant.", timeout=120) + assert p.sid == sid # acelasi proces, aceeasi sesiune + assert out2.result is not None + finally: + await p.stop() diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_infra.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_infra.py new file mode 100644 index 0000000..08175e3 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_infra.py @@ -0,0 +1,171 @@ +"""Teste pentru wrapper-ul `infra` (Lane B). + +Nu se conecteaza nicaieri: totul ruleaza cu INFRA_DRY_RUN=1, care doar tipareste +comanda ssh pe care ar fi rulat-o. +""" + +from __future__ import annotations + +import importlib.machinery +import importlib.util +import json +import pathlib +import subprocess +import sys + +import pytest + +INFRA = pathlib.Path(__file__).resolve().parents[1] / "security" / "infra" + + +def load_infra(): + """Incarca `infra` ca modul, desi fisierul nu are extensia .py.""" + spec = importlib.util.spec_from_loader( + "infra_mod", importlib.machinery.SourceFileLoader("infra_mod", str(INFRA)) + ) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture() +def home(tmp_path, monkeypatch): + base = tmp_path / ".claude-discord" + (base / "logs").mkdir(parents=True) + monkeypatch.setenv("CLAUDE_DISCORD_DIR", str(base)) + return base + + +def run_cli(args, home_dir, dry=True): + env = { + "PATH": "/usr/bin:/bin", + "HOME": str(home_dir.parent), + "CLAUDE_DISCORD_DIR": str(home_dir), + } + if dry: + env["INFRA_DRY_RUN"] = "1" + return subprocess.run( + [sys.executable, str(INFRA), *args], + capture_output=True, text=True, env=env, timeout=30, + ) + + +# ------------------------------------------------------------------ interfata + +def test_este_executabil(): + assert INFRA.stat().st_mode & 0o111, "infra trebuie sa fie executabil" + + +def test_lista_hosturilor(home): + res = run_cli(["--list"], home) + assert res.returncode == 0 + for host in ("pvemini", "oracle", "oracle-prod", "gitea"): + assert host in res.stdout + assert "10.0.20.201" in res.stdout + assert "[PRODUCTIE]" in res.stdout + + +def test_fara_argumente_e_utilizare_gresita(home): + res = run_cli([], home) + assert res.returncode == 2 + assert "infra " in res.stdout + + +def test_host_permis_construieste_ssh(home): + res = run_cli(["pvemini", "pct", "list"], home) + assert res.returncode == 0 + assert "root@10.0.20.201" in res.stdout + assert res.stdout.rstrip().endswith("pct list") + assert "BatchMode=yes" in res.stdout + + +def test_host_necunoscut_refuzat(home): + res = run_cli(["10.0.20.99", "uptime"], home) + assert res.returncode == 3 + assert "host necunoscut" in res.stderr + assert "10.0.20.99" not in res.stdout # nu a construit nicio comanda ssh + + +def test_host_necunoscut_nu_incearca_dns(home): + res = run_cli(["router.local", "reboot"], home) + assert res.returncode == 3 + assert "Hosturi permise" in res.stderr + + +def test_comanda_lipsa_refuzata(home): + res = run_cli(["pvemini"], home) + assert res.returncode == 2 + assert "lipseste comanda" in res.stderr + + +# ----------------------------------------------------------------- jurnalizare + +def test_apelul_e_jurnalizat(home): + run_cli(["oracle", "docker", "ps", "-a"], home) + log = (home / "logs" / "infra.log").read_text() + assert "host=oracle" in log + assert "target=root@10.0.20.121" in log + assert "docker ps -a" in log + assert "rc=dry-run" in log + + +def test_refuzul_e_jurnalizat(home): + run_cli(["host-strain", "rm", "-rf", "/"], home) + log = (home / "logs" / "infra.log").read_text() + assert "host=host-strain" in log + assert "rc=refuzat" in log + assert "host in afara listei" in log + assert "rm -rf /" in log + + +def test_comanda_completa_in_jurnal_cu_ghilimele(home): + run_cli(["docker", "sh", "-c", "echo unu doi"], home) + log = (home / "logs" / "infra.log").read_text() + assert "'echo unu doi'" in log # shlex.join pastreaza argumentul intreg + + +# ------------------------------------------------------- lista configurabila + +def test_fisier_de_hosturi_propriu_inlocuieste_lista(home): + (home / "infra-hosts.json").write_text( + json.dumps({"labo": {"addr": "10.9.9.9", "user": "test", "desc": "laborator"}}) + ) + res = run_cli(["labo", "uptime"], home) + assert res.returncode == 0 + assert "test@10.9.9.9" in res.stdout + # hosturile implicite nu mai sunt valabile daca fisierul exista + res2 = run_cli(["pvemini", "uptime"], home) + assert res2.returncode == 3 + + +def test_fisier_de_hosturi_corupt_opreste_totul(home): + (home / "infra-hosts.json").write_text("{ nu e json") + res = run_cli(["pvemini", "uptime"], home) + assert res.returncode == 4 + assert "nu e JSON valid" in res.stderr + + +def test_intrare_invalida_in_fisierul_de_hosturi(home): + (home / "infra-hosts.json").write_text(json.dumps({"x": {"user": "root"}})) + res = run_cli(["x", "uptime"], home) + assert res.returncode == 4 + assert "intrare invalida" in res.stderr + + +def test_forma_scurta_addr_ca_string(home): + (home / "infra-hosts.json").write_text(json.dumps({"scurt": "10.1.2.3"})) + res = run_cli(["scurt", "uptime"], home) + assert res.returncode == 0 + assert "root@10.1.2.3" in res.stdout + + +# ------------------------------------------------------------- lista implicita + +def test_toate_hosturile_implicite_au_adresa(): + mod = load_infra() + for name, spec in mod.DEFAULT_HOSTS.items(): + assert spec["addr"].count(".") == 3, name + assert spec["user"], name + # hosturile de productie sunt marcate ca atare + assert mod.DEFAULT_HOSTS["oracle-prod"]["prod"] is True + assert mod.DEFAULT_HOSTS["pvemini"]["prod"] is True diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_limits.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_limits.py new file mode 100644 index 0000000..cf803e8 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_limits.py @@ -0,0 +1,118 @@ +"""T8: 4 procese, coada per fir, rate limit, plafon de cost.""" + +from __future__ import annotations + +import asyncio + +import pytest + +import limits as L + + +def mkl(store=None, **kw): + kw.setdefault("alerter", lambda *a, **k: None) + return L.Limits(store, **kw) + + +async def test_maxim_4_procese_al_cincilea_asteapta(): + lim = mkl(max_procs=4) + held = [] + ev = asyncio.Event() + + async def hold(i): + async with lim.process_slot(): + held.append(i) + await ev.wait() + + tasks = [asyncio.create_task(hold(i)) for i in range(5)] + await asyncio.sleep(0.05) + assert len(held) == 4 and lim.free_slots == 0 + ev.set() + await asyncio.gather(*tasks) + assert len(held) == 5 and lim.free_slots == 4 + + +async def test_coada_per_fir_serializeaza_acelasi_fir(): + lim = mkl() + ordine = [] + + async def tur(tag, delay): + async with lim.thread_lock("1"): + ordine.append(f"start-{tag}") + await asyncio.sleep(delay) + ordine.append(f"stop-{tag}") + + await asyncio.gather(tur("a", 0.02), tur("b", 0.0)) + assert ordine == ["start-a", "stop-a", "start-b", "stop-b"] + + +async def test_fire_diferite_merg_in_paralel(): + lim = mkl() + async with lim.thread_lock("1"): + assert lim.queued("1") and not lim.queued("2") + async with lim.thread_lock("2"): + pass + + +def test_rate_limit_per_user(): + t = [1000.0] + lim = mkl(rate_per_min=3, clock=lambda: t[0]) + for _ in range(3): + lim.admit("u1") + with pytest.raises(L.RateLimited) as exc: + lim.admit("u1") + assert exc.value.retry_after == pytest.approx(60.0) + lim.admit("u2") # alt utilizator nu e afectat + t[0] += 61.0 + lim.admit("u1") # fereastra a trecut + + +def test_plafon_de_cost_opreste_botul(store): + lim = mkl(store, cost_cap=1.0) + assert not lim.stopped() and lim.cost_remaining() == pytest.approx(1.0) + lim.record_cost(0.4, "1") + lim.admit("u1") + assert lim.record_cost(0.7, "1") == pytest.approx(1.1) + assert lim.stopped() and lim.cost_remaining() == 0.0 + with pytest.raises(L.CostCapReached): + lim.admit("u1") + + +def test_plafon_alerteaza_o_singura_data(store): + alerte = [] + lim = mkl(store, cost_cap=0.5, alerter=lambda *a, **k: alerte.append(a)) + lim.record_cost(0.6, "1") + lim.record_cost(0.1, "1") + assert len(alerte) == 1 and alerte[0][0] == "CRITICAL" + + +def test_plafonul_se_ridica_a_doua_zi(store): + lim = mkl(store, cost_cap=1.0) + lim.record_cost(2.0, "1") + assert lim.stopped() + store.state["cost"]["day"] = "2000-01-01" # ziua se schimba + assert not lim.stopped() + + +def test_costul_fara_store_e_local(): + lim = mkl(None, cost_cap=1.0) + lim.record_cost(0.5) + lim.record_cost("gunoi") + assert lim.cost_today() == pytest.approx(0.5) + + +def test_valorile_implicite_vin_din_config(state_dir): + import config + (state_dir / "env").write_text("COST_CAP_USD_DAY=2.5\nMAX_PROCS=2 # comentariu\n") + config.reload(state_dir) + lim = mkl() + assert lim.cost_cap == 2.5 and lim.max_procs == 2 + assert lim.turn_timeout == 900.0 + + +async def test_contextul_turn_verifica_tot(store): + lim = mkl(store, max_procs=1, cost_cap=10.0, turn_timeout=42.0) + async with lim.turn("1", "u1") as timeout: + assert timeout == 42.0 + assert lim.free_slots == 0 + assert lim.free_slots == 1 diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_render.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_render.py new file mode 100644 index 0000000..0acf55e --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_render.py @@ -0,0 +1,137 @@ +"""T10: chunker si loop de editare per canal.""" + +from __future__ import annotations + +import asyncio + +import pytest + +import render + + +def test_text_scurt_ramane_intr_o_bucata(): + r = render.split_message("salut") + assert r.kind == "chunks" and r.parts == ["salut"] + + +def test_sparge_la_2000_de_caractere(): + text = "\n".join(f"linia {i} " + "x" * 50 for i in range(60)) + r = render.split_message(text) + assert r.kind == "chunks" and len(r.parts) > 1 + assert all(len(p) <= render.MAX_MSG for p in r.parts) + assert "linia 0" in r.parts[0] and "linia 59" in r.parts[-1] + + +def test_fence_nu_se_rupe_peste_granita(): + corp = "\n".join(f"cod linia {i}" for i in range(300)) + r = render.split_message(f"intro\n```python\n{corp}\n```\ngata") + assert len(r.parts) >= 2 + for p in r.parts: + assert p.count("```") % 2 == 0 # fiecare bucata are fence-uri echilibrate + assert r.parts[0].endswith("```") + assert r.parts[1].startswith("```python") + assert all(len(p) <= render.MAX_MSG for p in r.parts) + + +def test_fence_fara_limbaj(): + corp = "\n".join(f"linia {i}" for i in range(400)) + r = render.split_message(f"```\n{corp}\n```") + assert r.parts[1].startswith("```\n") + + +def test_linie_unica_uriasa_se_taie(): + r = render.split_message("y" * 5000) + assert r.kind == "chunks" and all(len(p) <= render.MAX_MSG for p in r.parts) + + +def test_peste_6000_devine_atasament(): + r = render.split_message("z" * 6001) + assert r.kind == "attachment" + assert isinstance(r, render.Attachment) + assert r.content == "z" * 6001 and r.filename.endswith(".md") + assert len(r.preview) <= render.MAX_MSG + + +def test_text_gol(): + assert render.split_message("").parts == [""] + + +def test_footer(): + s = render.footer("sonnet", 34500, 0.1547, 1.2) + assert "sonnet" in s and "34.5s" in s and "0.1547" in s and "1.2000" in s + + +# ------------------------------------------------------------- edit loop +async def test_loop_coalesceaza_actualizarile(): + edits = [] + + async def edit(target, text): + edits.append((target, text)) + + loop = render.ChannelEditLoop("c1", edit, min_interval=0.05, max_interval=0.2) + loop.start() + for i in range(20): + loop.queue("msg1", f"text {i}") + await asyncio.sleep(0.15) + await loop.stop() + assert len(edits) < 20 # nu s-a trimis fiecare actualizare + assert edits[-1] == ("msg1", "text 19") # ultima versiune ajunge + + +async def test_flush_trimite_tot_ce_a_ramas(): + edits = [] + loop = render.ChannelEditLoop("c1", lambda t, x: _append(edits, t, x), + min_interval=10, max_interval=10) + loop.queue("m1", "a") + loop.queue("m2", "b") + await loop.flush() + assert sorted(edits) == [("m1", "a"), ("m2", "b")] + + +async def _append(lst, t, x): + lst.append((t, x)) + + +async def test_intervalul_creste_sub_presiune_si_scade_la_liniste(): + loop = render.ChannelEditLoop("c1", lambda t, x: _append([], t, x), + min_interval=0.01, max_interval=0.2) + loop.start() + for i in range(10): + loop.queue(f"m{i}", "x") + await asyncio.sleep(0.1) + crescut = loop.interval + assert crescut > 0.01 + loop.note_rate_limited() + assert loop.interval == 0.2 + await asyncio.sleep(0.5) + await loop.stop() + assert loop.interval < 0.2 # linistea coboara intervalul + + +async def test_editarea_esuata_nu_doboara_loop_ul(): + async def edit(target, text): + raise RuntimeError("429 sau altceva") + + loop = render.ChannelEditLoop("c1", edit, min_interval=0.01, max_interval=0.05) + loop.start() + loop.queue("m1", "a") + await asyncio.sleep(0.1) + assert loop.errors >= 1 + loop.queue("m1", "b") + await asyncio.sleep(0.1) + assert loop.errors >= 2 # loop-ul inca traieste + await loop.stop() + + +async def test_un_singur_loop_per_canal(): + edits = [] + mgr = render.RenderManager(lambda t, x: _append(edits, t, x), + min_interval=0.01, max_interval=0.05) + l1 = mgr.loop_for("canal-1") + assert mgr.loop_for("canal-1") is l1 + assert mgr.loop_for("canal-2") is not l1 + mgr.queue("canal-1", "m1", "salut") + await asyncio.sleep(0.08) + assert ("m1", "salut") in edits + await mgr.stop_all() + assert mgr.loops == {} diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_runner.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_runner.py new file mode 100644 index 0000000..61782c5 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_runner.py @@ -0,0 +1,222 @@ +"""T4 + T5: proces persistent, steering, respawn, reaper, EOF, timeout.""" + +from __future__ import annotations + +import asyncio +import os + +import pytest + +import runner +import stream + + +def mk(fake_bin, store=None, **kw): + return runner.ClaudeProcess("1", os.getcwd(), "sonnet", claude_bin=fake_bin, + on_pid=(store.set_pid if store else None), **kw) + + +def test_build_cmd_are_toate_flagurile(): + cmd = runner.build_cmd("claude", "opus", None, "/x/settings.json") + assert cmd[:2] == ["claude", "-p"] + for flag in ("--input-format", "--output-format", "--verbose", "--permission-mode", + "--settings", "--model", "--autocompact"): + assert flag in cmd + assert cmd[cmd.index("--permission-mode") + 1] == "bypassPermissions" + assert "--resume" not in cmd + assert "--resume" in runner.build_cmd(["python", "fake"], "sonnet", "sid-1") + + +async def test_tur_normal_si_proces_persistent(fake_bin, scenario): + scenario("normal") + p = mk(fake_bin) + out = await p.run_turn("salut") + assert out.result.text == "ecou: salut" and out.result.total_cost_usd == pytest.approx(0.0123) + assert p.sid == "sid-fake-0001" and not out.restarted + pid = p.pid + out2 = await p.run_turn("inca unul") + assert out2.result.text == "ecou: inca unul" + assert p.pid == pid and p.alive # ACELASI proces pentru turul urmator + await p.stop() + assert not p.alive + + +async def test_evenimentele_ajung_la_callback(fake_bin, scenario): + scenario("tools") + p = mk(fake_bin) + seen = [] + await p.run_turn("fa ceva", on_event=lambda ev: (seen.append(ev), asyncio.sleep(0))[1]) + tipuri = [type(e) for e in seen] + assert stream.SystemInit in tipuri and stream.ToolUse in tipuri + assert stream.ToolResult in tipuri and tipuri[-1] is stream.Result + await p.stop() + + +async def test_stream_tolerant_in_tur_real(fake_bin, scenario): + scenario("unknown") + p = mk(fake_bin) + out = await p.run_turn("salut") # tip necunoscut + linie non-JSON pe mijloc + assert out.result.text == "ecou: salut" + await p.stop() + + +async def test_steering_mid_tur(fake_bin, scenario): + """Mesaj trimis in timp ce turul ruleaza ajunge la proces inainte de result.""" + scenario("slow", FAKE_CLAUDE_DELAY=0.6) + p = mk(fake_bin) + + async def steer(): + await asyncio.sleep(0.15) + await p.send("de fapt, opreste-te") + + task = asyncio.create_task(steer()) + out = await p.run_turn("porneste ceva lung") + await task + assert "porneste ceva lung" in out.result.text + assert "de fapt, opreste-te" in out.result.text + await p.stop() + + +async def test_eof_inainte_de_result_da_turn_failed(fake_bin, scenario): + scenario("eof") + p = mk(fake_bin) + with pytest.raises(runner.TurnFailed): + await p.run_turn("salut") + assert not p.alive + + +async def test_crash_pastreaza_stderr_pentru_status(fake_bin, scenario): + scenario("crash") + p = mk(fake_bin) + with pytest.raises(runner.TurnFailed) as exc: + await p.run_turn("salut") + assert "boom" in str(exc.value) + + +async def test_stderr_buffer_circular(fake_bin, scenario): + scenario("crash") + p = mk(fake_bin) + with pytest.raises(runner.TurnFailed): + await p.run_turn("x") + assert p.stderr_buf.maxlen == runner.STDERR_TAIL + + +async def test_respawn_transparent_cu_resume(fake_bin, scenario, store): + scenario("normal") + p = mk(fake_bin, store=store) + await p.run_turn("primul") + sid = p.sid + p.proc.kill() # OOM simulat + await p.proc.wait() + p.proc = None + out = await p.run_turn("al doilea") + assert out.restarted is True + restart_ev = [e for e in out.events if isinstance(e, runner.SessionRestarted)] + assert restart_ev and "sesiune repornita" in restart_ev[0].text + assert p.sid == sid # --resume a pastrat sesiunea + assert p.restarts == 1 + await p.stop() + + +async def test_timeout_de_tur_omoara_procesul(fake_bin, scenario): + scenario("slow", FAKE_CLAUDE_DELAY=5) + p = mk(fake_bin) + with pytest.raises(runner.TurnTimeout): + await p.run_turn("lung", timeout=0.3) + assert not p.alive + + +async def test_send_pe_proces_mort_da_eroare(fake_bin, scenario): + scenario("normal") + p = mk(fake_bin) + with pytest.raises(runner.TurnFailed): + await p.send("nimeni nu asculta") + + +async def test_pid_ul_ajunge_in_state(fake_bin, scenario, store): + scenario("normal") + m = runner.RunnerManager(store, claude_bin=fake_bin) + p = m.get("42", os.getcwd(), "sonnet") + await p.run_turn("salut") + assert store.thread("42")["pid"] == p.pid + assert store.thread_process_alive("42") + await m.stop_all() + assert store.thread("42")["pid"] is None + + +async def test_reaper_omoara_inactivii_dar_nu_turul_in_zbor(fake_bin, scenario, store): + scenario("normal") + m = runner.RunnerManager(store, claude_bin=fake_bin, idle_s=0.0) + a = m.get("a", os.getcwd(), "sonnet") + b = m.get("b", os.getcwd(), "sonnet") + await a.run_turn("x") + await b.run_turn("y") + store.set_inflight("b", "t", "u", "m") # firul b are tur in zbor + killed = await m.reap_once() + assert killed == ["a"] + assert not a.alive and b.alive + store.clear_inflight("b") + assert await m.reap_once() == ["b"] + await m.stop_all() + + +async def test_reaper_nu_taie_daca_procesul_e_activ(fake_bin, scenario): + scenario("normal") + m = runner.RunnerManager(None, claude_bin=fake_bin, idle_s=60.0) + p = m.get("a", os.getcwd(), "sonnet") + await p.run_turn("x") + assert await m.reap_once() == [] and p.alive + await m.stop_all() + + +async def test_set_options_opreste_procesul_iar_resume_pastreaza_sesiunea(fake_bin, scenario): + scenario("normal") + m = runner.RunnerManager(None, claude_bin=fake_bin) + p = m.get("a", os.getcwd(), "sonnet") + await p.run_turn("x") + sid = p.sid + assert await m.set_options("a", model="opus") is True + assert not p.alive and p.model == "opus" + out = await p.run_turn("y") + assert out.restarted and p.sid == sid + await m.stop_all() + + +async def test_reset_new_sterge_sesiunea(fake_bin, scenario): + scenario("normal") + m = runner.RunnerManager(None, claude_bin=fake_bin) + p = m.get("a", os.getcwd(), "sonnet") + await p.run_turn("x") + await m.reset("a") + assert p.sid is None and not p.alive + await m.stop_all() + + +async def test_live_count_si_stop_all(fake_bin, scenario): + scenario("normal") + m = runner.RunnerManager(None, claude_bin=fake_bin) + for tid in ("a", "b"): + await m.get(tid, os.getcwd(), "sonnet").run_turn("x") + assert m.live_count() == 2 + await m.stop_all() + assert m.live_count() == 0 + + +async def test_thread_id_ajunge_in_mediul_procesului(fake_bin, scenario, monkeypatch): + """Lane B: hook-ul PreToolUse citeste firul din CLAUDE_DISCORD_THREAD_ID.""" + scenario("env") + monkeypatch.setenv("MARKER_DE_TEST", "pastrat") + p = runner.ClaudeProcess("fir-777", os.getcwd(), "sonnet", claude_bin=fake_bin) + out = await p.run_turn("x") + assert "thread=fir-777" in out.result.text + assert "sesiune=-" in out.result.text # inca nu avem sid la prima pornire + assert "marker=pastrat" in out.result.text # restul mediului ramane intact + await p.stop() + + +async def test_session_id_ajunge_in_mediu_la_respawn(fake_bin, scenario): + scenario("env") + p = runner.ClaudeProcess("fir-888", os.getcwd(), "sonnet", sid="sid-vechi", claude_bin=fake_bin) + out = await p.run_turn("x") + assert "thread=fir-888" in out.result.text and "sesiune=sid-vechi" in out.result.text + await p.stop() diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_session_store.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_session_store.py new file mode 100644 index 0000000..92edff1 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_session_store.py @@ -0,0 +1,127 @@ +"""T7 + T5: scriere atomica, lock per fir, PID reuse, recovery, sweep.""" + +from __future__ import annotations + +import json +import os +import time + +import pytest + +import session_store as ss + + +def test_roundtrip_si_scriere_atomica(store, state_dir): + store.update_thread("111", sid="s1", cwd="/workspace/x", model="opus") + raw = json.loads((state_dir / "state.json").read_text()) + assert raw["threads"]["111"]["sid"] == "s1" + assert raw["version"] == ss.VERSION and "cost" in raw + # nu raman fisiere temporare in urma + assert not [p for p in state_dir.iterdir() if p.name.startswith(".state.")] + + alt = ss.SessionStore(state_dir / "state.json") + alt.load() + assert alt.thread("111")["model"] == "opus" + + +def test_fisier_corupt_e_pus_deoparte_si_alerteaza(state_dir): + (state_dir / "state.json").write_text('{"threads": {"a": ') + alerts = [] + s = ss.SessionStore(state_dir / "state.json", alerter=lambda *a, **k: alerts.append(a)) + st = s.load() + assert st["threads"] == {} + assert alerts and alerts[0][0] == "CRITICAL" + assert [p for p in state_dir.iterdir() if ".corrupt-" in p.name] + # fisierul nou e valid + assert json.loads((state_dir / "state.json").read_text())["threads"] == {} + + +def test_json_valid_dar_structura_gresita(state_dir): + (state_dir / "state.json").write_text('["nu", "e", "obiect"]') + s = ss.SessionStore(state_dir / "state.json", alerter=lambda *a, **k: None) + assert s.load()["threads"] == {} + + +def test_campuri_necunoscute_sunt_ignorate_la_incarcare(state_dir): + (state_dir / "state.json").write_text(json.dumps( + {"version": 1, "threads": {"7": {"sid": "s", "ceva_nou": 1}}, "cost": {"day": "2026-01-01", "usd": 3.0}})) + s = ss.SessionStore(state_dir / "state.json") + st = s.load() + assert st["threads"]["7"]["sid"] == "s" and "ceva_nou" not in st["threads"]["7"] + assert st["threads"]["7"]["inflight"] is None + + +def test_pid_start_time_si_pid_reuse(): + me = os.getpid() + start = ss.pid_start_time(me) + assert start and start > 0 + assert ss.pid_alive(me, start) is True + # acelasi PID cu alt starttime = PID reciclat, NU e procesul nostru + assert ss.pid_alive(me, start + 500.0) is False + assert ss.pid_alive(None) is False + assert ss.pid_alive(4194303, 1.0) is False + + +def test_thread_process_alive(store): + me = os.getpid() + store.set_pid("1", me) + assert store.thread_process_alive("1") is True + store.update_thread("1", pid_start_time=ss.pid_start_time(me) + 100) + assert store.thread_process_alive("1") is False + + +def test_sweep_marcheaza_turul_pierdut_fara_reluare(store): + store.update_thread("1", pid=4194303, pid_start_time=1.0) + store.set_inflight("1", "t1", "u1", "m1") + lost = store.sweep_lost_turns() + assert len(lost) == 1 + assert lost[0]["thread_id"] == "1" and lost[0]["message_id"] == "m1" + assert "NU il reiau automat" in lost[0]["warning"] + assert store.thread("1")["inflight"] is None + assert store.sweep_lost_turns() == [] # idempotent + + +def test_sweep_nu_atinge_un_proces_viu(store): + store.set_pid("1", os.getpid()) + store.set_inflight("1", "t1", "u1", "m1") + assert store.sweep_lost_turns() == [] + assert store.is_inflight("1") + + +def test_cost_pe_fir_si_pe_zi(store): + store.add_cost("1", 0.1547) + store.add_cost("1", 0.05) + store.add_cost("2", 1.0) + assert store.thread("1")["cost_usd_total"] == pytest.approx(0.2047) + assert store.cost_today() == pytest.approx(1.2047) + store.add_cost("1", "nu-i numar") + assert store.cost_today() == pytest.approx(1.2047) + + +def test_cost_se_reseteaza_la_zi_noua(store): + store.add_cost("1", 5.0) + store.state["cost"]["day"] = "2000-01-01" + assert store.cost_today() == 0.0 + assert store.state["cost"]["day"] == ss.today() + + +async def test_lock_per_fir_e_distinct(store): + a, b = store.lock_for("1"), store.lock_for("2") + assert a is store.lock_for("1") and a is not b + async with a: + assert a.locked() and not b.locked() + + +def test_lock_de_fisier_intre_procese(state_dir): + s = ss.SessionStore(state_dir / "state.json") + s.load() + s.save() + assert (state_dir / "state.json.lock").exists() + + +def test_inflight_set_si_clear(store): + store.set_inflight("9", "t", "u", "m") + assert store.is_inflight("9") + assert store.thread("9")["inflight"]["started_at"] <= time.time() + store.clear_inflight("9") + assert not store.is_inflight("9") diff --git a/proxmox/lxc171-claude-agent/discord-bridge/tests/test_stream.py b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_stream.py new file mode 100644 index 0000000..1f62e30 --- /dev/null +++ b/proxmox/lxc171-claude-agent/discord-bridge/tests/test_stream.py @@ -0,0 +1,122 @@ +"""T6: parser tolerant.""" + +from __future__ import annotations + +import json + +import pytest + +import stream + + +def P(**kw): + return stream.StreamParser(**kw) + + +def test_system_init_aduce_session_id(): + p = P() + (ev,) = p.feed_line(json.dumps({"type": "system", "subtype": "init", + "session_id": "s1", "model": "sonnet", "cwd": "/workspace", + "tools": ["Bash"]})) + assert isinstance(ev, stream.SystemInit) + assert ev.session_id == "s1" and ev.model == "sonnet" and ev.tools == ("Bash",) + + +def test_assistant_text_si_tool_use(): + p = P() + evs = p.feed_line(json.dumps({"type": "assistant", "session_id": "s1", "message": {"content": [ + {"type": "text", "text": "salut"}, + {"type": "tool_use", "id": "tu1", "name": "Bash", "input": {"command": "ls"}}]}})) + assert [type(e) for e in evs] == [stream.AssistantText, stream.ToolUse] + assert evs[0].text == "salut" + assert evs[1].name == "Bash" and evs[1].input["command"] == "ls" + + +def test_tool_result_din_user(): + p = P() + (ev,) = p.feed_line(json.dumps({"type": "user", "message": {"content": [ + {"type": "tool_result", "tool_use_id": "tu1", "content": [{"type": "text", "text": "out"}]}]}})) + assert isinstance(ev, stream.ToolResult) and ev.text == "out" and ev.is_error is False + + +def test_result_aduce_cost_si_durata(): + p = P() + (ev,) = p.feed_line(json.dumps({"type": "result", "subtype": "success", "is_error": False, + "total_cost_usd": 0.1547, "duration_ms": 34500, + "num_turns": 3, "result": "gata"})) + assert (ev.total_cost_usd, ev.duration_ms, ev.num_turns, ev.text) == (0.1547, 34500, 3, "gata") + assert ev.is_error is False and p.saw_result + + +def test_result_cu_campuri_stricate_nu_crapa(): + p = P() + (ev,) = p.feed_line(json.dumps({"type": "result", "total_cost_usd": "nu-i numar", + "duration_ms": None, "num_turns": "x"})) + assert ev.total_cost_usd == 0.0 and ev.duration_ms == 0 and ev.num_turns == 0 + + +def test_result_de_eroare_marcat(): + p = P() + (ev,) = p.feed_line(json.dumps({"type": "result", "subtype": "error_during_execution"})) + assert ev.is_error is True + + +def test_tip_necunoscut_logat_o_singura_data(): + calls = [] + p = P(version_fn=lambda: (calls.append(1), "2.1.251")[1]) + line = json.dumps({"type": "rate_limit_event", "detail": "x"}) + assert p.feed_line(line) == [] + assert p.feed_line(line) == [] + assert p.feed_line(json.dumps({"type": "alt_tip_nou"})) == [] + assert p.unknown_types == {"rate_limit_event", "alt_tip_nou"} + assert len(calls) == 2 # o data per tip, nu per linie + + +def test_linie_non_json_ignorata(caplog): + p = P() + with caplog.at_level("WARNING"): + assert p.feed_line("Error: something went wrong") == [] + assert p.feed_line("[1, 2, 3]") == [] + assert p.bad_lines == 2 + assert any("non-JSON" in r.message or "nu e obiect" in r.message for r in caplog.records) + + +def test_linii_goale_ignorate(): + p = P() + assert p.feed_line("") == [] and p.feed_line(" \n") == [] + + +async def _aiter(lines): + for x in lines: + yield x + + +async def test_eof_inainte_de_result_da_eroare_explicita(): + p = P() + got = [] + with pytest.raises(stream.StreamEOFError): + async for ev in p.aiter_events(_aiter([ + json.dumps({"type": "system", "subtype": "init", "session_id": "s1"}), + json.dumps({"type": "assistant", "message": {"content": [{"type": "text", "text": "hm"}]}}), + ])): + got.append(ev) + assert len(got) == 2 + + +async def test_aiter_se_opreste_la_result(): + p = P() + evs = [e async for e in p.aiter_events(_aiter([ + json.dumps({"type": "system", "subtype": "init", "session_id": "s1"}), + json.dumps({"type": "result", "total_cost_usd": 1.0}), + json.dumps({"type": "assistant", "message": {"content": [{"type": "text", "text": "dupa"}]}}), + ]))] + assert [type(e) for e in evs] == [stream.SystemInit, stream.Result] + + +async def test_aiter_accepta_bytes_si_gunoi(): + p = P() + evs = [e async for e in p.aiter_events(_aiter([ + b"gunoi binar\n", + b'{"type":"result","total_cost_usd":0.5}\n', + ]))] + assert len(evs) == 1 and evs[0].total_cost_usd == 0.5