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:
@@ -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`
|
||||
|
||||
@@ -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)) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
3
proxmox/lxc171-claude-agent/discord-bridge/.gitignore
vendored
Normal file
3
proxmox/lxc171-claude-agent/discord-bridge/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
134
proxmox/lxc171-claude-agent/discord-bridge/INTERFACES.md
Normal file
134
proxmox/lxc171-claude-agent/discord-bridge/INTERFACES.md
Normal 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.
|
||||
311
proxmox/lxc171-claude-agent/discord-bridge/README.md
Normal file
311
proxmox/lxc171-claude-agent/discord-bridge/README.md
Normal 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 |
|
||||
300
proxmox/lxc171-claude-agent/discord-bridge/alerts.py
Normal file
300
proxmox/lxc171-claude-agent/discord-bridge/alerts.py
Normal 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}")
|
||||
703
proxmox/lxc171-claude-agent/discord-bridge/bot.py
Normal file
703
proxmox/lxc171-claude-agent/discord-bridge/bot.py
Normal 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())
|
||||
435
proxmox/lxc171-claude-agent/discord-bridge/cleanup.py
Normal file
435
proxmox/lxc171-claude-agent/discord-bridge/cleanup.py
Normal 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))
|
||||
123
proxmox/lxc171-claude-agent/discord-bridge/config.py
Normal file
123
proxmox/lxc171-claude-agent/discord-bridge/config.py
Normal 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()
|
||||
164
proxmox/lxc171-claude-agent/discord-bridge/limits.py
Normal file
164
proxmox/lxc171-claude-agent/discord-bridge/limits.py
Normal 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
|
||||
@@ -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
|
||||
45
proxmox/lxc171-claude-agent/discord-bridge/ops/env.example
Normal file
45
proxmox/lxc171-claude-agent/discord-bridge/ops/env.example
Normal 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
|
||||
151
proxmox/lxc171-claude-agent/discord-bridge/ops/install.sh
Executable file
151
proxmox/lxc171-claude-agent/discord-bridge/ops/install.sh
Executable 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 =="
|
||||
@@ -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
|
||||
}
|
||||
9
proxmox/lxc171-claude-agent/discord-bridge/pytest.ini
Normal file
9
proxmox/lxc171-claude-agent/discord-bridge/pytest.ini
Normal 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
|
||||
209
proxmox/lxc171-claude-agent/discord-bridge/render.py
Normal file
209
proxmox/lxc171-claude-agent/discord-bridge/render.py
Normal 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"
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
-r requirements.txt
|
||||
pytest>=8.0
|
||||
pytest-asyncio>=0.24
|
||||
@@ -0,0 +1,2 @@
|
||||
# Puntea Discord -> Claude Code. Nucleul e stdlib; discord.py e doar pentru adaptor.
|
||||
discord.py==2.4.0
|
||||
398
proxmox/lxc171-claude-agent/discord-bridge/runner.py
Normal file
398
proxmox/lxc171-claude-agent/discord-bridge/runner.py
Normal 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]
|
||||
282
proxmox/lxc171-claude-agent/discord-bridge/security/README.md
Normal file
282
proxmox/lxc171-claude-agent/discord-bridge/security/README.md
Normal 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.
|
||||
@@ -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.
|
||||
"""
|
||||
350
proxmox/lxc171-claude-agent/discord-bridge/security/approvals.py
Normal file
350
proxmox/lxc171-claude-agent/discord-bridge/security/approvals.py
Normal 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)
|
||||
@@ -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:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
192
proxmox/lxc171-claude-agent/discord-bridge/security/infra
Executable file
192
proxmox/lxc171-claude-agent/discord-bridge/security/infra
Executable 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)
|
||||
329
proxmox/lxc171-claude-agent/discord-bridge/session_store.py
Normal file
329
proxmox/lxc171-claude-agent/discord-bridge/session_store.py
Normal 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"])
|
||||
260
proxmox/lxc171-claude-agent/discord-bridge/stream.py
Normal file
260
proxmox/lxc171-claude-agent/discord-bridge/stream.py
Normal 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`"
|
||||
)
|
||||
64
proxmox/lxc171-claude-agent/discord-bridge/tests/conftest.py
Normal file
64
proxmox/lxc171-claude-agent/discord-bridge/tests/conftest.py
Normal 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")
|
||||
122
proxmox/lxc171-claude-agent/discord-bridge/tests/fake_claude.py
Executable file
122
proxmox/lxc171-claude-agent/discord-bridge/tests/fake_claude.py
Executable 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())
|
||||
281
proxmox/lxc171-claude-agent/discord-bridge/tests/test_alerts.py
Normal file
281
proxmox/lxc171-claude-agent/discord-bridge/tests/test_alerts.py
Normal 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")
|
||||
@@ -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 == {}
|
||||
269
proxmox/lxc171-claude-agent/discord-bridge/tests/test_bot.py
Normal file
269
proxmox/lxc171-claude-agent/discord-bridge/tests/test_bot.py
Normal 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
|
||||
313
proxmox/lxc171-claude-agent/discord-bridge/tests/test_cleanup.py
Normal file
313
proxmox/lxc171-claude-agent/discord-bridge/tests/test_cleanup.py
Normal 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"
|
||||
@@ -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() != ""
|
||||
@@ -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() == ""
|
||||
@@ -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()
|
||||
171
proxmox/lxc171-claude-agent/discord-bridge/tests/test_infra.py
Normal file
171
proxmox/lxc171-claude-agent/discord-bridge/tests/test_infra.py
Normal 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
|
||||
118
proxmox/lxc171-claude-agent/discord-bridge/tests/test_limits.py
Normal file
118
proxmox/lxc171-claude-agent/discord-bridge/tests/test_limits.py
Normal 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
|
||||
137
proxmox/lxc171-claude-agent/discord-bridge/tests/test_render.py
Normal file
137
proxmox/lxc171-claude-agent/discord-bridge/tests/test_render.py
Normal 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 == {}
|
||||
222
proxmox/lxc171-claude-agent/discord-bridge/tests/test_runner.py
Normal file
222
proxmox/lxc171-claude-agent/discord-bridge/tests/test_runner.py
Normal 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()
|
||||
@@ -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")
|
||||
122
proxmox/lxc171-claude-agent/discord-bridge/tests/test_stream.py
Normal file
122
proxmox/lxc171-claude-agent/discord-bridge/tests/test_stream.py
Normal 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
|
||||
Reference in New Issue
Block a user