sync: 194 file da Baida98/AI@3cf07303 (2026-08-30 07:30 UTC)

#151
agents/executor.py CHANGED
@@ -292,39 +292,15 @@ class Executor:
292
  _t0 = _time_mod.monotonic()
293
  result = await asyncio.wait_for(fn(**inputs), timeout=_adaptive_to)
294
  _timeout_tracker.record(tool_name, _time_mod.monotonic() - _t0)
295
-
296
- # Il tool ha già prodotto il side effect: la persistenza memoria
297
- # è osservabilità e non deve riaprire il retry del tool.
298
- _memory_persisted = True
299
- _memory_error = None
300
  if self.memory:
301
- try:
302
- # S577→S600: inputs 100→500 — parity con altri handler
303
- await self.memory.save_episode(
304
- "tool",
305
- f"{tool_name}: {str(inputs)[:500]}",
306
- str(result)[:500],
307
- True,
308
- )
309
- except Exception as _memory_exc:
310
- _memory_persisted = False
311
- _memory_error = f"{type(_memory_exc).__name__}: {str(_memory_exc)[:240]}"
312
- _logger.warning(
313
- "[executor] tool %s completato ma save_episode fallito; "
314
- "nessun retry del side effect: %s",
315
- tool_name,
316
- _memory_error,
317
- )
318
- response = {
319
- "success": True,
320
- "tool": tool_name,
321
- "output": result,
322
- "attempt": attempt + 1,
323
- "memory_persisted": _memory_persisted,
324
- }
325
- if _memory_error:
326
- response["memory_error"] = _memory_error
327
- return response
328
 
329
  except asyncio.TimeoutError:
330
  # FIX-GAP2: registra il timeout come durata massima per shrink futuro
 
292
  _t0 = _time_mod.monotonic()
293
  result = await asyncio.wait_for(fn(**inputs), timeout=_adaptive_to)
294
  _timeout_tracker.record(tool_name, _time_mod.monotonic() - _t0)
 
 
 
 
 
295
  if self.memory:
296
+ # S577→S600: inputs 100→500 — parity con altri handler
297
+ await self.memory.save_episode(
298
+ "tool",
299
+ f"{tool_name}: {str(inputs)[:500]}",
300
+ str(result)[:500],
301
+ True,
302
+ )
303
+ return {"success": True, "tool": tool_name, "output": result, "attempt": attempt + 1}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
304
 
305
  except asyncio.TimeoutError:
306
  # FIX-GAP2: registra il timeout come durata massima per shrink futuro
agents/unified_loop.py CHANGED
@@ -214,46 +214,37 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
214
  _logger.debug("[unified_loop] state callback silenced: %s", _state_callback_error)
215
 
216
  async def _rollback_writes(self, on_step=None) -> None:
217
- """Restore all writes from this run or report an incomplete rollback.
218
-
219
- A None snapshot means the file did not exist and must be removed.
220
- Failed restores remain tracked so a supervisor can retry or block the run.
 
221
  """
222
  if not self._write_snapshots or not self.executor:
223
  return
224
  if on_step:
225
  await _maybe_await(on_step({
226
  "action": "text_chunk",
227
- "token": f"\u23ea Rollback di {len(self._write_snapshots)} file modificati...\n",
228
  "status": "streaming",
229
  }))
230
-
231
- remaining: dict[str, str | None] = {}
232
- rolled = 0
233
- for path, original in list(self._write_snapshots.items()):
234
- tool_name = "delete_file" if original is None else "write_file"
235
- inputs = {"path": path} if original is None else {"path": path, "content": original}
236
  try:
237
- result = await asyncio.wait_for(
238
- self.executor.run_tool(tool_name, inputs),
239
  timeout=10.0,
240
  )
241
- payload = result.get("output") if isinstance(result, dict) else None
242
- nested_failed = isinstance(payload, dict) and payload.get("ok") is False
243
- if not isinstance(result, dict) or not result.get("success") or nested_failed:
244
- error = (payload or {}).get("error") if isinstance(payload, dict) else None
245
- raise RuntimeError(error or result.get("error", "rollback tool failed"))
246
- rolled += 1
247
- except Exception as exc:
248
- remaining[path] = original
249
- _logger.error("GAP-3 rollback fallito per %s: %s", path, str(exc)[:240])
250
-
251
- total = len(self._write_snapshots)
252
- self._write_snapshots = remaining
253
- _logger.info("GAP-3 rollback: %d/%d file ripristinati", rolled, total)
254
- if remaining:
255
- raise RuntimeError(f"VFS rollback incompleto: {len(remaining)}/{total} file non ripristinati")
256
 
 
257
  async def _vfs_git_backup(self) -> None:
258
  """GAP-NEW-4: Push _session_files al branch vfs-backup su GitHub.
259
 
@@ -1328,28 +1319,18 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
1328
  {"path": _wf_path, "content": _wf_generated} if rn == "write_file"
1329
  else {"path": _wf_path, "patch": _wf_generated}
1330
  )
1331
- # GAP-3: snapshot pre-write; read errors are not "file absent".
1332
  if rn == "write_file" and _wf_path not in self._write_snapshots:
1333
- _snap_r = await asyncio.wait_for(
1334
- self.executor.run_tool("read_file", {"path": _wf_path}),
1335
- timeout=4.0,
1336
- )
1337
- _snap_payload = _snap_r.get("output") if isinstance(_snap_r, dict) else None
1338
- _snap_failed = isinstance(_snap_payload, dict) and _snap_payload.get("ok") is False
1339
- if isinstance(_snap_payload, dict):
1340
- _snap_content = _snap_payload.get("content")
1341
- _snap_error = str(_snap_payload.get("error", ""))
1342
- else:
1343
- _snap_content = _snap_payload
1344
- _snap_error = str(_snap_r.get("error", "")) if isinstance(_snap_r, dict) else ""
1345
- if isinstance(_snap_content, str) and not _snap_failed:
1346
- self._write_snapshots[_wf_path] = _snap_content
1347
- elif "File non trovato" in _snap_error or "File not found" in _snap_error:
1348
- self._write_snapshots[_wf_path] = None
1349
- else:
1350
- raise RuntimeError(
1351
- f"Snapshot VFS non disponibile per {_wf_path}: {_snap_error[:240]}"
1352
  )
 
 
 
 
 
1353
  # GAP-VFS: lock per-path — serializza scritture parallele sullo stesso file
1354
  _vfs_lock = self._get_vfs_lock(_wf_path)
1355
  async with _vfs_lock:
@@ -3576,12 +3557,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
3576
  }
3577
 
3578
  state.errors.append(error_text)
3579
- if getattr(self, "_write_snapshots", None):
3580
- try:
3581
- await self._rollback_writes(on_step)
3582
- except Exception as rollback_error:
3583
- state.errors.append(str(rollback_error)[:500])
3584
- _logger.error("[unified_loop] rollback inatteso incompleto: %s", rollback_error)
3585
  previous = state.state_machine.current
3586
  if previous != AgentState.FAILED:
3587
  try:
@@ -3739,12 +3714,6 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
3739
 
3740
  async def _finish(result: dict[str, Any]) -> dict[str, Any]:
3741
  next_state = AgentState.COMPLETED if result.get("success", True) else AgentState.FAILED
3742
- if next_state == AgentState.FAILED and getattr(self, "_write_snapshots", None):
3743
- try:
3744
- await self._rollback_writes(on_step)
3745
- except Exception as rollback_error:
3746
- result.setdefault("errors", []).append(str(rollback_error)[:500])
3747
- result["rollback_incomplete"] = True
3748
  try:
3749
  await self._transition_state(state, next_state, on_step)
3750
  finally:
 
214
  _logger.debug("[unified_loop] state callback silenced: %s", _state_callback_error)
215
 
216
  async def _rollback_writes(self, on_step=None) -> None:
217
+ """
218
+ GAP-3: ripristina i file sovrascritti se il loop si interrompe a metà.
219
+ Chiama dopo un errore grave che ha lasciato il progetto in stato inconsistente.
220
+ Ogni file in _write_snapshots viene ripristinato al suo contenuto originale.
221
+ File che non esistevano (snapshot=None) vengono ignorati (non possiamo eliminarli in modo sicuro).
222
  """
223
  if not self._write_snapshots or not self.executor:
224
  return
225
  if on_step:
226
  await _maybe_await(on_step({
227
  "action": "text_chunk",
228
+ "token": f"\u23ea Rollback di {len(self._write_snapshots)} file modificati...\n",
229
  "status": "streaming",
230
  }))
231
+ _rolled = 0
232
+ for path, original in self._write_snapshots.items():
233
+ if original is None:
234
+ continue # file non esisteva prima — saltiamo (non eliminiamo)
 
 
235
  try:
236
+ await asyncio.wait_for(
237
+ self.executor.run_tool("write_file", {"path": path, "content": original}),
238
  timeout=10.0,
239
  )
240
+ _rolled += 1
241
+ except Exception:
242
+ pass # non-fatal — best effort rollback
243
+ _total = len(self._write_snapshots) # salva prima del clear
244
+ self._write_snapshots = {}
245
+ _logger.info("GAP-3 rollback: %d/%d file ripristinati", _rolled, _total)
 
 
 
 
 
 
 
 
 
246
 
247
+ # ── GAP-NEW-4: Git VFS auto-snapshot ────────────────────────────────────────
248
  async def _vfs_git_backup(self) -> None:
249
  """GAP-NEW-4: Push _session_files al branch vfs-backup su GitHub.
250
 
 
1319
  {"path": _wf_path, "content": _wf_generated} if rn == "write_file"
1320
  else {"path": _wf_path, "patch": _wf_generated}
1321
  )
1322
+ # GAP-3: snapshot pre-write — cattura originale per rollback atomico
1323
  if rn == "write_file" and _wf_path not in self._write_snapshots:
1324
+ try:
1325
+ _snap_r = await asyncio.wait_for(
1326
+ self.executor.run_tool("read_file", {"path": _wf_path}),
1327
+ timeout=4.0,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1328
  )
