Skip to content

Commit cc59902

Browse files
committed
github: eager bot-user-ID auto-detection (slice of upstream 9824d33)
Port the github slice of upstream `9824d33` (PR #441 adapter-hardening pass) so `is_me` checks work and self-reply loops are prevented in multi-tenant deployments. Adds `_detect_bot_user_id` mirroring upstream's exact sequence and fallback: - try `users.getAuthenticated` (`GET /user`) first — works for PAT mode and (returns the bot user) for installation tokens too - on failure, fall back to `apps.getAuthenticated` (`GET /app`) and resolve the bot user via `users.getByUsername` (`GET /users/{slug}[bot]`) Detection runs eagerly on the first webhook for an installation (before dispatch, so the very first reply sees a populated bot id) and is cached on `self._bot_user_id` so it's fetched once per process, not per webhook. `initialize()` and `remove_reaction()` now route through the same method. `_github_api_request` gains an `installation_id` override so detection can authenticate for the webhook's installation in multi-tenant mode (reuses existing `_get_installation_token` plumbing). Tests (mock the GitHub API client via AsyncMock): - first webhook for a new installation populates `_bot_user_id` before message handling proceeds - a message authored by the bot itself is filtered as `is_me` - PAT path uses the `users.getAuthenticated` (`GET /user`) path - second webhook for same installation does not re-fetch (cache hit) Refs #98. github slice of the 4-part `9824d33` security pass. https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj
1 parent 3ba6456 commit cc59902

2 files changed

Lines changed: 264 additions & 20 deletions

File tree

src/chat_sdk/adapters/github/adapter.py

Lines changed: 79 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -201,21 +201,68 @@ async def initialize(self, chat: ChatInstance) -> None:
201201
"""Initialize the adapter."""
202202
self._chat = chat
203203

204-
if not self._bot_user_id and self._auth_token:
205-
try:
206-
user_data = await self._github_api_request("GET", "/user")
207-
self._bot_user_id = user_data.get("id")
204+
# Fetch bot user ID if not provided. For multi-tenant mode there is no
205+
# fixed installation yet, so detection happens lazily on the first
206+
# webhook (see ``handle_webhook``) so ``is_me`` checks work for the
207+
# very first reply.
208+
if self._bot_user_id is None and self._auth_token:
209+
await self._detect_bot_user_id()
210+
211+
self._logger.info("GitHub adapter initialized")
212+
213+
async def _detect_bot_user_id(self, installation_id: int | None = None) -> None:
214+
"""Fetch the bot's user ID from GitHub. Used for self-message detection.
215+
216+
Best-effort: errors are swallowed so they do not block webhook
217+
processing. The bot identity is the same across all installations of
218+
the same App, so we only detect once and cache the result on
219+
``self._bot_user_id``.
220+
221+
Mirrors the upstream TypeScript ``detectBotUserId`` sequence:
222+
try ``users.getAuthenticated`` (``GET /user``) first — this works for
223+
PAT mode and (returns the bot user) for installation tokens too. On
224+
failure, fall back to ``apps.getAuthenticated`` (``GET /app``) and
225+
resolve the bot user via ``users.getByUsername`` (``GET /users/{slug}[bot]``).
226+
"""
227+
# Cache hit: already detected once, don't re-fetch per webhook.
228+
if self._bot_user_id is not None:
229+
return
230+
231+
try:
232+
user = await self._github_api_request("GET", "/user", installation_id=installation_id)
233+
self._bot_user_id = user.get("id")
234+
self._logger.info(
235+
"GitHub bot user ID auto-detected",
236+
{
237+
"botUserId": self._bot_user_id,
238+
"login": user.get("login"),
239+
},
240+
)
241+
return
242+
except Exception as error:
243+
self._logger.debug(
244+
"users.getAuthenticated failed; falling back to apps.getAuthenticated",
245+
{"error": str(error)},
246+
)
247+
248+
try:
249+
# For App-authenticated installation tokens, /user is not available;
250+
# use apps.getAuthenticated and resolve the bot user via
251+
# /users/{login}[bot].
252+
app = await self._github_api_request("GET", "/app", installation_id=installation_id)
253+
if app:
254+
login = f"{app['slug']}[bot]"
255+
bot_user = await self._github_api_request("GET", f"/users/{login}", installation_id=installation_id)
256+
self._bot_user_id = bot_user.get("id")
208257
self._logger.info(
209-
"GitHub auth completed",
258+
"GitHub bot user ID auto-detected via app slug",
210259
{
211260
"botUserId": self._bot_user_id,
212-
"login": user_data.get("login"),
261+
"login": login,
213262
},
214263
)
215-
except Exception as error:
216-
self._logger.warn("Could not fetch bot user ID", {"error": str(error)})
217-
218-
self._logger.info("GitHub adapter initialized")
264+
except Exception as error:
265+
self._logger.warn("Could not auto-detect GitHub bot user ID", {"error": str(error)})
219266

220267
async def get_user(self, user_id: str) -> UserInfo | None:
221268
"""Look up a GitHub user by numeric account ID.
@@ -306,6 +353,13 @@ async def handle_webhook(self, request: Any, options: WebhookOptions | None = No
306353
if owner_login and repo_name:
307354
await self._store_installation_id(owner_login, repo_name, installation_id)
308355

356+
# Eagerly resolve the bot user ID before dispatching to handlers, so
357+
# is_me checks work on the very first webhook. Without this,
358+
# multi-tenant adapters can self-reply-loop until detection fires
359+
# lazily elsewhere. Cached after the first detection (once per process).
360+
if self._bot_user_id is None:
361+
await self._detect_bot_user_id(installation_id)
362+
309363
if event_type == "issue_comment":
310364
if payload.get("action") == "created":
311365
self._handle_issue_comment(payload, installation_id, options)
@@ -629,12 +683,10 @@ async def add_reaction(self, thread_id: str, message_id: str, emoji: EmojiValue
629683

630684
async def remove_reaction(self, thread_id: str, message_id: str, emoji: EmojiValue | str) -> None:
631685
"""Remove a reaction from a message."""
632-
if not self._bot_user_id and self._auth_token:
633-
try:
634-
user_data = await self._github_api_request("GET", "/user")
635-
self._bot_user_id = user_data.get("id")
636-
except Exception:
637-
self._logger.warn("Could not detect bot user ID for reaction removal")
686+
# Multi-tenant mode has no fixed installation, so initialize() can't
687+
# detect _bot_user_id. Detect lazily (cached after first success).
688+
if self._bot_user_id is None:
689+
await self._detect_bot_user_id()
638690

639691
decoded = self.decode_thread_id(thread_id)
640692
comment_id = int(message_id)
@@ -1059,24 +1111,31 @@ async def _get_installation_token(self, installation_id: int) -> str:
10591111
)
10601112
return token
10611113

1062-
async def _github_api_request(self, method: str, path: str, body: Any = None) -> Any:
1114+
async def _github_api_request(
1115+
self, method: str, path: str, body: Any = None, *, installation_id: int | None = None
1116+
) -> Any:
10631117
"""Make a request to the GitHub API.
10641118
10651119
Supports PAT auth (``_auth_token``) and GitHub App auth
10661120
(``_app_credentials`` with JWT -> installation token exchange).
1121+
1122+
``installation_id`` overrides ``self._installation_id`` for App auth.
1123+
Multi-tenant mode has no fixed installation on the adapter (it is
1124+
extracted per-webhook), so callers operating inside a webhook context
1125+
pass the webhook's installation explicitly.
10671126
"""
10681127
auth_token = self._auth_token
10691128

10701129
# GitHub App auth: exchange JWT for installation token
10711130
if not auth_token and self._app_credentials:
1072-
installation_id = self._installation_id
1073-
if not installation_id:
1131+
resolved_installation_id = installation_id if installation_id is not None else self._installation_id
1132+
if not resolved_installation_id:
10741133
raise RuntimeError(
10751134
"Installation ID required for GitHub App authentication. "
10761135
"This usually means you're trying to make an API call outside of a webhook context. "
10771136
"For proactive messages, use thread IDs from previous webhook interactions."
10781137
)
1079-
auth_token = await self._get_installation_token(installation_id)
1138+
auth_token = await self._get_installation_token(resolved_installation_id)
10801139

10811140
headers: dict[str, str] = {
10821141
"Accept": "application/vnd.github+json",

tests/test_github_dispatch.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,3 +281,188 @@ async def test_in_reply_to_id_routes_to_root_thread(self):
281281
assert decoded.review_comment_id == root_comment_id
282282
# The thread_id format should contain :rc:3000
283283
assert f":rc:{root_comment_id}" in thread_id
284+
285+
286+
# =============================================================================
287+
# Tests -- eager bot-user-ID auto-detection (github slice of upstream 9824d33)
288+
# =============================================================================
289+
290+
291+
def _make_multi_tenant_adapter(**overrides: Any) -> GitHubAdapter:
292+
"""Create a multi-tenant GitHub App adapter (no installation_id)."""
293+
defaults: dict[str, Any] = {
294+
"webhook_secret": WEBHOOK_SECRET,
295+
"app_id": "12345",
296+
"private_key": "-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----",
297+
"logger": ConsoleLogger("error"),
298+
}
299+
defaults.update(overrides)
300+
return GitHubAdapter(defaults)
301+
302+
303+
def _make_install_payload(*, sender_id: int = 100, installation_id: int = 54321) -> dict[str, Any]:
304+
"""An issue_comment payload carrying an installation id (multi-tenant)."""
305+
payload = _make_issue_comment_payload(sender_id=sender_id, owner="acme", repo="app", pr_number=42)
306+
payload["installation"] = {"id": installation_id, "node_id": "MDIzOk"}
307+
return payload
308+
309+
310+
class TestEagerBotUserIdDetection:
311+
"""Eager bot-user-ID detection so is_me works and self-reply loops are prevented.
312+
313+
Mirrors the github slice of upstream commit 9824d33: detection runs on the
314+
first webhook for an installation (so the very first reply has a populated
315+
bot id), is cached so subsequent webhooks don't re-fetch, and falls back
316+
from users.getAuthenticated (GET /user) to apps.getAuthenticated (GET /app)
317+
+ users.getByUsername (GET /users/{slug}[bot]) for installation tokens.
318+
"""
319+
320+
@pytest.mark.asyncio
321+
async def test_webhook_populates_bot_user_id_before_dispatch(self):
322+
"""First webhook for a new installation populates _bot_user_id before message handling."""
323+
adapter = _make_multi_tenant_adapter()
324+
chat = MagicMock()
325+
chat.process_message = MagicMock()
326+
chat.get_state = MagicMock(return_value=MagicMock(set=AsyncMock(), get=AsyncMock(return_value=None)))
327+
adapter._chat = chat
328+
329+
# Installation tokens: /user is unavailable, so detection falls back to
330+
# /app then /users/{slug}[bot].
331+
async def fake_api(method: str, path: str, body: Any = None, *, installation_id: int | None = None) -> Any:
332+
if path == "/user":
333+
raise RuntimeError("404 /user not available for installation token")
334+
if path == "/app":
335+
return {"slug": "my-bot", "id": 1}
336+
if path == "/users/my-bot[bot]":
337+
return {"id": 7777, "login": "my-bot[bot]"}
338+
raise AssertionError(f"unexpected path {path}")
339+
340+
api = AsyncMock(side_effect=fake_api)
341+
adapter._github_api_request = api
342+
343+
# process_message must observe a populated bot id. Capture it at call time.
344+
observed: dict[str, Any] = {}
345+
chat.process_message.side_effect = lambda *a, **kw: observed.update({"bot_id": adapter._bot_user_id})
346+
347+
payload = _make_install_payload(sender_id=100, installation_id=54321)
348+
body = json.dumps(payload)
349+
headers = {
350+
"x-hub-signature-256": _sign(body),
351+
"x-github-event": "issue_comment",
352+
"content-type": "application/json",
353+
}
354+
355+
result = await adapter.handle_webhook(FakeRequest(body, headers))
356+
357+
assert result["status"] == 200
358+
assert adapter._bot_user_id == 7777
359+
# Detection used the webhook's installation id for the API calls.
360+
assert api.await_args_list[0].kwargs["installation_id"] == 54321
361+
# process_message ran, and the bot id was already set when it did.
362+
chat.process_message.assert_called_once()
363+
assert observed["bot_id"] == 7777
364+
365+
@pytest.mark.asyncio
366+
async def test_message_from_bot_filtered_as_is_me(self):
367+
"""A message authored by the bot itself is filtered (self-reply loop prevented)."""
368+
adapter = _make_multi_tenant_adapter()
369+
chat = MagicMock()
370+
chat.process_message = MagicMock()
371+
chat.get_state = MagicMock(return_value=MagicMock(set=AsyncMock(), get=AsyncMock(return_value=None)))
372+
adapter._chat = chat
373+
374+
async def fake_api(method: str, path: str, body: Any = None, *, installation_id: int | None = None) -> Any:
375+
if path == "/user":
376+
raise RuntimeError("404")
377+
if path == "/app":
378+
return {"slug": "my-bot"}
379+
if path == "/users/my-bot[bot]":
380+
return {"id": 8888, "login": "my-bot[bot]"}
381+
raise AssertionError(f"unexpected path {path}")
382+
383+
adapter._github_api_request = AsyncMock(side_effect=fake_api)
384+
385+
# Sender is the bot itself (id 8888) -> must be ignored.
386+
payload = _make_install_payload(sender_id=8888, installation_id=54321)
387+
body = json.dumps(payload)
388+
headers = {
389+
"x-hub-signature-256": _sign(body),
390+
"x-github-event": "issue_comment",
391+
"content-type": "application/json",
392+
}
393+
394+
result = await adapter.handle_webhook(FakeRequest(body, headers))
395+
396+
assert result["status"] == 200
397+
assert adapter._bot_user_id == 8888
398+
# Self-message: process_message NOT called -> no self-reply loop.
399+
chat.process_message.assert_not_called()
400+
401+
@pytest.mark.asyncio
402+
async def test_pat_path_uses_users_get_authenticated(self):
403+
"""PAT path resolves the bot via GET /user (users.getAuthenticated), no /app fallback."""
404+
adapter = _make_adapter() # token-based (PAT) adapter
405+
chat = MagicMock()
406+
chat.process_message = MagicMock()
407+
chat.get_state = MagicMock(return_value=MagicMock(set=AsyncMock(), get=AsyncMock(return_value=None)))
408+
adapter._chat = chat
409+
410+
api = AsyncMock(return_value={"id": 4242, "login": "pat-bot"})
411+
adapter._github_api_request = api
412+
413+
payload = _make_issue_comment_payload(sender_id=100)
414+
body = json.dumps(payload)
415+
headers = {
416+
"x-hub-signature-256": _sign(body),
417+
"x-github-event": "issue_comment",
418+
"content-type": "application/json",
419+
}
420+
421+
result = await adapter.handle_webhook(FakeRequest(body, headers))
422+
423+
assert result["status"] == 200
424+
assert adapter._bot_user_id == 4242
425+
# Only GET /user was used; no apps.getAuthenticated fallback.
426+
called_paths = [c.args[1] for c in api.await_args_list]
427+
assert called_paths == ["/user"]
428+
chat.process_message.assert_called_once()
429+
430+
@pytest.mark.asyncio
431+
async def test_second_webhook_does_not_refetch(self):
432+
"""A second webhook for the same installation does NOT re-fetch (cache hit)."""
433+
adapter = _make_multi_tenant_adapter()
434+
chat = MagicMock()
435+
chat.process_message = MagicMock()
436+
chat.get_state = MagicMock(return_value=MagicMock(set=AsyncMock(), get=AsyncMock(return_value=None)))
437+
adapter._chat = chat
438+
439+
async def fake_api(method: str, path: str, body: Any = None, *, installation_id: int | None = None) -> Any:
440+
if path == "/user":
441+
raise RuntimeError("404")
442+
if path == "/app":
443+
return {"slug": "my-bot"}
444+
if path == "/users/my-bot[bot]":
445+
return {"id": 9999, "login": "my-bot[bot]"}
446+
raise AssertionError(f"unexpected path {path}")
447+
448+
api = AsyncMock(side_effect=fake_api)
449+
adapter._github_api_request = api
450+
451+
headers_for = lambda b: { # noqa: E731
452+
"x-hub-signature-256": _sign(b),
453+
"x-github-event": "issue_comment",
454+
"content-type": "application/json",
455+
}
456+
457+
body1 = json.dumps(_make_install_payload(sender_id=100, installation_id=54321))
458+
await adapter.handle_webhook(FakeRequest(body1, headers_for(body1)))
459+
assert adapter._bot_user_id == 9999
460+
461+
detection_calls_after_first = len(api.await_args_list)
462+
463+
body2 = json.dumps(_make_install_payload(sender_id=101, installation_id=54321))
464+
await adapter.handle_webhook(FakeRequest(body2, headers_for(body2)))
465+
466+
# No additional detection API calls on the second webhook (cache hit).
467+
assert len(api.await_args_list) == detection_calls_after_first
468+
assert chat.process_message.call_count == 2

0 commit comments

Comments
 (0)