Skip to content

Commit 71b7152

Browse files
github: eager bot-user-ID auto-detection (slice of upstream 9824d33) (#128)
* 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 * fix(github): auth /app with App JWT and serialize bot-id detection Addresses two PR #128 review findings on bot-user-ID auto-detection: HIGH (Gemini): GET /app must use an App JWT, not an installation token. _detect_bot_user_id's fallback calls apps.getAuthenticated (GET /app), but _github_api_request unconditionally exchanged the App JWT for an installation token whenever _app_credentials was set. GitHub's /app endpoint rejects installation tokens (401/403), so bot-id detection was broken for the GitHub-App/installation case (the primary /user path also 403s on installation tokens, so the /app fallback is what must work). Special-case path == "/app" in the auth-selection branch to authenticate with self._generate_app_jwt() directly; all other App-auth requests keep the installation-token exchange (and its RuntimeError when no installation id is resolvable). /users/{slug}[bot] is a public lookup and works fine with the installation token. MEDIUM (Gemini): race condition + await-in-try in _detect_bot_user_id. Concurrent webhooks could all trigger redundant detection before the first cached the result. Add an asyncio.Lock (_detect_lock, created in __init__) and double-checked locking: fast-path early-return on cache hit, then acquire the lock and re-check inside it before running the detection sequence with each await inside its try block. Best-effort semantics (errors logged, not raised) preserved. Tests (tests/test_github_dispatch.py): - TestAppEndpointAuthSelection.test_app_endpoint_uses_jwt_not_installation_token: fakes the HTTP layer so the real auth-selection logic runs; asserts /app is sent "Bearer APP_JWT" (not the installation token) while /user and /users/{slug}[bot] use the installation token. Fails against the broken code (where /app went through installation-token exchange). - TestConcurrentDetectionFetchesOnce.test_concurrent_detect_fetches_once: fires 8 concurrent _detect_bot_user_id calls with a slow mocked /user; asserts the API is hit exactly once. Fails without the lock (8 calls). https://claude.ai/code/session_01FyMxQn2BEAzmwKS1GZczKj --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 316953b commit 71b7152

2 files changed

Lines changed: 422 additions & 23 deletions

File tree

src/chat_sdk/adapters/github/adapter.py

Lines changed: 106 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,10 @@ def __init__(self, config: GitHubAdapterConfig | None = None) -> None:
120120
self._installation_id: int | None = None
121121
self._installation_token_cache: dict[int, tuple[str, float]] = {}
122122
self._token_lock = asyncio.Lock()
123+
# Serializes concurrent bot-user-ID detection so concurrent webhooks
124+
# don't all race to fetch the (identical) bot identity (see
125+
# ``_detect_bot_user_id``).
126+
self._detect_lock = asyncio.Lock()
123127

124128
# Shared aiohttp session for connection pooling
125129
self._http_session: Any | None = None
@@ -201,21 +205,77 @@ async def initialize(self, chat: ChatInstance) -> None:
201205
"""Initialize the adapter."""
202206
self._chat = chat
203207

204-
if not self._bot_user_id and self._auth_token:
208+
# Fetch bot user ID if not provided. For multi-tenant mode there is no
209+
# fixed installation yet, so detection happens lazily on the first
210+
# webhook (see ``handle_webhook``) so ``is_me`` checks work for the
211+
# very first reply.
212+
if self._bot_user_id is None and self._auth_token:
213+
await self._detect_bot_user_id()
214+
215+
self._logger.info("GitHub adapter initialized")
216+
217+
async def _detect_bot_user_id(self, installation_id: int | None = None) -> None:
218+
"""Fetch the bot's user ID from GitHub. Used for self-message detection.
219+
220+
Best-effort: errors are swallowed so they do not block webhook
221+
processing. The bot identity is the same across all installations of
222+
the same App, so we only detect once and cache the result on
223+
``self._bot_user_id``.
224+
225+
Mirrors the upstream TypeScript ``detectBotUserId`` sequence:
226+
try ``users.getAuthenticated`` (``GET /user``) first — this works for
227+
PAT mode and (returns the bot user) for installation tokens too. On
228+
failure, fall back to ``apps.getAuthenticated`` (``GET /app``) and
229+
resolve the bot user via ``users.getByUsername`` (``GET /users/{slug}[bot]``).
230+
"""
231+
# Cache hit: already detected once, don't re-fetch per webhook. This is
232+
# the fast path that avoids taking the lock once detection has succeeded.
233+
if self._bot_user_id is not None:
234+
return
235+
236+
# Double-checked locking: concurrent webhooks must not all race to fetch
237+
# the (identical) bot identity. Serialize detection and re-check the
238+
# cache inside the lock so only the first caller hits the API.
239+
async with self._detect_lock:
240+
if self._bot_user_id is not None:
241+
return
242+
205243
try:
206-
user_data = await self._github_api_request("GET", "/user")
207-
self._bot_user_id = user_data.get("id")
244+
user = await self._github_api_request("GET", "/user", installation_id=installation_id)
245+
self._bot_user_id = user.get("id")
208246
self._logger.info(
209-
"GitHub auth completed",
247+
"GitHub bot user ID auto-detected",
210248
{
211249
"botUserId": self._bot_user_id,
212-
"login": user_data.get("login"),
250+
"login": user.get("login"),
213251
},
214252
)
253+
return
215254
except Exception as error:
216-
self._logger.warn("Could not fetch bot user ID", {"error": str(error)})
255+
self._logger.debug(
256+
"users.getAuthenticated failed; falling back to apps.getAuthenticated",
257+
{"error": str(error)},
258+
)
217259

218-
self._logger.info("GitHub adapter initialized")
260+
try:
261+
# For App-authenticated installation tokens, /user is not
262+
# available; use apps.getAuthenticated (GET /app, authenticated
263+
# with the App JWT) and resolve the bot user via
264+
# /users/{login}[bot].
265+
app = await self._github_api_request("GET", "/app", installation_id=installation_id)
266+
if app:
267+
login = f"{app['slug']}[bot]"
268+
bot_user = await self._github_api_request("GET", f"/users/{login}", installation_id=installation_id)
269+
self._bot_user_id = bot_user.get("id")
270+
self._logger.info(
271+
"GitHub bot user ID auto-detected via app slug",
272+
{
273+
"botUserId": self._bot_user_id,
274+
"login": login,
275+
},
276+
)
277+
except Exception as error:
278+
self._logger.warn("Could not auto-detect GitHub bot user ID", {"error": str(error)})
219279

220280
async def get_user(self, user_id: str) -> UserInfo | None:
221281
"""Look up a GitHub user by numeric account ID.
@@ -306,6 +366,13 @@ async def handle_webhook(self, request: Any, options: WebhookOptions | None = No
306366
if owner_login and repo_name:
307367
await self._store_installation_id(owner_login, repo_name, installation_id)
308368

369+
# Eagerly resolve the bot user ID before dispatching to handlers, so
370+
# is_me checks work on the very first webhook. Without this,
371+
# multi-tenant adapters can self-reply-loop until detection fires
372+
# lazily elsewhere. Cached after the first detection (once per process).
373+
if self._bot_user_id is None:
374+
await self._detect_bot_user_id(installation_id)
375+
309376
if event_type == "issue_comment":
310377
if payload.get("action") == "created":
311378
self._handle_issue_comment(payload, installation_id, options)
@@ -629,12 +696,10 @@ async def add_reaction(self, thread_id: str, message_id: str, emoji: EmojiValue
629696

630697
async def remove_reaction(self, thread_id: str, message_id: str, emoji: EmojiValue | str) -> None:
631698
"""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")
699+
# Multi-tenant mode has no fixed installation, so initialize() can't
700+
# detect _bot_user_id. Detect lazily (cached after first success).
701+
if self._bot_user_id is None:
702+
await self._detect_bot_user_id()
638703

639704
decoded = self.decode_thread_id(thread_id)
640705
comment_id = int(message_id)
@@ -1059,24 +1124,42 @@ async def _get_installation_token(self, installation_id: int) -> str:
10591124
)
10601125
return token
10611126