1329
+ self._write_snapshots[_wf_path] = (
1330
+ _snap_r.get("output") if _snap_r.get("success") else None
1331
+ )
1332
+ except Exception:
1333
+ self._write_snapshots[_wf_path] = None # file non esisteva
1334
  # GAP-VFS: lock per-path — serializza scritture parallele sullo stesso file
1335
  _vfs_lock = self._get_vfs_lock(_wf_path)
1336
  async with _vfs_lock:
 
3557
  }
3558
 
3559
  state.errors.append(error_text)
 
 
 
 
 
 
3560
  previous = state.state_machine.current
3561
  if previous != AgentState.FAILED:
3562
  try:
 
3714
 
3715
  async def _finish(result: dict[str, Any]) -> dict[str, Any]:
3716
  next_state = AgentState.COMPLETED if result.get("success", True) else AgentState.FAILED
 
 
 
 
 
 
3717
  try:
3718
  await self._transition_state(state, next_state, on_step)
3719
  finally:
api/background_tasks.py DELETED
@@ -1,53 +0,0 @@
1
- """Supervision utilities for long-lived background asyncio tasks."""
2
- from __future__ import annotations
3
-
4
- import asyncio
5
- import logging
6
- from collections.abc import Awaitable
7
- from typing import Any
8
-
9
- _logger = logging.getLogger("agente_ai.background_tasks")
10
- _tasks: dict[str, asyncio.Task[Any]] = {}
11
-
12
-
13
- def spawn_background_task(coro: Awaitable[Any], *, name: str) -> asyncio.Task[Any]:
14
- """Start one named background task and retain it for lifecycle shutdown.
15
-
16
- A live task with the same name is reused. The passed coroutine is closed in
17
- that case so duplicate startup calls do not leak an un-awaited coroutine.
18
- """
19
- current = _tasks.get(name)
20
- if current is not None and not current.done():
21
- close = getattr(coro, "close", None)
22
- if close is not None:
23
- close()
24
- return current
25
-
26
- task = asyncio.create_task(coro, name=name)
27
- _tasks[name] = task
28
-
29
- def _report(task_result: asyncio.Task[Any]) -> None:
30
- if task_result.cancelled():
31
- return
32
- try:
33
- error = task_result.exception()
34
- except asyncio.CancelledError:
35
- return
36
- if error is not None:
37
- _logger.error("background task %s failed: %s", name, error, exc_info=error)
38
-
39
- task.add_done_callback(_report)
40
- return task
41
-
42
-
43
- async def shutdown_background_tasks() -> None:
44
- """Cancel and await all supervised background tasks."""
45
- tasks = [task for task in _tasks.values() if not task.done()]
46
- for task in tasks:
47
- task.cancel()
48
- if tasks:
49
- await asyncio.gather(*tasks, return_exceptions=True)
50
- _tasks.clear()
51
-
52
-
53
- __all__ = ["spawn_background_task", "shutdown_background_tasks"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
api/event_store.py CHANGED
@@ -4,7 +4,7 @@ backend/api/event_store.py — Event Store (persistenza, Fase 1 ADR-S26-S30)
4
  Responsabilità: SALVARE tutti gli eventi per replayability, debugging, benchmark.
5
  NON instrada — per pub/sub usa event_bus.py.
6
 
7
- Schema Supabase (tabella `event_store`, creata dalla migration versionata):
8
  id UUID PK default gen_random_uuid()
9
  topic TEXT NOT NULL
10
  payload JSONB NOT NULL default '{}'
@@ -40,24 +40,25 @@ router = APIRouter(
40
 
41
  _TABLE = "event_store"
42
 
43
- # ── Schema probe (la creazione è gestita esclusivamente dalle migration) ────────
44
 
45
  _TABLE_CREATED = False
46
 
47
  async def _ensure_table() -> bool:
48
- """Verifica che la tabella event_store creata dalla migration sia raggiungibile."""
49
  global _TABLE_CREATED
50
  if _TABLE_CREATED:
51
  return True
52
  if not _sb:
53
  return False
54
  try:
55
- # La tabella deve esistere: il client runtime non esegue DDL.
56
  res = _sb.table(_TABLE).select("id").limit(1).execute()
57
  _TABLE_CREATED = True
58
  return True
59
  except Exception as exc:
60
- _logger.warning("[event_store] tabella '%s' non raggiungibile: %s", _TABLE, exc)
 
61
  return False
62
 
63
 
@@ -90,8 +91,9 @@ async def store_event(req: StoreEventRequest) -> StoredEvent:
90
  Chiamato automaticamente dall'event_bus (via hook) o esplicitamente
91
  dai componenti che vogliono garantire persistenza.
92
  """
93
- if not await _ensure_table():
94
- raise HTTPException(503, detail="Event Store non disponibile (schema Supabase assente o non raggiungibile)")
 
95
 
96
  record = {
97
  "topic": req.topic,
@@ -130,8 +132,9 @@ async def replay_events(
130
  Recupera eventi filtrati dall'Event Store. Supporta replay per debugging,
131
  test di regressione e audit trail.
132
  """
133
- if not await _ensure_table():
134
- raise HTTPException(503, detail="Event Store non disponibile (schema Supabase assente o non raggiungibile)")
 
135
 
136
  try:
137
  q = _sb.table(_TABLE).select("*").order("created_at", desc=True).limit(limit)
@@ -157,35 +160,19 @@ async def replay_events(
157
  raise HTTPException(500, detail=f"Event Store query error: {exc}")
158
 
159
 
160
- @router.get("/store/status", summary="Diagnostica Event Store")
161
- async def store_status():
162
- """Verifica connettività dello store e restituisce statistiche."""
163
- if not await _ensure_table():
164
- return {"status": "unavailable", "reason": "schema event_store assente o non raggiungibile"}
165
- try:
166
- res = _sb.table(_TABLE).select("topic", count="exact").execute()
167
- total = res.count if hasattr(res, "count") and res.count else len(res.data or [])
168
- return {
169
- "status": "ok",
170
- "component": "event_store",
171
- "total_events": total,
172
- "table": _TABLE,
173
- }
174
- except Exception as exc:
175
- return {"status": "error", "detail": str(exc)}
176
- @router.get("/store/{event_id:uuid}", summary="Recupera evento singolo")
177
- async def get_event(event_id: uuid.UUID) -> StoredEvent:
178
  """Recupera un evento specifico per ID."""
179
- if not await _ensure_table():
180
- raise HTTPException(503, detail="Event Store non disponibile (schema Supabase assente o non raggiungibile)")
 
181
  try:
182
- event_id_str = str(event_id)
183
- res = _sb.table(_TABLE).select("*").eq("id", event_id_str).limit(1).execute()
184
  if not res.data:
185
  raise HTTPException(404, detail=f"Evento {event_id} non trovato")
186
  row = res.data[0]
187
  return StoredEvent(**{
188
- "id": row.get("id", str(event_id)),
189
  "topic": row.get("topic", ""),
190
  "payload": row.get("payload", {}),
191
  "correlation_id": row.get("correlation_id"),
@@ -199,3 +186,19 @@ async def get_event(event_id: uuid.UUID) -> StoredEvent:
199
  raise HTTPException(500, detail=f"Event Store get error: {exc}")
200
 
201
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4
  Responsabilità: SALVARE tutti gli eventi per replayability, debugging, benchmark.
5
  NON instrada — per pub/sub usa event_bus.py.
6
 
7
+ Schema Supabase (tabella `event_store`, auto-created se non esiste):
8
  id UUID PK default gen_random_uuid()
9
  topic TEXT NOT NULL
10
  payload JSONB NOT NULL default '{}'
 
40
 
41
  _TABLE = "event_store"
42
 
43
+ # ── Auto-create table (best-effort, richiede service role key) ─────────────────
44
 
45
  _TABLE_CREATED = False
46
 
47
  async def _ensure_table() -> bool:
48
+ """Crea la tabella event_store su Supabase se non esiste. Best-effort."""
49
  global _TABLE_CREATED
50
  if _TABLE_CREATED:
51
  return True
52
  if not _sb:
53
  return False
54
  try:
55
+ # Prova una SELECT se la tabella non esiste, Supabase ritorna un errore
56
  res = _sb.table(_TABLE).select("id").limit(1).execute()
57
  _TABLE_CREATED = True
58
  return True
59
  except Exception as exc:
60
+ _logger.warning("[event_store] tabella '%s' non raggiungibile: %s "
61
+ "crea manualmente con migration Supabase", _TABLE, exc)
62
  return False
63
 
64
 
 
91
  Chiamato automaticamente dall'event_bus (via hook) o esplicitamente
92
  dai componenti che vogliono garantire persistenza.
93
  """
94
+ await _ensure_table()
95
+ if not _sb:
96
+ raise HTTPException(503, detail="Event Store non disponibile (Supabase non configurato)")
97
 
98
  record = {
99
  "topic": req.topic,
 
132
  Recupera eventi filtrati dall'Event Store. Supporta replay per debugging,
133
  test di regressione e audit trail.
134
  """
135
+ await _ensure_table()
136
+ if not _sb:
137
+ raise HTTPException(503, detail="Event Store non disponibile")
138
 
139
  try:
140
  q = _sb.table(_TABLE).select("*").order("created_at", desc=True).limit(limit)
 
160
  raise HTTPException(500, detail=f"Event Store query error: {exc}")
161
 
162
 
163
+ @router.get("/store/{event_id}", summary="Recupera evento singolo")
164
+ async def get_event(event_id: str) -> StoredEvent:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  """Recupera un evento specifico per ID."""
166
+ await _ensure_table()
167
+ if not _sb:
168
+ raise HTTPException(503, detail="Event Store non disponibile")
169
  try:
170
+ res = _sb.table(_TABLE).select("*").eq("id", event_id).limit(1).execute()
 
171
  if not res.data:
172
  raise HTTPException(404, detail=f"Evento {event_id} non trovato")
173
  row = res.data[0]
174
  return StoredEvent(**{
175
+ "id": row.get("id", event_id),
176
  "topic": row.get("topic", ""),
177
  "payload": row.get("payload", {}),
178
  "correlation_id": row.get("correlation_id"),
 
186
  raise HTTPException(500, detail=f"Event Store get error: {exc}")
187
 
188
 
189
+ @router.get("/store/status", summary="Diagnostica Event Store")
190
+ async def store_status():
191
+ """Verifica connettività dello store e restituisce statistiche."""
192
+ if not _sb:
193
+ return {"status": "unavailable", "reason": "Supabase non configurato"}
194
+ try:
195
+ res = _sb.table(_TABLE).select("topic", count="exact").execute()
196
+ total = res.count if hasattr(res, "count") and res.count else len(res.data or [])
197
+ return {
198
+ "status": "ok",
199
+ "component": "event_store",
200
+ "total_events": total,
201
+ "table": _TABLE,
202
+ }
203
+ except Exception as exc:
204
+ return {"status": "error", "detail": str(exc)}
api/job_queue.py CHANGED
@@ -355,11 +355,11 @@ async def _hands_consumer_loop() -> None:
355
  continue
356
 
357
  # Esegui in background — non blocca il loop consumer
358
- from api.background_tasks import spawn_background_task
359
- task_id = str(job.get("taskId") or uuid.uuid4())
360
- spawn_background_task(
361
- _execute_queued_job(job), name=f"job-queue-execution:{task_id}"
362
- )
363
 
364
  except Exception as exc:
365
  _logger.debug("[jq] consumer tick error: %s", exc)
@@ -377,15 +377,18 @@ async def start_job_queue_consumer() -> None:
377
  return
378
 
379
  # Load publisher su tutti gli Space
380
- from api.background_tasks import spawn_background_task
381
- spawn_background_task(_load_publisher_loop(), name="job-queue-load-publisher")
 
 
 
382
 
383
  # Consumer per tutti i ruoli worker o legacy 'hands'
384
  _IS_WORKER = _SPACE_ROLE.startswith("worker-") or _SPACE_ROLE in ("hands", "unknown")
385
 
386
  if _IS_WORKER:
387
  _logger.info("[jq] Avvio consumer loop per ruolo worker: %s", _SPACE_ROLE)
388
- spawn_background_task(_hands_consumer_loop(), name="job-queue-hands-consumer")
389
  else:
390
  _logger.info("[jq] SPACE_ROLE=%s — consumer non avviato (ruolo non worker)", _SPACE_ROLE)
391
 
 
355
  continue
356
 
357
  # Esegui in background — non blocca il loop consumer
358
+ t = asyncio.create_task(_execute_queued_job(job))
359
+ t.add_done_callback(lambda task: (
360
+ _logger.error("[jq] job task crashed: %s", task.exception(), exc_info=task.exception())
361
+ if not task.cancelled() and task.exception() else None
362
+ ))
363
 
364
  except Exception as exc:
365
  _logger.debug("[jq] consumer tick error: %s", exc)
 
377
  return
378
 
379
  # Load publisher su tutti gli Space
380
+ def _log_jq_exc(t):
381
+ if not t.cancelled() and t.exception():
382
+ _logger.warning("[job_queue] bg loop raised: %s", t.exception())
383
+
384
+ asyncio.create_task(_load_publisher_loop()).add_done_callback(_log_jq_exc)
385
 
386
  # Consumer per tutti i ruoli worker o legacy 'hands'
387
  _IS_WORKER = _SPACE_ROLE.startswith("worker-") or _SPACE_ROLE in ("hands", "unknown")
388
 
389
  if _IS_WORKER:
390
  _logger.info("[jq] Avvio consumer loop per ruolo worker: %s", _SPACE_ROLE)
391
+ asyncio.create_task(_hands_consumer_loop()).add_done_callback(_log_jq_exc)
392
  else:
393
  _logger.info("[jq] SPACE_ROLE=%s — consumer non avviato (ruolo non worker)", _SPACE_ROLE)
394
 
api/providers.py CHANGED
@@ -250,13 +250,8 @@ async def ai_provider_readiness(role: AuthRole = Depends(require_role(AuthRole.M
250
 
251
 
252
  @router.get('/api/ai/health')
253
- async def ai_provider_health(role: AuthRole = Depends(require_role(AuthRole.OPERATOR))):
254
- """Restituisce diagnostica dettagliata dei provider ai soli operatori autorizzati.
255
-
256
- Il payload contiene profili, modelli, classi di errore e dati di quota upstream;
257
- non deve quindi essere reso disponibile al browser tramite il token interno del
258
- proxy. Il controllo pubblico di disponibilità resta `/api/health`.
259
- """
260
  now = time.monotonic()
261
  if _ai_health_cache["data"] and now - _ai_health_cache["at"] < _AI_HEALTH_TTL:
262
  return _ai_health_cache["data"]
 
250
 
251
 
252
  @router.get('/api/ai/health')
253
+ async def ai_provider_health(role: AuthRole = Depends(require_role(AuthRole.MACHINE))): # GAP-1-fix
254
+ """Testa tutti i provider AI in parallelo — risultati cachati 60s."""
 
 
 
 
 
255
  now = time.monotonic()
256
  if _ai_health_cache["data"] and now - _ai_health_cache["at"] < _AI_HEALTH_TTL:
257
  return _ai_health_cache["data"]
api/public_snapshot.py CHANGED
@@ -55,14 +55,9 @@ from .version import RUNTIME_VERSION
55
 
56
  _logger = logging.getLogger("agente_ai.public_snapshot")
57
 
58
- _SNAPSHOT_ATTEMPTS = 3
59
- _SNAPSHOT_ATTEMPT_TIMEOUT_SECONDS = 4.0
60
- _SNAPSHOT_RETRY_DELAY_SECONDS = 1.0
61
- _SNAPSHOT_TOTAL_BUDGET_SECONDS = (
62
- _SNAPSHOT_ATTEMPTS * _SNAPSHOT_ATTEMPT_TIMEOUT_SECONDS
63
- + (_SNAPSHOT_ATTEMPTS - 1) * _SNAPSHOT_RETRY_DELAY_SECONDS
64
- )
65
  _ACTIVE_STATUSES = {"RUNNING", "IN_PROGRESS", "EXECUTING", "PROCESSING"}
 
 
66
  def _snapshot_row() -> dict[str, Any]:
67
  tasks = list(_agent_tasks.values())
68
  return {
@@ -93,12 +88,9 @@ async def write_public_dashboard_snapshot() -> bool:
93
  client.table("public_dashboard_snapshot").upsert(row, on_conflict="singleton").execute()
94
 
95
  last_error: Exception | None = None
96
- for attempt in range(1, _SNAPSHOT_ATTEMPTS + 1):
97
  try:
98
- await asyncio.wait_for(
99
- asyncio.to_thread(operation),
100
- timeout=_SNAPSHOT_ATTEMPT_TIMEOUT_SECONDS,
101
- )
102
  _logger.info(
103
  "BOOT: public snapshot upserted status=%s sessions=%d queued=%d in_progress=%d version=%s",
104
  row["service_status"], row["active_sessions"], row["queued_tasks"],
@@ -107,8 +99,8 @@ async def write_public_dashboard_snapshot() -> bool:
107
  return True
108
  except Exception as exc:
109
  last_error = exc
110
- if attempt < _SNAPSHOT_ATTEMPTS:
111
- await asyncio.sleep(_SNAPSHOT_RETRY_DELAY_SECONDS)
112
 
113
  error_code = getattr(last_error, "code", None) or getattr(last_error, "status_code", None)
114
  _logger.warning(
 
55
 
56
  _logger = logging.getLogger("agente_ai.public_snapshot")
57
 
 
 
 
 
 
 
 
58
  _ACTIVE_STATUSES = {"RUNNING", "IN_PROGRESS", "EXECUTING", "PROCESSING"}
59
+
60
+
61
  def _snapshot_row() -> dict[str, Any]:
62
  tasks = list(_agent_tasks.values())
63
  return {
 
88
  client.table("public_dashboard_snapshot").upsert(row, on_conflict="singleton").execute()
89
 
90
  last_error: Exception | None = None
91
+ for attempt in range(1, 4):
92
  try:
93
+ await asyncio.to_thread(operation)
 
 
 
94
  _logger.info(
95
  "BOOT: public snapshot upserted status=%s sessions=%d queued=%d in_progress=%d version=%s",
96
  row["service_status"], row["active_sessions"], row["queued_tasks"],
 
99
  return True
100
  except Exception as exc:
101
  last_error = exc
102
+ if attempt < 3:
103
+ await asyncio.sleep(2)
104
 
105
  error_code = getattr(last_error, "code", None) or getattr(last_error, "status_code", None)
106
  _logger.warning(
api/speculative.py CHANGED
@@ -117,8 +117,8 @@ def _prune_cache() -> None:
117
 
118
  # S388: Groq client singleton per speculative — evita new OpenAI() per ogni task.
119
  _spec_groq_client: Any = None
120
- _SPEC_GROQ_MAX_CONCURRENCY = 4
121
- _spec_groq_semaphore = asyncio.Semaphore(_SPEC_GROQ_MAX_CONCURRENCY)
122
  def _get_spec_groq_client() -> Any:
123
  global _spec_groq_client
124
  if _spec_groq_client is not None:
@@ -127,8 +127,8 @@ def _get_spec_groq_client() -> Any:
127
  if not groq_key:
128
  return None
129
  try:
130
- from openai import AsyncOpenAI
131
- _spec_groq_client = AsyncOpenAI(
132
  api_key=groq_key,
133
  base_url="https://api.groq.com/openai/v1",
134
  timeout=3.0,
@@ -137,6 +137,8 @@ def _get_spec_groq_client() -> Any:
137
  except Exception:
138
  _spec_groq_client = None
139
  return _spec_groq_client
 
 
140
  async def _extract_tools_fast(goal: str) -> list[dict]:
141
  """
142
  Usa Groq openai/gpt-oss-20b per estrarre tool calls in ~300ms.
@@ -149,16 +151,16 @@ async def _extract_tools_fast(goal: str) -> list[dict]:
149
  if not client:
150
  return []
151
  prompt = _EXTRACTION_PROMPT.format(message=goal[:500])
152
- async with _spec_groq_semaphore:
153
- resp = await asyncio.wait_for(
154
- client.chat.completions.create(
155
- model="openai/gpt-oss-20b",
156
- messages=[{"role": "user", "content": prompt}],
157
- temperature=0.0,
158
- max_tokens=400, # S587: 256→400 — JSON array da goal[:500] supera 256 tok
159
- ),
160
- timeout=3.0,
161
- )
162
  raw = (resp.choices[0].message.content or "").strip()
163
  # Estrai JSON array anche se ci sono prefissi di testo
164
  start = raw.find("[")
 
117
 
118
  # S388: Groq client singleton per speculative — evita new OpenAI() per ogni task.
119
  _spec_groq_client: Any = None
120
+
121
+
122
  def _get_spec_groq_client() -> Any:
123
  global _spec_groq_client
124
  if _spec_groq_client is not None:
 
127
  if not groq_key:
128
  return None
129
  try:
130
+ from openai import OpenAI
131
+ _spec_groq_client = OpenAI(
132
  api_key=groq_key,
133
  base_url="https://api.groq.com/openai/v1",
134
  timeout=3.0,
 
137
  except Exception:
138
  _spec_groq_client = None
139
  return _spec_groq_client
140
+
141
+
142
  async def _extract_tools_fast(goal: str) -> list[dict]:
143
  """
144
  Usa Groq openai/gpt-oss-20b per estrarre tool calls in ~300ms.
 
151
  if not client:
152
  return []
153
  prompt = _EXTRACTION_PROMPT.format(message=goal[:500])
154
+ resp = await asyncio.wait_for(
155
+ asyncio.to_thread(
156
+ client.chat.completions.create,
157
+ model="openai/gpt-oss-20b",
158
+ messages=[{"role": "user", "content": prompt}],
159
+ temperature=0.0,
160
+ max_tokens=400, # S587: 256→400 — JSON array da goal[:500] supera 256 tok
161
+ ),
162
+ timeout=3.0,
163
+ )
164
  raw = (resp.choices[0].message.content or "").strip()
165
  # Estrai JSON array anche se ci sono prefissi di testo
166
  start = raw.find("[")
api/state.py CHANGED
@@ -139,11 +139,6 @@ _CHECKPOINT_TTL_MS = 2 * 60 * 60 * 1000
139
  _CHECKPOINT_MAX = 100
140
  _AGENT_TASK_TTL_MS = 2 * 60 * 60 * 1000
141
  _AGENT_TASK_MAX = 200
142
- # All statuses that represent a finished task and can therefore expire from the
143
- # bounded in-memory store. Keep this set aligned with API lifecycle writers.
144
- _AGENT_TASK_TERMINAL_STATES: frozenset[str] = frozenset({
145
- 'SUCCESS', 'COMPLETED', 'ERROR', 'CANCELLED', 'RATE_LIMITED',
146
- })
147
 
148
  # ── Telemetry & Health ────────────────────────────────────────────────────────
149
  _ai_health_cache: dict = {"data": None, "at": 0.0}
@@ -249,7 +244,7 @@ def _prune_checkpoints() -> None:
249
  def _prune_agent_tasks() -> None:
250
  now = int(time.time() * 1000)
251
  expired = [k for k, v in list(_agent_tasks.items())
252
- if v.get('status') in _AGENT_TASK_TERMINAL_STATES
253
  and now - v.get('created_at', 0) > _AGENT_TASK_TTL_MS]
254
  for k in expired:
255
  _agent_tasks.pop(k, None)
 
139
  _CHECKPOINT_MAX = 100
140
  _AGENT_TASK_TTL_MS = 2 * 60 * 60 * 1000
141
  _AGENT_TASK_MAX = 200
 
 
 
 
 
142
 
143
  # ── Telemetry & Health ────────────────────────────────────────────────────────
144
  _ai_health_cache: dict = {"data": None, "at": 0.0}
 
244
  def _prune_agent_tasks() -> None:
245
  now = int(time.time() * 1000)
246
  expired = [k for k, v in list(_agent_tasks.items())
247
+ if v.get('status') in ('SUCCESS', 'ERROR', 'CANCELLED')
248
  and now - v.get('created_at', 0) > _AGENT_TASK_TTL_MS]
249
  for k in expired:
250
  _agent_tasks.pop(k, None)
benchmark-extended.mjs ADDED
The diff for this file is too large to render. See raw diff
 
cors_policy.py DELETED
@@ -1,61 +0,0 @@
1
- """CORS policy shared by the FastAPI entrypoint.
2
-
3
- The backend authenticates browser calls with explicit bearer/internal headers,
4
- not with ambient browser cookies. Credentials therefore remain disabled unless
5
- an explicit future policy requires them.
6
- """
7
- from __future__ import annotations
8
-
9
- import os
10
- from urllib.parse import urlsplit
11
-
12
- PUBLIC_FRONTEND_ORIGIN = "https://agente-ai.pages.dev"
13
- _DEVELOPMENT_ORIGINS = frozenset(
14
- {
15
- "http://localhost:3000",
16
- "http://localhost:5173",
17
- "http://127.0.0.1:3000",
18
- "http://127.0.0.1:5173",
19
- }
20
- )
21
- _DEVELOPMENT_ENVS = frozenset({"dev", "development", "local", "test"})
22
-
23
-
24
- def _is_valid_origin(origin: str) -> bool:
25
- """Return True only for an exact HTTP(S) origin without path or wildcard."""
26
- if not origin or origin == "*" or any(char.isspace() for char in origin):
27
- return False
28
- parsed = urlsplit(origin)
29
- return bool(
30
- parsed.scheme in {"http", "https"}
31
- and parsed.netloc
32
- and not parsed.username
33
- and not parsed.password
34
- and not parsed.path
35
- and not parsed.query
36
- and not parsed.fragment
37
- )
38
-
39
-
40
- def allowed_origins(raw: str | None = None, environment: str | None = None) -> list[str]:
41
- """Build the exact CORS allow-list from environment and safe defaults.
42
-
43
- ``CORS_ALLOWED_ORIGINS`` is a comma-separated list of exact origins. An
44
- invalid value, including ``*``, is ignored rather than widening access.
45
- Local development origins are enabled only for an explicit development or
46
- test environment. The production default is the canonical Pages origin.
47
- """
48
- configured = raw if raw is not None else os.getenv("CORS_ALLOWED_ORIGINS", "")
49
- origins = [
50
- origin.strip().rstrip("/")
51
- for origin in configured.split(",")
52
- if origin.strip()
53
- ]
54
- valid = {origin for origin in origins if _is_valid_origin(origin)}
55
-
56
- env_name = (environment if environment is not None else os.getenv("APP_ENV", os.getenv("ENVIRONMENT", ""))).strip().lower()
57
- if not valid:
58
- valid.add(PUBLIC_FRONTEND_ORIGIN)
59
- if env_name in _DEVELOPMENT_ENVS:
60
- valid.update(_DEVELOPMENT_ORIGINS)
61
- return sorted(valid)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
main.py CHANGED
@@ -10,7 +10,6 @@ from fastapi import FastAPI
10
  from fastapi.middleware.cors import CORSMiddleware
11
  from fastapi.staticfiles import StaticFiles
12
  from api.version import RUNTIME_VERSION
13
- from cors_policy import allowed_origins
14
 
15
  # Configurazione Logging
16
  logging.basicConfig(
@@ -26,14 +25,13 @@ app = FastAPI(
26
  version=RUNTIME_VERSION,
27
  )
28
 
29
- # CORS: whitelist esatta e fail-closed. Il frontend usa bearer/internal headers,
30
- # non cookie cross-origin; le credenziali browser restano quindi disabilitate.
31
  app.add_middleware(
32
  CORSMiddleware,
33
- allow_origins=allowed_origins(),
34
- allow_credentials=False,
35
- allow_methods=["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
36
- allow_headers=["Authorization", "Content-Type", "X-Internal-Token", "X-Requested-With"],
37
  )
38
 
39
  # ── P17-F1: RLS Fix & Auto-Migration ──────────────────────────────────────────
@@ -246,20 +244,6 @@ async def run_cli_task(task_description: str):
246
  sys.exit(1)
247
 
248
  # ── Startup ───────────────────────────────────────────────────────────────────
249
- async def _snapshot_heartbeat() -> None:
250
- """Keep the public dashboard snapshot fresh without affecting request handling."""
251
- from api.public_snapshot import refresh_public_dashboard_snapshot
252
-
253
- while True:
254
- await asyncio.sleep(30)
255
- try:
256
- await asyncio.wait_for(refresh_public_dashboard_snapshot(), timeout=15.0)
257
- except asyncio.CancelledError:
258
- raise
259
- except Exception as exc:
260
- _logger.debug("Public snapshot heartbeat failed (non-blocking): %s", exc)
261
-
262
-
263
  @app.on_event("startup")
264
  async def startup_event():
265
  _logger.info("Server starting up...")
@@ -269,26 +253,16 @@ async def startup_event():
269
  _logger.info("✅ BOOT: apply_rls_fix_sync() eseguito con successo.")
270
  except Exception as e:
271
  _logger.warning(f"⚠️ BOOT: apply_rls_fix_sync() fallito (non bloccante): {e}")
272
- # Schema e RLS sono gestiti dalle migrazioni versionate Supabase.
273
- # Non avviare auto-migrazioni in background: il boot deve restare
274
- # deterministico e non può dichiarare successo prima della migrazione.
275
  try:
276
  from api.public_snapshot import write_public_dashboard_snapshot
277
- snapshot_ok = await asyncio.wait_for(write_public_dashboard_snapshot(), timeout=15.0)
278
  if snapshot_ok:
279
  _logger.info("✅ BOOT: public dashboard snapshot writer completato.")
280
  else:
281
  _logger.warning("⚠️ BOOT: public dashboard snapshot writer non ha persistito lo snapshot.")
282
  except Exception as e:
283
  _logger.warning(f"⚠️ BOOT: avvio public snapshot writer fallito (non bloccante): {e}")
284
- try:
285
- from api.background_tasks import spawn_background_task
286
- from api.telemetry import telemetry_alert_loop
287
- spawn_background_task(telemetry_alert_loop(), name="telemetry-alert-loop")
288
- spawn_background_task(_snapshot_heartbeat(), name="public-snapshot-heartbeat")
289
- _logger.info("✅ BOOT: telemetry e snapshot loops supervisionati.")
290
- except Exception as e:
291
- _logger.warning(f"⚠️ BOOT: avvio telemetry/snapshot loops fallito (non bloccante): {e}")
292
  try:
293
  from api.providers import start_heartbeat
294
  start_heartbeat()
@@ -297,24 +271,9 @@ async def startup_event():
297
  _logger.warning(f"⚠️ BOOT: avvio provider heartbeat fallito (non bloccante): {e}")
298
  if not any(arg in sys.argv for arg in ["--task", "-t"]):
299
  try:
300
- from api.background_tasks import spawn_background_task
301
  from api.job_queue import start_job_queue_consumer
302
- spawn_background_task(
303
- start_job_queue_consumer(), name="job-queue-consumer-supervisor"
304
- )
305
- except Exception as e:
306
- _logger.warning("⚠️ BOOT: avvio job queue fallito (non bloccante): %s", e)
307
-
308
-
309
- @app.on_event("shutdown")
310
- async def shutdown_event():
311
- """Cancel and await all registered background tasks before loop shutdown."""
312
- try:
313
- from api.background_tasks import shutdown_background_tasks
314
- await shutdown_background_tasks()
315
- _logger.info("✅ SHUTDOWN: background tasks supervisionate arrestate.")
316
- except Exception as e:
317
- _logger.warning("⚠️ SHUTDOWN: arresto background tasks incompleto: %s", e)
318
 
319
  # ── SPA Hosting ────────────────────────────────────────────────────��──────────
320
  _STATIC_DIR = os.getenv('FRONTEND_DIST', '/app/backend/static')
 
10
  from fastapi.middleware.cors import CORSMiddleware
11
  from fastapi.staticfiles import StaticFiles
12
  from api.version import RUNTIME_VERSION
 
13
 
14
  # Configurazione Logging
15
  logging.basicConfig(
 
25
  version=RUNTIME_VERSION,
26
  )
27
 
28
+ # CORS
 
29
  app.add_middleware(
30
  CORSMiddleware,
31
+ allow_origins=["*"],
32
+ allow_credentials=True,
33
+ allow_methods=["*"],
34
+ allow_headers=["*"],
35
  )
36
 
37
  # ── P17-F1: RLS Fix & Auto-Migration ──────────────────────────────────────────
 
244
  sys.exit(1)
245
 
246
  # ── Startup ───────────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  @app.on_event("startup")
248
  async def startup_event():
249
  _logger.info("Server starting up...")
 
253
  _logger.info("✅ BOOT: apply_rls_fix_sync() eseguito con successo.")
254
  except Exception as e:
255
  _logger.warning(f"⚠️ BOOT: apply_rls_fix_sync() fallito (non bloccante): {e}")
256
+ asyncio.create_task(_run_auto_migration())
 
 
257
  try:
258
  from api.public_snapshot import write_public_dashboard_snapshot
259
+ snapshot_ok = await asyncio.wait_for(write_public_dashboard_snapshot(), timeout=12.0)
260
  if snapshot_ok:
261
  _logger.info("✅ BOOT: public dashboard snapshot writer completato.")
262
  else:
263
  _logger.warning("⚠️ BOOT: public dashboard snapshot writer non ha persistito lo snapshot.")
264
  except Exception as e:
265
  _logger.warning(f"⚠️ BOOT: avvio public snapshot writer fallito (non bloccante): {e}")
 
 
 
 
 
 
 
 
266
  try:
267
  from api.providers import start_heartbeat
268
  start_heartbeat()
 
271
  _logger.warning(f"⚠️ BOOT: avvio provider heartbeat fallito (non bloccante): {e}")
272
  if not any(arg in sys.argv for arg in ["--task", "-t"]):
273
  try:
 
274
  from api.job_queue import start_job_queue_consumer
275
+ asyncio.create_task(start_job_queue_consumer())
276
+ except Exception: pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
 
278
  # ── SPA Hosting ────────────────────────────────────────────────────��──────────
279
  _STATIC_DIR = os.getenv('FRONTEND_DIST', '/app/backend/static')
scripts/lib/report-engine.mjs ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { mkdirSync, renameSync, writeFileSync } from "node:fs";
2
+ import { join, resolve } from "node:path";
3
+
4
+ const DEFAULT_REPORT_DIRECTORY = "/tmp/agente-ai";
5
+
6
+ function safeSegment(value) {
7
+ return String(value ?? "benchmark")
8
+ .trim()
9
+ .replace(/[^a-zA-Z0-9._-]+/g, "-")
10
+ .replace(/^-+|-+$/g, "")
11
+ .slice(0, 96) || "benchmark";
12
+ }
13
+
14
+ /**
15
+ * Persiste un artefatto diagnostico del runner senza incidere sui calcoli.
16
+ * Il write-then-rename evita file JSON parziali in caso di interruzione.
17
+ */
18
+ export function saveBenchmarkReport(reportName, payload) {
19
+ const reportDirectory = resolve(process.env.BENCHMARK_REPORT_DIR || DEFAULT_REPORT_DIRECTORY);
20
+ mkdirSync(reportDirectory, { recursive: true });
21
+
22
+ const safeName = safeSegment(reportName);
23
+ const stamp = new Date().toISOString().replace(/[:.]/g, "-");
24
+ const finalPath = join(reportDirectory, `${safeName}-${stamp}.json`);
25
+ const temporaryPath = `${finalPath}.${process.pid}.tmp`;
26
+ const document = {
27
+ schemaVersion: "benchmark-report-v1",
28
+ generatedAt: new Date().toISOString(),
29
+ reportName: safeName,
30
+ ...payload,
31
+ };
32
+
33
+ writeFileSync(temporaryPath, `${JSON.stringify(document, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
34
+ renameSync(temporaryPath, finalPath);
35
+ return finalPath;
36
+ }
tests/test_agent_task_pruning.py DELETED
@@ -1,70 +0,0 @@
1
- import unittest
2
- from unittest.mock import patch
3
-
4
- import api.state as state
5
-
6
-
7
- _TERMINAL_STATES = (
8
- "SUCCESS",
9
- "COMPLETED",
10
- "ERROR",
11
- "CANCELLED",
12
- "RATE_LIMITED",
13
- )
14
-
15
-
16
- class AgentTaskPruningTests(unittest.TestCase):
17
- def test_prune_expires_every_terminal_state_and_cleans_byok(self):
18
- now_ms = 10_000_000
19
- old_ms = now_ms - state._AGENT_TASK_TTL_MS - 1
20
- task_ids = {f"old-{status.lower()}" for status in _TERMINAL_STATES}
21
- task_ids.update({"running", "queued"})
22
- tasks = {
23
- task_id: {
24
- "status": (
25
- task_id.removeprefix("old-").upper()
26
- if task_id.startswith("old-")
27
- else task_id.upper()
28
- ),
29
- "created_at": old_ms,
30
- }
31
- for task_id in task_ids
32
- }
33
- byok_clients = {task_id: object() for task_id in task_ids}
34
-
35
- with patch.object(state, "_agent_tasks", tasks), patch.object(
36
- state, "_task_ai_clients", byok_clients
37
- ), patch.object(state.time, "time", return_value=now_ms / 1000):
38
- state._prune_agent_tasks()
39
- self.assertEqual(set(state._agent_tasks), {"running", "queued"})
40
- self.assertEqual(set(state._task_ai_clients), {"running", "queued"})
41
-
42
- def test_prune_keeps_recent_terminal_tasks(self):
43
- now_ms = 10_000_000
44
- recent_ms = now_ms - state._AGENT_TASK_TTL_MS + 1
45
- tasks = {
46
- status.lower(): {"status": status, "created_at": recent_ms}
47
- for status in _TERMINAL_STATES
48
- }
49
-
50
- with patch.object(state, "_agent_tasks", tasks), patch.object(
51
- state, "_task_ai_clients", {}
52
- ), patch.object(state.time, "time", return_value=now_ms / 1000):
53
- state._prune_agent_tasks()
54
- self.assertEqual(
55
- set(state._agent_tasks),
56
- {status.lower() for status in _TERMINAL_STATES},
57
- )
58
-
59
- def test_terminal_state_set_matches_pruning_contract(self):
60
- self.assertEqual(
61
- state._AGENT_TASK_TERMINAL_STATES,
62
- frozenset(_TERMINAL_STATES),
63
- )
64
- self.assertNotIn("QUEUED", state._AGENT_TASK_TERMINAL_STATES)
65
- self.assertNotIn("RUNNING", state._AGENT_TASK_TERMINAL_STATES)
66
- self.assertNotIn("CREATING", state._AGENT_TASK_TERMINAL_STATES)
67
-
68
-
69
- if __name__ == "__main__":
70
- unittest.main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_ai_provider_health_access.py DELETED
@@ -1,83 +0,0 @@
1
- """Regressioni di accesso alla diagnostica dettagliata dei provider.
2
-
3
- Esegui con: python3 -m unittest backend.tests.test_ai_provider_health_access -v
4
- """
5
- from __future__ import annotations
6
-
7
- import os
8
- import sys
9
- import time
10
- import unittest
11
- from unittest.mock import patch
12
-
13
- from fastapi import FastAPI
14
- from fastapi.testclient import TestClient
15
-
16
- _BACKEND = os.path.join(os.path.dirname(__file__), "..")
17
- if _BACKEND not in sys.path:
18
- sys.path.insert(0, _BACKEND)
19
-
20
- from api import auth_guard, providers
21
-
22
-
23
- class TestAIProviderHealthAccess(unittest.TestCase):
24
- """Il payload con profili, quote e dettagli upstream è solo per OPERATOR."""
25
-
26
- @classmethod
27
- def setUpClass(cls) -> None:
28
- app = FastAPI()
29
- app.include_router(providers.router)
30
- cls.client = TestClient(app)
31
-
32
- def setUp(self) -> None:
33
- self.env_patch = patch.dict(
34
- os.environ,
35
- {
36
- "INTERNAL_TOKEN": "test-internal-token",
37
- "OPERATOR_TOKEN": "test-operator-token",
38
- },
39
- clear=False,
40
- )
41
- self.env_patch.start()
42
- self.previous_health_cache = dict(providers._ai_health_cache)
43
- auth_guard._rate_store.clear()
44
- auth_guard._rate_store_checks = 0
45
-
46
- def tearDown(self) -> None:
47
- self.env_patch.stop()
48
- providers._ai_health_cache.clear()
49
- providers._ai_health_cache.update(self.previous_health_cache)
50
- auth_guard._rate_store.clear()
51
- auth_guard._rate_store_checks = 0
52
-
53
- def test_anonymous_browser_cannot_request_detailed_provider_diagnostics(self) -> None:
54
- response = self.client.get("/api/ai/health")
55
-
56
- self.assertEqual(response.status_code, 403)
57
- self.assertEqual(response.json()["detail"]["required_role"], "OPERATOR")
58
-
59
- def test_proxy_machine_token_cannot_escalate_browser_to_provider_diagnostics(self) -> None:
60
- response = self.client.get(
61
- "/api/ai/health",
62
- headers={"X-Internal-Token": "test-internal-token"},
63
- )
64
-
65
- self.assertEqual(response.status_code, 403)
66
- self.assertEqual(response.json()["detail"]["your_role"], "MACHINE")
67
- self.assertEqual(response.json()["detail"]["required_role"], "OPERATOR")
68
-
69
- def test_operator_can_read_cached_diagnostics_without_triggering_a_probe(self) -> None:
70
- providers._ai_health_cache["data"] = {"providers": [], "tested_at": 1}
71
- providers._ai_health_cache["at"] = time.monotonic()
72
-
73
- response = self.client.get(
74
- "/api/ai/health",
75
- headers={"X-Operator-Token": "test-operator-token"},
76
- )
77
-
78
- self.assertEqual(response.status_code, 200)
79
- self.assertEqual(response.json(), {"providers": [], "tested_at": 1})
80
-
81
-
82
- if __name__ == "__main__":
83
- unittest.main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_background_tasks.py DELETED
@@ -1,57 +0,0 @@
1
- import asyncio
2
- import unittest
3
-
4
- from api import background_tasks
5
-
6
-
7
- class BackgroundTaskSupervisorTests(unittest.IsolatedAsyncioTestCase):
8
- async def asyncSetUp(self):
9
- await background_tasks.shutdown_background_tasks()
10
-
11
- async def asyncTearDown(self):
12
- await background_tasks.shutdown_background_tasks()
13
-
14
- async def test_duplicate_name_reuses_task_and_closes_duplicate_coroutine(self):
15
- started = asyncio.Event()
16
- release = asyncio.Event()
17
-
18
- async def worker():
19
- started.set()
20
- await release.wait()
21
-
22
- first = background_tasks.spawn_background_task(worker(), name="duplicate")
23
- await started.wait()
24
- duplicate = background_tasks.spawn_background_task(worker(), name="duplicate")
25
- self.assertIs(first, duplicate)
26
- release.set()
27
- await background_tasks.shutdown_background_tasks()
28
- self.assertTrue(first.done())
29
-
30
- async def test_shutdown_cancels_and_awaits_running_task(self):
31
- cancelled = asyncio.Event()
32
-
33
- async def worker():
34
- try:
35
- await asyncio.Event().wait()
36
- except asyncio.CancelledError:
37
- cancelled.set()
38
- raise
39
-
40
- background_tasks.spawn_background_task(worker(), name="cancellable")
41
- await asyncio.sleep(0)
42
- await background_tasks.shutdown_background_tasks()
43
- self.assertTrue(cancelled.is_set())
44
- self.assertFalse(background_tasks._tasks)
45
-
46
- async def test_completed_task_is_not_left_in_registry_after_shutdown(self):
47
- async def worker():
48
- return "ok"
49
-
50
- task = background_tasks.spawn_background_task(worker(), name="completed")
51
- await task
52
- await background_tasks.shutdown_background_tasks()
53
- self.assertFalse(background_tasks._tasks)
54
-
55
-
56
- if __name__ == "__main__":
57
- unittest.main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_cors_policy.py DELETED
@@ -1,65 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import unittest
4
-
5
- from fastapi import FastAPI
6
- from fastapi.middleware.cors import CORSMiddleware
7
- from fastapi.testclient import TestClient
8
-
9
- from cors_policy import PUBLIC_FRONTEND_ORIGIN, allowed_origins
10
-
11
-
12
- class CorsPolicyTests(unittest.TestCase):
13
- def test_production_defaults_to_canonical_frontend_only(self) -> None:
14
- self.assertEqual(allowed_origins(raw="", environment="production"), [PUBLIC_FRONTEND_ORIGIN])
15
-
16
- def test_custom_origins_are_exact_and_wildcards_are_ignored(self) -> None:
17
- origins = allowed_origins(
18
- raw=" https://agente-ai.pages.dev/ , * , https://evil.example/path , http://localhost:5173 ",
19
- environment="production",
20
- )
21
- self.assertEqual(origins, ["http://localhost:5173", PUBLIC_FRONTEND_ORIGIN])
22
-
23
- def test_local_origins_are_available_only_in_development(self) -> None:
24
- production = allowed_origins(raw="", environment="production")
25
- development = allowed_origins(raw="", environment="development")
26
- self.assertNotIn("http://localhost:5173", production)
27
- self.assertIn("http://localhost:5173", development)
28
-
29
- def test_invalid_configuration_fails_closed_to_public_default(self) -> None:
30
- self.assertEqual(allowed_origins(raw="*", environment="production"), [PUBLIC_FRONTEND_ORIGIN])
31
-
32
- def test_middleware_rejects_untrusted_origin_and_does_not_allow_credentials(self) -> None:
33
- app = FastAPI()
34
- app.add_middleware(
35
- CORSMiddleware,
36
- allow_origins=allowed_origins(raw=PUBLIC_FRONTEND_ORIGIN, environment="production"),
37
- allow_credentials=False,
38
- allow_methods=["GET", "OPTIONS"],
39
- allow_headers=["Authorization", "Content-Type"],
40
- )
41
-
42
- @app.get("/health")
43
- def health() -> dict[str, str]:
44
- return {"status": "ok"}
45
-
46
- client = TestClient(app)
47
- trusted = client.get("/health", headers={"Origin": PUBLIC_FRONTEND_ORIGIN})
48
- untrusted = client.get("/health", headers={"Origin": "https://audit-origin.invalid"})
49
- preflight = client.options(
50
- "/health",
51
- headers={
52
- "Origin": "https://audit-origin.invalid",
53
- "Access-Control-Request-Method": "GET",
54
- "Access-Control-Request-Headers": "authorization",
55
- },
56
- )
57
-
58
- self.assertEqual(trusted.headers.get("access-control-allow-origin"), PUBLIC_FRONTEND_ORIGIN)
59
- self.assertNotIn("access-control-allow-credentials", trusted.headers)
60
- self.assertNotIn("access-control-allow-origin", untrusted.headers)
61
- self.assertNotEqual(preflight.headers.get("access-control-allow-origin"), "https://audit-origin.invalid")
62
-
63
-
64
- if __name__ == "__main__":
65
- unittest.main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_executor_side_effect_retry.py DELETED
@@ -1,91 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import unittest
4
- from unittest.mock import patch
5
-
6
- from agents import executor as executor_module
7
- from agents.executor import Executor
8
-
9
-
10
- class FailingMemory:
11
- async def save_episode(self, *_args, **_kwargs) -> None:
12
- raise RuntimeError("memory unavailable")
13
-
14
-
15
- class RecordingMemory:
16
- def __init__(self) -> None:
17
- self.calls = 0
18
-
19
- async def save_episode(self, *_args, **_kwargs) -> None:
20
- self.calls += 1
21
-
22
-
23
- class ExecutorSideEffectRetryTests(unittest.IsolatedAsyncioTestCase):
24
- async def test_memory_failure_does_not_retry_completed_side_effect(self) -> None:
25
- calls = 0
26
-
27
- async def non_idempotent_tool(**_inputs):
28
- nonlocal calls
29
- calls += 1
30
- return {"created_id": "resource-1"}
31
-
32
- with patch.dict(
33
- executor_module.TOOL_REGISTRY,
34
- {"non_idempotent_tool": {"required_inputs": [], "fallbacks": [], "_fn": non_idempotent_tool}},
35
- clear=False,
36
- ):
37
- result = await Executor(llm_client=object(), memory=FailingMemory(), max_retries=2).run_tool(
38
- "non_idempotent_tool", {}, timeout=2, worker_hint="test",
39
- )
40
-
41
- self.assertTrue(result["success"])
42
- self.assertEqual(result["attempt"], 1)
43
- self.assertEqual(calls, 1)
44
- self.assertFalse(result["memory_persisted"])
45
- self.assertIn("memory unavailable", result["memory_error"])
46
-
47
- async def test_tool_failure_still_retries_before_any_side_effect_result(self) -> None:
48
- calls = 0
49
-
50
- async def flaky_tool(**_inputs):
51
- nonlocal calls
52
- calls += 1
53
- if calls == 1:
54
- raise RuntimeError("transient tool failure")
55
- return {"ok": True}
56
-
57
- with patch.dict(
58
- executor_module.TOOL_REGISTRY,
59
- {"flaky_tool": {"required_inputs": [], "fallbacks": [], "_fn": flaky_tool}},
60
- clear=False,
61
- ):
62
- result = await Executor(llm_client=object(), memory=None, max_retries=2).run_tool(
63
- "flaky_tool", {}, timeout=2, worker_hint="test",
64
- )
65
-
66
- self.assertTrue(result["success"])
67
- self.assertEqual(result["attempt"], 2)
68
- self.assertEqual(calls, 2)
69
-
70
- async def test_successful_memory_persistence_is_reported(self) -> None:
71
- memory = RecordingMemory()
72
-
73
- async def safe_tool(**_inputs):
74
- return "done"
75
-
76
- with patch.dict(
77
- executor_module.TOOL_REGISTRY,
78
- {"safe_tool": {"required_inputs": [], "fallbacks": [], "_fn": safe_tool}},
79
- clear=False,
80
- ):
81
- result = await Executor(llm_client=object(), memory=memory, max_retries=0).run_tool(
82
- "safe_tool", {}, timeout=2, worker_hint="test",
83
- )
84
-
85
- self.assertTrue(result["success"])
86
- self.assertTrue(result["memory_persisted"])
87
- self.assertEqual(memory.calls, 1)
88
-
89
-
90
- if __name__ == "__main__":
91
- unittest.main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_model_watch_adapter.py CHANGED
@@ -171,14 +171,14 @@ class GeminiModelsAdapterTests(unittest.IsolatedAsyncioTestCase):
171
  provider="gemini",
172
  profile=profile,
173
  base_url="https://generativelanguage.googleapis.com/v1beta",
174
- api_key="x",
175
  default_model=default_model,
176
  auth_mode="query_key",
177
  )
178
 
179
  async def test_native_models_payload_is_parsed_and_prefix_removed(self):
180
  async def handler(request):
181
- self.assertEqual(request.url.params.get("key"), "x")
182
  return httpx.Response(200, json={"models": [
183
  {"name": "models/gemini-3.6-flash"},
184
  {"name": "models/gemini-3.5-flash"},
 
171
  provider="gemini",
172
  profile=profile,
173
  base_url="https://generativelanguage.googleapis.com/v1beta",
174
+ api_key="gemini-secret-not-logged",
175
  default_model=default_model,
176
  auth_mode="query_key",
177
  )
178
 
179
  async def test_native_models_payload_is_parsed_and_prefix_removed(self):
180
  async def handler(request):
181
+ self.assertEqual(request.url.params.get("key"), "gemini-secret-not-logged")
182
  return httpx.Response(200, json={"models": [
183
  {"name": "models/gemini-3.6-flash"},
184
  {"name": "models/gemini-3.5-flash"},
tests/test_public_snapshot_writer.py CHANGED
@@ -4,14 +4,10 @@ from api import public_snapshot
4
 
5
 
6
  class _FakeTable:
7
- def __init__(self, calls, failures=0):
8
  self.calls = calls
9
- self.failures = failures
10
 
11
  def upsert(self, row, on_conflict=None):
12
- if self.failures:
13
- self.failures -= 1
14
- raise RuntimeError("temporary Supabase failure")
15
  self.calls.append((row, on_conflict))
16
  return self
17
 
@@ -20,22 +16,17 @@ class _FakeTable:
20
 
21
 
22
  class _FakeClient:
23
- def __init__(self, failures=0):
24
  self.calls = []
25
- self.failures = failures
26
 
27
  def table(self, name):
28
  assert name == "public_dashboard_snapshot"
29
- return _FakeTable(self.calls, self.failures)
30
-
31
-
32
- def _run(coro):
33
- return asyncio.run(coro)
34
 
35
 
36
  def test_snapshot_writer_is_idempotent_and_counts_runtime_state(monkeypatch):
37
  fake = _FakeClient()
38
- monkeypatch.setattr(public_snapshot, "get_snapshot_client", lambda: fake)
39
  monkeypatch.setattr(
40
  public_snapshot,
41
  "_agent_tasks",
@@ -47,8 +38,8 @@ def test_snapshot_writer_is_idempotent_and_counts_runtime_state(monkeypatch):
47
  )
48
  monkeypatch.setattr(public_snapshot, "_loop_registry", {"session-1": object()})
49
 
50
- assert _run(public_snapshot.write_public_dashboard_snapshot()) is True
51
- assert _run(public_snapshot.write_public_dashboard_snapshot()) is True
52
 
53
  assert len(fake.calls) == 2
54
  first, second = fake.calls
@@ -62,34 +53,6 @@ def test_snapshot_writer_is_idempotent_and_counts_runtime_state(monkeypatch):
62
  assert first[0]["service_status"] == "operational"
63
 
64
 
65
- def test_snapshot_writer_retries_transient_failure(monkeypatch):
66
- calls = []
67
-
68
- class _RetryTable:
69
- def upsert(self, row, on_conflict=None):
70
- calls.append((row, on_conflict))
71
- if len(calls) < 3:
72
- raise RuntimeError("temporary Supabase failure")
73
- return self
74
-
75
- def execute(self):
76
- return object()
77
-
78
- class _RetryClient:
79
- def table(self, name):
80
- assert name == "public_dashboard_snapshot"
81
- return _RetryTable()
82
-
83
- monkeypatch.setattr(public_snapshot, "get_snapshot_client", lambda: _RetryClient())
84
- async def _no_sleep(_seconds):
85
- return None
86
-
87
- monkeypatch.setattr(public_snapshot.asyncio, "sleep", _no_sleep)
88
-
89
- assert _run(public_snapshot.write_public_dashboard_snapshot()) is True
90
- assert len(calls) == 3
91
-
92
-
93
- def test_snapshot_writer_returns_false_when_supabase_is_unavailable(monkeypatch):
94
- monkeypatch.setattr(public_snapshot, "get_snapshot_client", lambda: None)
95
- assert _run(public_snapshot.write_public_dashboard_snapshot()) is False
 
4
 
5
 
6
  class _FakeTable:
7
+ def __init__(self, calls):
8
  self.calls = calls
 
9
 
10
  def upsert(self, row, on_conflict=None):
 
 
 
11
  self.calls.append((row, on_conflict))
12
  return self
13
 
 
16
 
17
 
18
  class _FakeClient:
19
+ def __init__(self):
20
  self.calls = []
 
21
 
22
  def table(self, name):
23
  assert name == "public_dashboard_snapshot"
24
+ return _FakeTable(self.calls)
 
 
 
 
25
 
26
 
27
  def test_snapshot_writer_is_idempotent_and_counts_runtime_state(monkeypatch):
28
  fake = _FakeClient()
29
+ monkeypatch.setattr(public_snapshot, "sb", lambda: fake)
30
  monkeypatch.setattr(
31
  public_snapshot,
32
  "_agent_tasks",
 
38
  )
39
  monkeypatch.setattr(public_snapshot, "_loop_registry", {"session-1": object()})
40
 
41
+ assert asyncio.run(public_snapshot.write_public_dashboard_snapshot()) is True
42
+ assert asyncio.run(public_snapshot.write_public_dashboard_snapshot()) is True
43
 
44
  assert len(fake.calls) == 2
45
  first, second = fake.calls
 
53
  assert first[0]["service_status"] == "operational"
54
 
55
 
56
+ def test_snapshot_writer_is_non_blocking_when_supabase_is_unavailable(monkeypatch):
57
+ monkeypatch.setattr(public_snapshot, "sb", lambda: None)
58
+ assert asyncio.run(public_snapshot.write_public_dashboard_snapshot()) is False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_shell_safety_pip.py DELETED
@@ -1,32 +0,0 @@
1
- """Regression tests for the generic shell package-manager boundary."""
2
- from __future__ import annotations
3
- import unittest
4
-
5
- from tools._shell_safety import validate_shell_command
6
-
7
-
8
- class TestPipBlockedFromGenericShell(unittest.TestCase):
9
- def test_blocks_pip_install(self):
10
- self.assertIsNotNone(validate_shell_command("pip install requests"))
11
-
12
- def test_blocks_pip3_install(self):
13
- self.assertIsNotNone(validate_shell_command("pip3 install requests"))
14
-
15
- def test_blocks_python_module_pip(self):
16
- self.assertIsNotNone(validate_shell_command("python -m pip install requests"))
17
-
18
- def test_blocks_python3_module_pip3(self):
19
- self.assertIsNotNone(validate_shell_command("python3 -m pip3 install requests"))
20
-
21
- def test_blocks_python_module_ensurepip(self):
22
- self.assertIsNotNone(validate_shell_command("python -m ensurepip"))
23
-
24
- def test_allows_python_without_package_manager(self):
25
- self.assertIsNone(validate_shell_command("python -c 'print(1)'"))
26
-
27
- def test_allows_pnpm(self):
28
- self.assertIsNone(validate_shell_command("pnpm --version"))
29
-
30
-
31
- if __name__ == "__main__":
32
- unittest.main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_telemetry_alert_lifecycle.py DELETED
@@ -1,45 +0,0 @@
1
- import ast
2
- import pathlib
3
- import unittest
4
-
5
-
6
- _MAIN = pathlib.Path(__file__).parents[1] / "main.py"
7
-
8
-
9
- class TelemetryAlertLifecycleTests(unittest.TestCase):
10
- @classmethod
11
- def setUpClass(cls):
12
- cls.tree = ast.parse(_MAIN.read_text(encoding="utf-8"))
13
- cls.source = _MAIN.read_text(encoding="utf-8")
14
- cls.functions = {
15
- node.name: node
16
- for node in cls.tree.body
17
- if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
18
- }
19
-
20
- def test_startup_registers_named_telemetry_task(self):
21
- startup = self.functions["startup_event"]
22
- source = ast.get_source_segment(self.source, startup)
23
- self.assertIn("telemetry_alert_loop", source)
24
- self.assertIn('name="telemetry-alert-loop"', source)
25
- self.assertIn("_telemetry_alert_task", source)
26
-
27
- def test_shutdown_cancels_and_awaits_telemetry_task(self):
28
- shutdown = self.functions["shutdown_event"]
29
- source = ast.get_source_segment(self.source, shutdown)
30
- self.assertIn("task.cancel()", source)
31
- self.assertIn("await task", source)
32
- self.assertIn("asyncio.CancelledError", source)
33
-
34
- def test_task_reference_is_module_scoped(self):
35
- assignment_names = {
36
- target.id
37
- for node in self.tree.body
38
- if isinstance(node, ast.AnnAssign)
39
- and isinstance(target := node.target, ast.Name)
40
- }
41
- self.assertIn("_telemetry_alert_task", assignment_names)
42
-
43
-
44
- if __name__ == "__main__":
45
- unittest.main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_vfs_atomic_rollback.py DELETED
@@ -1,76 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import asyncio
4
- import tempfile
5
- import unittest
6
- from pathlib import Path
7
- from unittest.mock import patch
8
-
9
- from agents.unified_loop import UnifiedAgentLoop
10
- from tools.registry import _delete_file, _read_file, _write_file
11
-
12
-
13
- class FakeExecutor:
14
- def __init__(self, failures: set[str] | None = None) -> None:
15
- self.calls: list[tuple[str, dict]] = []
16
- self.failures = failures or set()
17
-
18
- async def run_tool(self, name: str, inputs: dict, timeout: float = 30.0) -> dict:
19
- self.calls.append((name, inputs))
20
- if name in self.failures:
21
- return {"success": False, "error": f"forced failure: {name}", "output": None}
22
- return {"success": True, "output": {"ok": True}}
23
-
24
-
25
- class VfsAtomicRollbackTests(unittest.IsolatedAsyncioTestCase):
26
- def make_loop(self, executor: FakeExecutor) -> UnifiedAgentLoop:
27
- loop = UnifiedAgentLoop.__new__(UnifiedAgentLoop)
28
- loop.executor = executor
29
- loop._write_snapshots = {}
30
- return loop
31
-
32
- async def test_rollback_restores_existing_and_deletes_new_files(self) -> None:
33
- executor = FakeExecutor()
34
- loop = self.make_loop(executor)
35
- loop._write_snapshots = {"existing.txt": "before", "created.txt": None}
36
-
37
- await loop._rollback_writes()
38
-
39
- self.assertEqual(
40
- [(name, inputs) for name, inputs in executor.calls],
41
- [
42
- ("write_file", {"path": "existing.txt", "content": "before"}),
43
- ("delete_file", {"path": "created.txt"}),
44
- ],
45
- )
46
- self.assertEqual(loop._write_snapshots, {})
47
-
48
- async def test_failed_restore_is_not_marked_clean(self) -> None:
49
- executor = FakeExecutor({"write_file"})
50
- loop = self.make_loop(executor)
51
- loop._write_snapshots = {"existing.txt": "before", "created.txt": None}
52
-
53
- with self.assertRaisesRegex(RuntimeError, "rollback incompleto"):
54
- await loop._rollback_writes()
55
-
56
- self.assertEqual(loop._write_snapshots, {"existing.txt": "before"})
57
- self.assertEqual(executor.calls[1][0], "delete_file")
58
-
59
- async def test_delete_file_respects_fs_jail_and_is_idempotent(self) -> None:
60
- with tempfile.TemporaryDirectory() as root:
61
- with patch.dict("os.environ", {"FS_TOOL_ROOT": root}, clear=False):
62
- target = Path(root) / "created.txt"
63
- target.write_text("created", encoding="utf-8")
64
- deleted = await _delete_file("created.txt")
65
- repeated = await _delete_file("created.txt")
66
- outside = await _delete_file("../outside.txt")
67
-
68
- self.assertTrue(deleted["ok"])
69
- self.assertTrue(deleted["deleted"])
70
- self.assertTrue(repeated["ok"])
71
- self.assertFalse(repeated["deleted"])
72
- self.assertFalse(outside["ok"])
73
-
74
-
75
- if __name__ == "__main__":
76
- unittest.main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tools/_shell_safety.py CHANGED
@@ -7,7 +7,7 @@ Algoritmo:
7
  1. Blocca metacaratteri shell (previene bypass allowlist)
8
  2. shlex.split() — tokenizzazione sicura, rileva quoting anomalo
9
  3. frozenset argv[0] — allowlist letterale, non regex di prefisso
10
- 4. Regole per git (subcmd), python -m e curl/wget (solo https://)
11
  5. create_subprocess_exec / subprocess.run con lista argv — NO shell=True
12
  """
13
  from __future__ import annotations
@@ -17,12 +17,11 @@ from typing import Optional
17
  _METACHAR_RE = re.compile(r'[;&|`<>\n\r]|\$[\(\{]')
18
  _ALLOWED: frozenset = frozenset({
19
  "ls", "cat", "echo", "pwd", "whoami", "date", "uname",
20
- "python3", "python", "node", "npm", "pnpm",
21
  "grep", "find", "head", "tail", "wc", "sort", "uniq", "diff",
22
  "mkdir", "touch", "cp", "mv", "chmod", "git", "curl", "wget",
23
  })
24
  _GIT_OK: frozenset = frozenset({"status", "log", "diff", "show", "branch", "remote"})
25
- _PYTHON_MODULES_BLOCKED: frozenset = frozenset({"pip", "pip3", "ensurepip"})
26
 
27
 
28
  def safe_shell_env() -> dict:
@@ -73,9 +72,6 @@ def validate_shell_command(command: str) -> Optional[str]:
73
  if exe == "git":
74
  if len(argv) < 2 or argv[1].lower() not in _GIT_OK:
75
  return f"git sub-comando non permesso (ammessi: {', '.join(sorted(_GIT_OK))})"
76
- elif exe in ("python", "python3") and len(argv) >= 3 and argv[1] == "-m":
77
- if argv[2].lower() in _PYTHON_MODULES_BLOCKED:
78
- return f"modulo Python non permesso: '{argv[2]}'"
79
  elif exe in ("curl", "wget"):
80
  if not any(a.startswith("https://") for a in argv[1:]):
81
  return f"{exe}: solo URL https://"
 
7
  1. Blocca metacaratteri shell (previene bypass allowlist)
8
  2. shlex.split() — tokenizzazione sicura, rileva quoting anomalo
9
  3. frozenset argv[0] — allowlist letterale, non regex di prefisso
10
+ 4. Regole per git (subcmd) e curl/wget (solo https://)
11
  5. create_subprocess_exec / subprocess.run con lista argv — NO shell=True
12
  """
13
  from __future__ import annotations
 
17
  _METACHAR_RE = re.compile(r'[;&|`<>\n\r]|\$[\(\{]')
18
  _ALLOWED: frozenset = frozenset({
19
  "ls", "cat", "echo", "pwd", "whoami", "date", "uname",
20
+ "python3", "python", "node", "npm", "pip3", "pip", "pnpm",
21
  "grep", "find", "head", "tail", "wc", "sort", "uniq", "diff",
22
  "mkdir", "touch", "cp", "mv", "chmod", "git", "curl", "wget",
23
  })
24
  _GIT_OK: frozenset = frozenset({"status", "log", "diff", "show", "branch", "remote"})
 
25
 
26
 
27
  def safe_shell_env() -> dict:
 
72
  if exe == "git":
73
  if len(argv) < 2 or argv[1].lower() not in _GIT_OK:
74
  return f"git sub-comando non permesso (ammessi: {', '.join(sorted(_GIT_OK))})"
 
 
 
75
  elif exe in ("curl", "wget"):
76
  if not any(a.startswith("https://") for a in argv[1:]):
77
  return f"{exe}: solo URL https://"
tools/registry.py CHANGED
@@ -92,7 +92,7 @@ from tools._shell_safety import (
92
  )
93
  import re as _re_registry
94
  _REGISTRY_SAFE_CMD_RE = _re_registry.compile(
95
- r'^(ls|cat|echo|pwd|whoami|date|uname|python3?|node|npm|pnpm|'
96
  r'grep|find|head|tail|wc|sort|uniq|diff|mkdir|touch|cp|mv|chmod|'
97
  r'git\s+(status|log|diff|show)|curl\s+https?://|wget\s+https?://)(\s|$)',
98
  _re_registry.IGNORECASE,
@@ -685,24 +685,6 @@ async def _read_file(path: str, encoding: str = "utf-8") -> dict:
685
  return {"ok": False, "error": str(exc)}
686
 
687
 
688
- async def _delete_file(path: str) -> dict:
689
- """Rimuove un file dalla jail VFS in modo idempotente."""
690
- try:
691
- _p, _err = _safe_fs_path(path)
692
- if _err:
693
- return {"ok": False, "error": _err}
694
- if not _p.exists():
695
- return {"ok": True, "path": path, "deleted": False}
696
- if not _p.is_file():
697
- return {"ok": False, "error": f"Non è un file: {path}"}
698
- _p.unlink()
699
- return {"ok": True, "path": path, "deleted": True}
700
- except PermissionError:
701
- return {"ok": False, "error": f"Accesso negato: {path}"}
702
- except Exception as exc:
703
- return {"ok": False, "error": str(exc)}
704
-
705
-
706
  async def _write_file(path: str, content: str, encoding: str = "utf-8") -> dict:
707
  """S666/GAP-2/SEC-FS-JAIL: Scrive/sovrascrive un file nel filesystem del backend, confinato a _fs_jail_root().
708
  Manus Gap fix: read-back verifica completezza scrittura.
@@ -2317,16 +2299,6 @@ TOOL_REGISTRY: dict[str, dict] = {
2317
  "fallbacks": [],
2318
  "_fn": _read_file,
2319
  },
2320
- "delete_file": {
2321
- "name": "delete_file",
2322
- "goal": "Rimuove un file dal filesystem del backend",
2323
- "description": "Rimuove un file locale confinato alla jail VFS; idempotente se il file è già assente.",
2324
- "required_inputs": ["path"],
2325
- "optional_inputs": {},
2326
- "risk_level": "medium",
2327
- "fallbacks": [],
2328
- "_fn": _delete_file,
2329
- },
2330
  "write_file": {
2331
  "name": "write_file",
2332
  "goal": "Scrive o sovrascrive un file nel filesystem del backend",
 
92
  )
93
  import re as _re_registry
94
  _REGISTRY_SAFE_CMD_RE = _re_registry.compile(
95
+ r'^(ls|cat|echo|pwd|whoami|date|uname|python3?|node|npm|pip[3]?|pnpm|'
96
  r'grep|find|head|tail|wc|sort|uniq|diff|mkdir|touch|cp|mv|chmod|'
97
  r'git\s+(status|log|diff|show)|curl\s+https?://|wget\s+https?://)(\s|$)',
98
  _re_registry.IGNORECASE,
 
685
  return {"ok": False, "error": str(exc)}
686
 
687
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
688
  async def _write_file(path: str, content: str, encoding: str = "utf-8") -> dict:
689
  """S666/GAP-2/SEC-FS-JAIL: Scrive/sovrascrive un file nel filesystem del backend, confinato a _fs_jail_root().
690
  Manus Gap fix: read-back verifica completezza scrittura.
 
2299
  "fallbacks": [],
2300
  "_fn": _read_file,
2301
  },
 
 
 
 
 
 
 
 
 
 
2302
  "write_file": {
2303
  "name": "write_file",
2304
  "goal": "Scrive o sovrascrive un file nel filesystem del backend",