diff --git a/plugins/platforms/google_chat/oauth.py b/plugins/platforms/google_chat/oauth.py index 8c581133fc..7c54726b8a 100644 --- a/plugins/platforms/google_chat/oauth.py +++ b/plugins/platforms/google_chat/oauth.py @@ -586,7 +586,8 @@ def revoke(email: Optional[str] = None) -> None: f"https://oauth2.googleapis.com/revoke?token={creds.token}", method="POST", headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) + ), + timeout=15, ) print("Token revoked with Google.") except Exception as exc: diff --git a/skills/productivity/google-workspace/scripts/gws_bridge.py b/skills/productivity/google-workspace/scripts/gws_bridge.py index e3cc9f1473..7d10ba2574 100755 --- a/skills/productivity/google-workspace/scripts/gws_bridge.py +++ b/skills/productivity/google-workspace/scripts/gws_bridge.py @@ -51,13 +51,16 @@ def refresh_token(token_data: dict) -> dict: req = urllib.request.Request(token_data["token_uri"], data=params) try: - with urllib.request.urlopen(req) as resp: + with urllib.request.urlopen(req, timeout=15) as resp: result = json.loads(resp.read()) except urllib.error.HTTPError as e: body = e.read().decode("utf-8", errors="replace") print(f"ERROR: Token refresh failed (HTTP {e.code}): {body}", file=sys.stderr) print("Re-run setup.py to re-authenticate.", file=sys.stderr) sys.exit(1) + except (urllib.error.URLError, TimeoutError) as e: + print(f"ERROR: Token refresh failed (network): {e}", file=sys.stderr) + sys.exit(1) token_data["token"] = result["access_token"] token_data["expiry"] = datetime.fromtimestamp( diff --git a/skills/productivity/google-workspace/scripts/setup.py b/skills/productivity/google-workspace/scripts/setup.py index fbf91128bd..d09085fe77 100644 --- a/skills/productivity/google-workspace/scripts/setup.py +++ b/skills/productivity/google-workspace/scripts/setup.py @@ -411,7 +411,8 @@ def revoke(): f"https://oauth2.googleapis.com/revoke?token={creds.token}", method="POST", headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) + ), + timeout=15, ) print("Token revoked with Google.") except Exception as e: diff --git a/tests/skills/test_google_workspace_api.py b/tests/skills/test_google_workspace_api.py index bbd51a35df..7ecfb4b7b7 100644 --- a/tests/skills/test_google_workspace_api.py +++ b/tests/skills/test_google_workspace_api.py @@ -103,6 +103,51 @@ def test_bridge_refreshes_expired_token(bridge_module, tmp_path): assert saved["type"] == "authorized_user" +def test_bridge_refresh_passes_timeout_to_urlopen(bridge_module): + """Token refresh must pass an explicit timeout so a hung Google endpoint + cannot block the agent turn indefinitely (no `timeout=` defaults to the + global socket timeout, which is unset).""" + past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat() + token_path = bridge_module.get_token_path() + _write_token(token_path, token="ya29.old", expiry=past) + + mock_resp = MagicMock() + mock_resp.read.return_value = json.dumps({ + "access_token": "ya29.refreshed", + "expires_in": 3600, + }).encode() + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + + with patch("urllib.request.urlopen", return_value=mock_resp) as mocked: + bridge_module.get_valid_token() + + assert mocked.call_count == 1 + _, kwargs = mocked.call_args + assert kwargs.get("timeout") is not None, ( + "urlopen call must pass timeout= to avoid hanging on unreachable upstream" + ) + + +def test_bridge_refresh_exits_cleanly_on_network_error(bridge_module): + """URLError/timeout during refresh exits 1 with a readable message + instead of crashing with a raw traceback.""" + import urllib.error + + past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat() + token_path = bridge_module.get_token_path() + _write_token(token_path, token="ya29.old", expiry=past) + + with patch( + "urllib.request.urlopen", + side_effect=urllib.error.URLError("timed out"), + ): + with pytest.raises(SystemExit) as exc_info: + bridge_module.get_valid_token() + + assert exc_info.value.code == 1 + + def test_bridge_exits_on_missing_token(bridge_module): """Missing token file causes exit with code 1.""" with pytest.raises(SystemExit):