feat(discord-bridge): punte Discord -> Claude Code pe LXC 171

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B29CApsP1JkSdjYaGaHpE7
This commit is contained in:
Claude Agent
2026-08-30 10:44:39 +00:00
parent d7b4007af8
commit d466f358ce
43 changed files with 7767 additions and 0 deletions

View File

@@ -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`

View File

@@ -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)) |
---

View File

@@ -0,0 +1,3 @@
__pycache__/
*.pyc
.pytest_cache/

View File

@@ -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": {
"<discord_thread_or_channel_id>": {
"sid": "uuid sesiune claude sau null",
"cwd": "/workspace/<proiect>",
"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/<pid>/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.

View File

@@ -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 <sid> --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 <sid>`, 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 <cale>` | Schimba directorul de lucru al firului (ex. `!cd /workspace/romfastsql`) |
| `!model <sonnet\|opus>` | 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 |

View File

@@ -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 "<subject>" <recipient>`. 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}")

View File

@@ -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 <cale>` schimba directorul de lucru\n"
"`!model <sonnet|opus>` 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 <cale>`.")
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())

View File

@@ -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/<pid>/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/<pid>/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/<pid>/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))

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 =="

View File

@@ -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
}

View File

@@ -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

View File

@@ -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"
)

View File

@@ -0,0 +1,3 @@
-r requirements.txt
pytest>=8.0
pytest-asyncio>=0.24

View File

@@ -0,0 +1,2 @@
# Puntea Discord -> Claude Code. Nucleul e stdlib; discord.py e doar pentru adaptor.
discord.py==2.4.0

View File

@@ -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 <sid>` 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]

View File

@@ -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/<request_id>.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/<request_id>.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/<request_id>.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/<id> 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=<secretul afisat la pasul 4>
```
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.

View File

@@ -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.
"""

View File

@@ -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/<request_id>.json cerere in asteptare
~/.claude-discord/approvals/done/<request_id>.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)

View File

@@ -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:*)"
]
}
}

View File

@@ -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())

View File

@@ -0,0 +1,192 @@
#!/usr/bin/env python3
"""infra -- singura poarta prin care puntea Discord atinge infrastructura.
infra <host> <comanda...> 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 <host> <comanda...>", "", "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)

View File

@@ -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/<pid>/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"])

View File

@@ -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`"
)

View File

@@ -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")

View File

@@ -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())

View File

@@ -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")

View File

@@ -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 == {}

View File

@@ -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

View File

@@ -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/<pid>, 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"

View File

@@ -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() != ""

View File

@@ -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() == ""

View File

@@ -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()

View File

@@ -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 <host>" 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

View File

@@ -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

View File

@@ -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 == {}

View File

@@ -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()

View File

@@ -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")

View File

@@ -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