diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index b6efab767d5..ddedeb78706 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -1768,7 +1768,7 @@ class APIServerAdapter(BasePlatformAdapter): })) except Exception as exc: logger.exception("[api_server] session chat stream failed") - await queue.put(_event_payload("error", {"message": str(exc)})) + await queue.put(_event_payload("error", {"message": _redact_api_error_text(exc)})) finally: await queue.put(_event_payload("done", {})) await queue.put(None) @@ -3297,7 +3297,7 @@ class APIServerAdapter(BasePlatformAdapter): jobs = _cron_list(include_disabled=include_disabled) return web.json_response({"jobs": jobs}) except Exception as e: - return web.json_response({"error": str(e)}, status=500) + return web.json_response({"error": _redact_api_error_text(e)}, status=500) async def _handle_create_job(self, request: "web.Request") -> "web.Response": """POST /api/jobs — create a new cron job.""" @@ -3351,7 +3351,7 @@ class APIServerAdapter(BasePlatformAdapter): _notify_cron_provider_jobs_changed() return web.json_response({"job": job}) except Exception as e: - return web.json_response({"error": str(e)}, status=500) + return web.json_response({"error": _redact_api_error_text(e)}, status=500) async def _handle_get_job(self, request: "web.Request") -> "web.Response": """GET /api/jobs/{job_id} — get a single cron job.""" @@ -3370,7 +3370,7 @@ class APIServerAdapter(BasePlatformAdapter): return web.json_response({"error": "Job not found"}, status=404) return web.json_response({"job": job}) except Exception as e: - return web.json_response({"error": str(e)}, status=500) + return web.json_response({"error": _redact_api_error_text(e)}, status=500) async def _handle_update_job(self, request: "web.Request") -> "web.Response": """PATCH /api/jobs/{job_id} — update a cron job.""" @@ -3408,7 +3408,7 @@ class APIServerAdapter(BasePlatformAdapter): _notify_cron_provider_jobs_changed() return web.json_response({"job": job}) except Exception as e: - return web.json_response({"error": str(e)}, status=500) + return web.json_response({"error": _redact_api_error_text(e)}, status=500) async def _handle_delete_job(self, request: "web.Request") -> "web.Response": """DELETE /api/jobs/{job_id} — delete a cron job.""" @@ -3428,7 +3428,7 @@ class APIServerAdapter(BasePlatformAdapter): _notify_cron_provider_jobs_changed() return web.json_response({"ok": True}) except Exception as e: - return web.json_response({"error": str(e)}, status=500) + return web.json_response({"error": _redact_api_error_text(e)}, status=500) async def _handle_pause_job(self, request: "web.Request") -> "web.Response": """POST /api/jobs/{job_id}/pause — pause a cron job.""" @@ -3448,7 +3448,7 @@ class APIServerAdapter(BasePlatformAdapter): _notify_cron_provider_jobs_changed() return web.json_response({"job": job}) except Exception as e: - return web.json_response({"error": str(e)}, status=500) + return web.json_response({"error": _redact_api_error_text(e)}, status=500) async def _handle_resume_job(self, request: "web.Request") -> "web.Response": """POST /api/jobs/{job_id}/resume — resume a paused cron job.""" @@ -3468,7 +3468,7 @@ class APIServerAdapter(BasePlatformAdapter): _notify_cron_provider_jobs_changed() return web.json_response({"job": job}) except Exception as e: - return web.json_response({"error": str(e)}, status=500) + return web.json_response({"error": _redact_api_error_text(e)}, status=500) async def _handle_run_job(self, request: "web.Request") -> "web.Response": """POST /api/jobs/{job_id}/run — trigger immediate execution.""" @@ -3487,7 +3487,7 @@ class APIServerAdapter(BasePlatformAdapter): return web.json_response({"error": "Job not found"}, status=404) return web.json_response({"job": job}) except Exception as e: - return web.json_response({"error": str(e)}, status=500) + return web.json_response({"error": _redact_api_error_text(e)}, status=500) async def _handle_cron_fire(self, request: "web.Request") -> "web.Response": """POST /api/cron/fire — Chronos managed-cron fire webhook (NAS → agent). diff --git a/scripts/release.py b/scripts/release.py index 5608f68b802..3ce210ade92 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -59,6 +59,7 @@ AUTHOR_MAP = { "267614622+agt-user@users.noreply.github.com": "agt-user", # PR #48496 salvage (telegram CLOSE-WAIT polling heartbeat, #48495) "80915+DavidMetcalfe@users.noreply.github.com": "DavidMetcalfe", # PR #52272 salvage (route reasoning-model thinking-timeouts to timeout not context_overflow + reasoning-specific guidance; #52271) "66773372+Tranquil-Flow@users.noreply.github.com": "Tranquil-Flow", # PR #52623 salvage (auxiliary Anthropic base_url host validation; #52608) + "65363919+coygeek@users.noreply.github.com": "coygeek", # PR #37735 salvage (redact provider error text at api-server HTTP boundary; #37733) "moonsong@nousresearch.local": "Tranquil-Flow", # PR #52623 salvage (auxiliary Anthropic base_url host validation; #52608) "140971685+Dr1985@users.noreply.github.com": "Dr1985", # PR #42567 salvage (launchd supervision detection + status reporting; #42524) "8180647+herbalizer404@users.noreply.github.com": "herbalizer404", # PR #49076 + #51835 salvage (auxiliary compression fallback: 403/session-usage payment errors + honor fallback chain when aux provider auth unavailable) diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index 752e3931bb9..3df7bac1dea 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -30,6 +30,7 @@ from gateway.platforms.api_server import ( ResponseStore, _IdempotencyCache, _derive_chat_session_id, + _redact_api_error_text, check_api_server_requirements, cors_middleware, security_headers_middleware, @@ -50,6 +51,34 @@ class TestCheckRequirements: assert check_api_server_requirements() is False +# --------------------------------------------------------------------------- +# _redact_api_error_text — guards every outward error site (envelopes, SSE +# error events, cron-endpoint 500 bodies) that routes raw exception text to +# authenticated HTTP clients. #37733 +# --------------------------------------------------------------------------- + + +class TestRedactApiErrorText: + def test_masks_secret_value_but_preserves_structure(self): + secret = "sk-api-server-leak-1234567890" + out = _redact_api_error_text(Exception(f"auth failed OPENAI_API_KEY={secret}")) + assert secret not in out + assert "OPENAI_API_KEY=" in out + + def test_redacts_regardless_of_global_redaction_setting(self): + # force=True must mask even when global redaction is disabled. + secret = "sk-forced-redaction-0987654321" + with patch("agent.redact._REDACT_ENABLED", False): + out = _redact_api_error_text(Exception(f"boom AWS_SECRET_ACCESS_KEY={secret}")) + assert secret not in out + + def test_limit_truncates_after_redaction(self): + assert len(_redact_api_error_text("x" * 500, limit=50)) == 50 + + def test_clean_text_passes_through_unchanged(self): + assert _redact_api_error_text("Job not found") == "Job not found" + + # --------------------------------------------------------------------------- # ResponseStore # ---------------------------------------------------------------------------