1062-
async def _github_api_request(self, method: str, path: str, body: Any = None) -> Any:
1127+
async def _github_api_request(
1128+
self, method: str, path: str, body: Any = None, *, installation_id: int | None = None
1129+
) -> Any:
10631130
"""Make a request to the GitHub API.
10641131
10651132
Supports PAT auth (``_auth_token``) and GitHub App auth
10661133
(``_app_credentials`` with JWT -> installation token exchange).
1134+
1135+
``installation_id`` overrides ``self._installation_id`` for App auth.
1136+
Multi-tenant mode has no fixed installation on the adapter (it is
1137+
extracted per-webhook), so callers operating inside a webhook context
1138+
pass the webhook's installation explicitly.
10671139
"""
10681140
auth_token = self._auth_token
10691141

1070-
# GitHub App auth: exchange JWT for installation token
1142+
# GitHub App auth.
10711143
if not auth_token and self._app_credentials:
1072-
installation_id = self._installation_id
1073-
if not installation_id:
1074-
raise RuntimeError(
1075-
"Installation ID required for GitHub App authentication. "
1076-
"This usually means you're trying to make an API call outside of a webhook context. "
1077-
"For proactive messages, use thread IDs from previous webhook interactions."
1078-
)
1079-
auth_token = await self._get_installation_token(installation_id)
1144+
if path == "/app":
1145+
# App-level endpoints (apps.getAuthenticated) REQUIRE app-JWT
1146+
# auth; installation tokens get 401/403 here. Authenticate with
1147+
# the App JWT directly instead of exchanging for an installation
1148+
# token. This is what makes bot-user-ID detection work in the
1149+
# GitHub-App/installation case (the /user path 403s on
1150+
# installation tokens, so this fallback must succeed).
1151+
auth_token = self._generate_app_jwt()
1152+
else:
1153+
# Installation-scoped endpoints: exchange the JWT for an
1154+
# installation token.
1155+
resolved_installation_id = installation_id if installation_id is not None else self._installation_id
1156+
if not resolved_installation_id:
1157+
raise RuntimeError(
1158+
"Installation ID required for GitHub App authentication. "
1159+
"This usually means you're trying to make an API call outside of a webhook context. "
1160+
"For proactive messages, use thread IDs from previous webhook interactions."
1161+
)
1162+
auth_token = await self._get_installation_token(resolved_installation_id)
10801163

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

0 commit comments

Comments
 (0)