Baida07 commited on
Commit
8d5190f
·
1 Parent(s): c8365f5

sync: 204 file da Baida98/AI@843961e3 (2026-08-31 07:48 UTC) [deploy-all] (#154)

Browse files

- sync: 204 file da Baida98/AI@843961e3 (2026-08-31 07:48 UTC) [deploy-all] (9f9d3dc314be6b399d4fe2afb217579f98304139)

agents/executor.py CHANGED
@@ -292,15 +292,39 @@ 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
  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
 
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
agents/unified_loop.py CHANGED
@@ -214,37 +214,46 @@ 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
- """
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,18 +1328,28 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
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,6 +3576,12 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
3557
  }
3558
 
3559
  state.errors.append(error_text)
 
 
 
 
 
 
3560
  previous = state.state_machine.current
3561
  if previous != AgentState.FAILED:
3562
  try:
@@ -3714,6 +3739,12 @@ class UnifiedAgentLoop(DirectToolsMixin, PromptBuilderMixin, LLMSelectionMixin,
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:
 
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
  {"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
  }
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
 
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:
api/background_tasks.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/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
- 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,18 +377,15 @@ async def start_job_queue_consumer() -> None:
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
 
 
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
  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
 
api/state.py CHANGED
@@ -139,6 +139,11 @@ _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
 
143
  # ── Telemetry & Health ────────────────────────────────────────────────────────
144
  _ai_health_cache: dict = {"data": None, "at": 0.0}
@@ -244,7 +249,7 @@ def _prune_checkpoints() -> None:
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)
 
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
  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)
benchmark-extended.mjs ADDED
The diff for this file is too large to render. See raw diff
 
cors_policy.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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,6 +10,7 @@ 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
 
14
  # Configurazione Logging
15
  logging.basicConfig(
@@ -25,13 +26,14 @@ app = FastAPI(
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,6 +246,20 @@ async def run_cli_task(task_description: str):
244
  sys.exit(1)
245
 
246
  # ── Startup ───────────────────────────────────────────────────────────────────
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  @app.on_event("startup")
248
  async def startup_event():
249
  _logger.info("Server starting up...")
@@ -265,6 +281,14 @@ async def startup_event():
265
  _logger.warning("⚠️ BOOT: public dashboard snapshot writer non ha persistito lo snapshot.")
266
  except Exception as e:
267
  _logger.warning(f"⚠️ BOOT: avvio public snapshot writer fallito (non bloccante): {e}")
 
 
 
 
 
 
 
 
268
  try:
269
  from api.providers import start_heartbeat
270
  start_heartbeat()
@@ -273,9 +297,24 @@ async def startup_event():
273
  _logger.warning(f"⚠️ BOOT: avvio provider heartbeat fallito (non bloccante): {e}")
274
  if not any(arg in sys.argv for arg in ["--task", "-t"]):
275
  try:
 
276
  from api.job_queue import start_job_queue_consumer
277
- asyncio.create_task(start_job_queue_consumer())
278
- except Exception: pass
 
 
 
 
 
 
 
 
 
 
 
 
 
 
279
 
280
  # ── SPA Hosting ───────────────────────────────────────────────────────────────
281
  _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
+ from cors_policy import allowed_origins
14
 
15
  # Configurazione Logging
16
  logging.basicConfig(
 
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
  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=10.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...")
 
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
  _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')
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 ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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_background_tasks.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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="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"},
 
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"},
tests/test_telemetry_alert_lifecycle.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/registry.py CHANGED
@@ -685,6 +685,24 @@ async def _read_file(path: str, encoding: str = "utf-8") -> dict:
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,6 +2317,16 @@ TOOL_REGISTRY: dict[str, dict] = {
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",
 
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
  "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",