Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion plugins/platforms/google_chat/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 4 additions & 1 deletion skills/productivity/google-workspace/scripts/gws_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
3 changes: 2 additions & 1 deletion skills/productivity/google-workspace/scripts/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
45 changes: 45 additions & 0 deletions tests/skills/test_google_workspace_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down