typeclaw 0.10.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +5 -1
  2. package/package.json +1 -1
  3. package/src/agent/index.ts +37 -4
  4. package/src/agent/multimodal/look-at.ts +8 -0
  5. package/src/agent/restart-handoff/index.ts +91 -0
  6. package/src/agent/restart-handoff/paths.ts +11 -0
  7. package/src/agent/session-origin.ts +30 -10
  8. package/src/agent/subagent-completion-reminder.ts +4 -2
  9. package/src/agent/system-prompt.ts +3 -1
  10. package/src/agent/tools/restart.ts +42 -1
  11. package/src/agent/tools/skip-response.ts +157 -0
  12. package/src/bundled-plugins/memory/README.md +18 -2
  13. package/src/bundled-plugins/memory/index.ts +108 -6
  14. package/src/bundled-plugins/memory/memory-logger.ts +33 -24
  15. package/src/bundled-plugins/security/policies/prompt-injection.ts +1 -1
  16. package/src/channels/adapters/discord-bot-invite.ts +89 -0
  17. package/src/channels/adapters/github/auth-app.ts +53 -9
  18. package/src/channels/adapters/github/auth-pat.ts +4 -1
  19. package/src/channels/adapters/github/auth.ts +10 -0
  20. package/src/channels/adapters/github/event-permissions.ts +83 -0
  21. package/src/channels/adapters/github/inbound.ts +126 -1
  22. package/src/channels/adapters/github/index.ts +60 -66
  23. package/src/channels/adapters/github/outbound.ts +65 -17
  24. package/src/channels/adapters/github/permission-guidance.ts +169 -0
  25. package/src/channels/adapters/github/team-membership.ts +56 -0
  26. package/src/channels/adapters/kakaotalk-classify.ts +13 -1
  27. package/src/channels/adapters/kakaotalk.ts +2 -0
  28. package/src/channels/router.ts +269 -34
  29. package/src/channels/schema.ts +8 -7
  30. package/src/channels/types.ts +1 -1
  31. package/src/cli/channel.ts +138 -52
  32. package/src/cli/init.ts +139 -100
  33. package/src/cli/inspect-controller.ts +66 -0
  34. package/src/cli/inspect.ts +24 -32
  35. package/src/cli/prompt-pem.ts +113 -0
  36. package/src/cli/run.ts +24 -5
  37. package/src/cli/tui.ts +34 -10
  38. package/src/cli/tunnel.ts +453 -14
  39. package/src/cli/ui.ts +22 -0
  40. package/src/compose/discover.ts +5 -0
  41. package/src/config/config.ts +35 -7
  42. package/src/config/providers.ts +64 -56
  43. package/src/init/env-file.ts +66 -0
  44. package/src/init/hatching.ts +32 -5
  45. package/src/init/index.ts +131 -39
  46. package/src/init/validate-api-key.ts +31 -0
  47. package/src/inspect/index.ts +5 -1
  48. package/src/inspect/loop.ts +12 -1
  49. package/src/inspect/replay.ts +15 -1
  50. package/src/run/codex-fetch-observer.ts +377 -0
  51. package/src/run/index.ts +14 -2
  52. package/src/server/command-runner.ts +31 -2
  53. package/src/server/index.ts +59 -1
  54. package/src/shared/protocol.ts +1 -1
  55. package/src/skills/typeclaw-channel-github/SKILL.md +47 -1
  56. package/src/skills/typeclaw-tunnels/SKILL.md +33 -1
  57. package/src/tui/index.ts +17 -5
  58. package/src/tunnels/index.ts +1 -0
  59. package/src/tunnels/manager.ts +18 -0
  60. package/src/tunnels/providers/cloudflare-named.ts +224 -0
  61. package/src/tunnels/types.ts +17 -1
  62. package/typeclaw.schema.json +25 -7
@@ -0,0 +1,83 @@
1
+ // Maps a GitHub webhook event (in the form used in typeclaw.json#channels.github.eventAllowlist,
2
+ // e.g. "issue_comment.created" or just "issues") to the GitHub App "Repository permissions"
3
+ // key that gates BOTH receiving payload fields AND posting replies for that event family.
4
+ //
5
+ // Source: https://docs.github.com/en/webhooks/webhook-events-and-payloads (each event page
6
+ // links to the App permission it requires).
7
+ //
8
+ // The permission key on the LEFT is what github.com calls the permission in the App settings UI
9
+ // ("Issues", "Pull requests", "Discussions"); the value on the RIGHT is the snake_case key that
10
+ // appears in the `permissions` object on GET /app/installations/{id} responses. They MUST match
11
+ // the strings GitHub actually emits — these are checked at runtime against an installation grant
12
+ // map, not normalised.
13
+ export const EVENT_PERMISSION_KEY: Record<string, string> = {
14
+ issues: 'issues',
15
+ issue_comment: 'issues',
16
+ pull_request: 'pull_requests',
17
+ pull_request_review: 'pull_requests',
18
+ pull_request_review_comment: 'pull_requests',
19
+ pull_request_review_thread: 'pull_requests',
20
+ discussion: 'discussions',
21
+ discussion_comment: 'discussions',
22
+ commit_comment: 'contents',
23
+ push: 'contents',
24
+ }
25
+
26
+ // Human-readable label for each App permission key, mirroring github.com's
27
+ // "Repository permissions" section verbatim. Used in the preflight warning so
28
+ // users can grep for the exact string on the App settings page.
29
+ export const PERMISSION_UI_LABEL: Record<string, string> = {
30
+ issues: 'Issues',
31
+ pull_requests: 'Pull requests',
32
+ discussions: 'Discussions',
33
+ contents: 'Contents',
34
+ metadata: 'Metadata',
35
+ }
36
+
37
+ export type GrantLevel = 'read' | 'write' | 'admin'
38
+
39
+ // Accepts both the dotted form ("issues.opened", as used in
40
+ // typeclaw.json#channels.github.eventAllowlist) and the bare event family
41
+ // ("issues", as used in webhook event-header names).
42
+ export function permissionKeyForEvent(event: string): string | null {
43
+ const family = event.includes('.') ? event.slice(0, event.indexOf('.')) : event
44
+ return EVENT_PERMISSION_KEY[family] ?? null
45
+ }
46
+
47
+ export type PermissionGap = {
48
+ permissionKey: string
49
+ uiLabel: string
50
+ granted: GrantLevel | null
51
+ events: string[]
52
+ needsWrite: boolean
53
+ }
54
+
55
+ // Unknown allowlist items are silently ignored — forward-compat for events
56
+ // typeclaw doesn't yet know about. `needsWrite` is hardcoded true because
57
+ // channel_reply is today's only canonical exit; flip to a per-event flag the
58
+ // day a read-only github channel becomes a supported use case.
59
+ export function findPermissionGaps(
60
+ eventAllowlist: readonly string[],
61
+ installationPermissions: Readonly<Record<string, GrantLevel>>,
62
+ ): PermissionGap[] {
63
+ const eventsByKey = new Map<string, Set<string>>()
64
+ for (const event of eventAllowlist) {
65
+ const key = permissionKeyForEvent(event)
66
+ if (key === null) continue
67
+ if (!eventsByKey.has(key)) eventsByKey.set(key, new Set())
68
+ eventsByKey.get(key)?.add(event)
69
+ }
70
+ const gaps: PermissionGap[] = []
71
+ for (const [permissionKey, events] of eventsByKey) {
72
+ const granted = installationPermissions[permissionKey] ?? null
73
+ if (granted === 'write' || granted === 'admin') continue
74
+ gaps.push({
75
+ permissionKey,
76
+ uiLabel: PERMISSION_UI_LABEL[permissionKey] ?? permissionKey,
77
+ granted,
78
+ events: [...events].sort(),
79
+ needsWrite: true,
80
+ })
81
+ }
82
+ return gaps.sort((a, b) => a.permissionKey.localeCompare(b.permissionKey))
83
+ }
@@ -15,6 +15,10 @@ export type GithubWebhookHandlerOptions = {
15
15
  selfLogin: () => string | null
16
16
  route: (message: InboundMessage) => void
17
17
  logger: GithubInboundLogger
18
+ // Optional: resolves whether the bot is a member of the given team. When
19
+ // omitted, team-reviewer requests are silently dropped (the v1 fallback
20
+ // behavior). The adapter wires this in production; tests inject a fake.
21
+ isBotInTeam?: (input: { org: string; slug: string; login: string }) => Promise<boolean>
18
22
  }
19
23
 
20
24
  export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions): (req: Request) => Promise<Response> {
@@ -43,7 +47,10 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
43
47
  const author = readAuthor(payload)
44
48
  if (selfId !== null && author !== null && String(author.id) === selfId) return ok()
45
49
 
46
- const classified = classifyGithubInbound(event, payload, options.selfLogin())
50
+ const teamIsBotMember = await resolveTeamMembership(event, payload, options)
51
+ const classified = classifyGithubInbound(event, payload, options.selfLogin(), {
52
+ teamIsBotMember,
53
+ })
47
54
  if (classified === null) return ok()
48
55
 
49
56
  if (delivery !== '') options.dedup.add(delivery)
@@ -64,6 +71,7 @@ export function classifyGithubInbound(
64
71
  event: string,
65
72
  payload: Record<string, unknown>,
66
73
  selfLogin: string | null,
74
+ options?: { teamIsBotMember?: boolean },
67
75
  ): InboundMessage | null {
68
76
  const repository = readRepository(payload)
69
77
  if (repository === null) return null
@@ -151,6 +159,18 @@ export function classifyGithubInbound(
151
159
  const number = readNumber(pr, 'number')
152
160
  const id = readNumber(pr, 'id') ?? number
153
161
  if (number === null || id === null) return null
162
+ const action = readString(payload, 'action')
163
+ if (action === 'review_requested' || action === 'review_request_removed') {
164
+ return classifyReviewRequest({
165
+ action,
166
+ payload,
167
+ pr,
168
+ number,
169
+ base,
170
+ selfLogin,
171
+ teamIsBotMember: options?.teamIsBotMember,
172
+ })
173
+ }
154
174
  return buildInbound(
155
175
  { ...base, chat: `pr:${number}`, thread: null },
156
176
  pr.body,
@@ -197,6 +217,85 @@ export function classifyGithubInbound(
197
217
  return null
198
218
  }
199
219
 
220
+ type ReviewRequestInput = {
221
+ action: 'review_requested' | 'review_request_removed'
222
+ payload: Record<string, unknown>
223
+ pr: Record<string, unknown>
224
+ number: number
225
+ base: Pick<InboundMessage, 'adapter' | 'workspace' | 'isDm' | 'mentionsOthers' | 'replyToOtherMessageId'>
226
+ selfLogin: string | null
227
+ teamIsBotMember: boolean | undefined
228
+ }
229
+
230
+ function classifyReviewRequest(input: ReviewRequestInput): InboundMessage | null {
231
+ const { action, payload, pr, number, base, selfLogin, teamIsBotMember } = input
232
+ if (selfLogin === null) return null
233
+ const sender = readUser(payload.sender)
234
+ if (sender === null) return null
235
+ // Self-loop guard: if the bot itself requested (or un-requested) the
236
+ // review, drop the event. The bot adding itself as a reviewer would
237
+ // otherwise wake a fresh session every time it self-assigns.
238
+ if (sender.login === selfLogin) return null
239
+
240
+ const requestedUser = readUser(payload.requested_reviewer)
241
+ const requestedTeam = readReviewerTeam(payload.requested_team)
242
+
243
+ const isMeAsUser = requestedUser !== null && requestedUser.login === selfLogin
244
+ const isMyTeam = requestedTeam !== null && teamIsBotMember === true
245
+ if (!isMeAsUser && !isMyTeam) return null
246
+
247
+ const title = readString(pr, 'title') ?? `#${number}`
248
+ const head = readString(readRecord(pr.head), 'ref')
249
+ const baseRef = readString(readRecord(pr.base), 'ref')
250
+ const branchSegment = head !== null && baseRef !== null ? ` Branch: ${head} → ${baseRef}.` : ''
251
+ const verbed =
252
+ action === 'review_requested'
253
+ ? isMyTeam
254
+ ? `requested a review from team @${requestedTeam?.slug} (you're a member of) on PR #${number}: "${title}".`
255
+ : `requested your review on PR #${number}: "${title}".`
256
+ : isMyTeam
257
+ ? `removed the review request for team @${requestedTeam?.slug} on PR #${number}: "${title}".`
258
+ : `removed your review request on PR #${number}: "${title}".`
259
+ const closing =
260
+ action === 'review_requested'
261
+ ? ' Please review the changes line-by-line and post your feedback.'
262
+ : ' You can stop any in-progress review.'
263
+ const text = `@${sender.login} ${verbed}${branchSegment}${closing}`
264
+
265
+ // Synthesize a stable per-event externalMessageId. The PR's `updated_at`
266
+ // changes on every review-request mutation, so combining it with the PR id
267
+ // and the action keeps separate "requested → removed → requested again"
268
+ // events from collapsing into one dedup'd id.
269
+ const updatedAt = readString(pr, 'updated_at') ?? ''
270
+ const prId = readNumber(pr, 'id') ?? number
271
+ const externalMessageId = `pr-${prId}-${action}-${updatedAt}`
272
+
273
+ return {
274
+ ...base,
275
+ chat: `pr:${number}`,
276
+ thread: null,
277
+ text,
278
+ externalMessageId,
279
+ authorId: String(sender.id),
280
+ authorName: sender.login,
281
+ authorIsBot: sender.type === 'Bot',
282
+ isBotMention: true,
283
+ replyToBotMessageId: null,
284
+ ts: updatedAt !== '' ? Date.parse(updatedAt) || 0 : 0,
285
+ }
286
+ }
287
+
288
+ export type GithubReviewerTeam = { slug: string; id: number; org: string | null }
289
+
290
+ export function readReviewerTeam(value: unknown): GithubReviewerTeam | null {
291
+ const team = readRecord(value)
292
+ const slug = readString(team, 'slug')
293
+ const id = readNumber(team, 'id')
294
+ if (slug === null || id === null) return null
295
+ const org = readString(readRecord(team?.organization), 'login')
296
+ return { slug, id, org }
297
+ }
298
+
200
299
  function buildInbound(
201
300
  key: Pick<
202
301
  InboundMessage,
@@ -223,6 +322,32 @@ function buildInbound(
223
322
  }
224
323
  }
225
324
 
325
+ async function resolveTeamMembership(
326
+ event: string,
327
+ payload: Record<string, unknown>,
328
+ options: GithubWebhookHandlerOptions,
329
+ ): Promise<boolean | undefined> {
330
+ if (event !== 'pull_request') return undefined
331
+ const action = readString(payload, 'action')
332
+ if (action !== 'review_requested' && action !== 'review_request_removed') return undefined
333
+ const team = readReviewerTeam(payload.requested_team)
334
+ if (team === null) return undefined
335
+ const selfLogin = options.selfLogin()
336
+ if (selfLogin === null) return false
337
+ if (options.isBotInTeam === undefined) return false
338
+ // The team payload sometimes omits `organization.login`. Fall back to the
339
+ // repository owner, which is the only org GitHub can legally route team
340
+ // reviewers from on a given PR.
341
+ const org = team.org ?? readRepository(payload)?.owner ?? null
342
+ if (org === null) return false
343
+ try {
344
+ return await options.isBotInTeam({ org, slug: team.slug, login: selfLogin })
345
+ } catch (err) {
346
+ options.logger.warn(`[github] team membership lookup failed: ${describe(err)}`)
347
+ return false
348
+ }
349
+ }
350
+
226
351
  function readRepository(payload: Record<string, unknown>): { owner: string; name: string } | null {
227
352
  const repository = readRecord(payload.repository)
228
353
  const owner = readRecord(repository?.owner)
@@ -6,12 +6,19 @@ import type { GithubSecretsBlock } from '@/secrets/schema'
6
6
  import { buildAuthStrategy } from './auth'
7
7
  import { createGithubChannelNameResolver } from './channel-resolver'
8
8
  import { createDeliveryDedup } from './dedup'
9
+ import { findPermissionGaps } from './event-permissions'
9
10
  import { createGithubFetchAttachmentCallback } from './fetch-attachment'
10
11
  import { createGithubHistoryCallback } from './history'
11
12
  import { createGithubWebhookHandler } from './inbound'
12
13
  import { applyManagedPath, buildManagedPath, resolveAgentId } from './managed-path'
13
14
  import { createGithubMembershipResolver } from './membership'
14
15
  import { createGithubOutboundCallback } from './outbound'
16
+ import {
17
+ buildAppPermissionPreflightGuidance,
18
+ buildPermissionGuidance,
19
+ parseListHooksPermissionStatus,
20
+ } from './permission-guidance'
21
+ import { createTeamMembershipChecker } from './team-membership'
15
22
  import { deregisterGithubWebhooks, registerGithubWebhooks, type WebhookRegistrationResult } from './webhook-register'
16
23
 
17
24
  export type GithubAdapterLogger = {
@@ -35,6 +42,17 @@ export type GithubAdapterOptions = {
35
42
  // wrong)" so the skip-registration log can be precise and actionable.
36
43
  // Optional so tests that don't exercise the tunnel-status path can omit it.
37
44
  tunnelConfiguredForChannel?: () => boolean
45
+ // Sleep between learning the public webhook URL and telling GitHub about
46
+ // it. cloudflared prints the trycloudflare.com URL as soon as the control
47
+ // connection comes up, but the Cloudflare edge needs a beat to start
48
+ // routing traffic for that hostname. If we register with GitHub the
49
+ // instant we know the URL, GitHub's automatic `ping` delivery races the
50
+ // edge and lands "failed to connect to host". 2s is enough on every
51
+ // network we've tested; tests pass 0 to skip.
52
+ webhookRegistrationDelayMs?: number
53
+ // Test-only: replaces the wall-clock sleep used for the registration
54
+ // delay above. Production leaves it undefined and we use `setTimeout`.
55
+ sleep?: (ms: number) => Promise<void>
38
56
  }
39
57
 
40
58
  export type GithubAdapter = {
@@ -49,9 +67,13 @@ const consoleLogger: GithubAdapterLogger = {
49
67
  error: (m) => console.error(m),
50
68
  }
51
69
 
70
+ const DEFAULT_WEBHOOK_REGISTRATION_DELAY_MS = 2_000
71
+
52
72
  export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapter {
53
73
  const logger = options.logger ?? consoleLogger
54
74
  const fetchImpl = options.fetchImpl ?? fetch
75
+ const webhookRegistrationDelayMs = options.webhookRegistrationDelayMs ?? DEFAULT_WEBHOOK_REGISTRATION_DELAY_MS
76
+ const sleep = options.sleep ?? defaultSleep
55
77
  const auth = buildAuthStrategy({ auth: options.secrets.auth, fetchImpl })
56
78
  const webhookSecret = resolveSecret(options.secrets.webhookSecret, undefined, process.env)
57
79
  if (webhookSecret === undefined || webhookSecret.trim() === '') throw new Error('GitHub webhookSecret is missing')
@@ -72,7 +94,12 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
72
94
  process.env.GH_TOKEN = t
73
95
  return t
74
96
  }
75
- const outbound = createGithubOutboundCallback({ token: tokenFn, logger, fetchImpl })
97
+ const outbound = createGithubOutboundCallback({
98
+ token: tokenFn,
99
+ authType: options.secrets.auth.type,
100
+ logger,
101
+ fetchImpl,
102
+ })
76
103
  const history = createGithubHistoryCallback({
77
104
  token: tokenFn,
78
105
  fetchImpl,
@@ -84,12 +111,14 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
84
111
  // No-op typing callback: GitHub has no typing indicator API.
85
112
  const typing = async (): Promise<void> => {}
86
113
  const dedup = createDeliveryDedup()
114
+ const isBotInTeam = createTeamMembershipChecker({ token: tokenFn, fetchImpl })
87
115
  const handler = createGithubWebhookHandler({
88
116
  webhookSecret,
89
117
  dedup,
90
118
  allowlist: () => options.configRef().eventAllowlist,
91
119
  selfId: () => selfId,
92
120
  selfLogin: () => selfLogin,
121
+ isBotInTeam,
93
122
  logger,
94
123
  route: (message) => {
95
124
  rememberWorkspace(message.workspace, message.chat)
@@ -140,6 +169,11 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
140
169
  process.env.GH_TOKEN = await auth.token()
141
170
  started = true
142
171
  logger.info(`[github] webhook listening on port ${options.configRef().webhookPort} as @${self.login}`)
172
+ // Best-effort: App-only preflight that compares the installation's granted
173
+ // permissions against the configured eventAllowlist and warns about gaps.
174
+ // Catches the most common misconfiguration (App installed with the default
175
+ // metadata-only permission set) before any event fires a 403.
176
+ await runAppPermissionPreflight(logger, auth, options.configRef().eventAllowlist)
143
177
  // Repository webhook registration is best-effort: failures are logged
144
178
  // per-repo, the adapter stays up. A misconfigured PAT or App that
145
179
  // can't manage hooks must not prevent the adapter from accepting
@@ -162,6 +196,12 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
162
196
  })
163
197
  } else if (repos.length > 0) {
164
198
  const legacyProviderHostSuffix = detectLegacyProviderHostSuffix(effectiveUrl)
199
+ if (webhookRegistrationDelayMs > 0) {
200
+ logger.info(
201
+ `[github] waiting ${webhookRegistrationDelayMs}ms before registering webhook so the Cloudflare edge can warm up`,
202
+ )
203
+ await sleep(webhookRegistrationDelayMs)
204
+ }
165
205
  const registration = await registerGithubWebhooks({
166
206
  token: tokenFn,
167
207
  webhookUrl: effectiveUrl,
@@ -290,72 +330,22 @@ function logRegistrationOutcome(
290
330
  }
291
331
  }
292
332
 
293
- // Parses webhook-register errors of the shape `list hooks failed: <status> <body>`.
294
- // Returns the status code when it matches the two shapes GitHub emits for
295
- // missing access on the list-hooks endpoint:
296
- // - 404 Not Found: the token cannot see the repo at all (private repo
297
- // gated behind missing repository access — GitHub returns 404 instead of
298
- // 403 to avoid leaking the existence of private repos).
299
- // - 403 Forbidden: the token sees the repo but lacks webhook-management
300
- // permission, OR is blocked by an org SSO/SAML authorization gate.
301
- // Returns null for any other error (network, malformed slug, create-hook
302
- // failures, etc.) so the guidance only fires on the actual symptom.
303
- export function parseListHooksPermissionStatus(error: string): number | null {
304
- const match = error.match(/^list hooks failed: (404|403)\b/)
305
- if (match === null) return null
306
- return Number(match[1])
307
- }
308
-
309
- // The labels below intentionally mirror github.com's current UI verbatim so a
310
- // user can grep their settings page for the exact string. If GitHub renames
311
- // any of these in a future redesign, update both here and the
312
- // `permissionGuidance` tests in lifecycle.test.ts.
313
- //
314
- // Fine-grained PAT:
315
- // Settings → Developer settings → Personal access tokens → Fine-grained tokens
316
- // "Resource owner", "Repository access", "Repository permissions" → "Webhooks" → "Read and write", "Metadata" → "Read-only"
317
- // GitHub App:
318
- // Settings → Developer settings → GitHub Apps → <app> → Permissions & events
319
- // "Repository permissions" → "Webhooks" → "Read and write"
320
- // Install/configure on the org: <app settings> → Install App / Configure → "Repository access"
321
- // Classic PAT (legacy, still supported by GitHub but we don't surface it in
322
- // channel-add prompts):
323
- // Settings → Developer settings → Personal access tokens (classic)
324
- // Scope: "admin:repo_hook" (or full "repo" for private repositories)
325
- export function buildPermissionGuidance(
326
- authType: 'pat' | 'app',
327
- failures: ReadonlyArray<{ repo: string; status: number }>,
328
- ): string {
329
- const repoList = failures.map((f) => `${f.repo} (${f.status})`).join(', ')
330
- const lines: string[] = [
331
- `[github] webhook setup needs more access for: ${repoList}.`,
332
- ' - 404 from GitHub means the token cannot see the repo (GitHub hides private repos behind 404 instead of 403).',
333
- ' - 403 means the token sees the repo but lacks webhook permission, or is blocked by org SAML/SSO.',
334
- '',
335
- ]
336
- if (authType === 'pat') {
337
- lines.push(
338
- ' Fix (fine-grained personal access token):',
339
- ' 1. Open https://github.com/settings/personal-access-tokens and edit the token TypeClaw is using.',
340
- ' 2. Under "Resource owner", select the org that owns the failing repos (e.g. the org in the slug above).',
341
- ' 3. Under "Repository access", choose "Only select repositories" and add every failing repo (or pick "All repositories").',
342
- ' 4. Under "Repository permissions", set "Webhooks" to "Read and write" and "Metadata" to "Read-only".',
343
- ' 5. Save. If the org enforces SAML SSO, click "Configure SSO" next to the token and authorize the org.',
344
- '',
345
- ' Or (classic personal access token): grant the "admin:repo_hook" scope (or "repo" for private repos),',
346
- ' and on a SAML-protected org click "Authorize" next to the token.',
347
- )
348
- } else {
349
- lines.push(
350
- ' Fix (GitHub App):',
351
- ' 1. Open https://github.com/settings/apps and edit the app TypeClaw is using.',
352
- ' 2. Under "Permissions & events" → "Repository permissions", set "Webhooks" to "Read and write". Save.',
353
- ' 3. From the app page, click "Install App" (or "Configure" if already installed) and select the org that owns the failing repos.',
354
- ' 4. Under "Repository access", choose "Only select repositories" and add every failing repo (or pick "All repositories").',
355
- ' 5. If the app permissions changed in step 2, install owners must accept the updated permissions from the install page before the new access takes effect.',
356
- )
333
+ async function runAppPermissionPreflight(
334
+ logger: GithubAdapterLogger,
335
+ auth: ReturnType<typeof buildAuthStrategy>,
336
+ eventAllowlist: readonly string[],
337
+ ): Promise<void> {
338
+ if (auth.getInstallationGrants === undefined) return
339
+ let grants
340
+ try {
341
+ grants = await auth.getInstallationGrants()
342
+ } catch (err) {
343
+ logger.warn(`[github] permission preflight skipped: ${err instanceof Error ? err.message : String(err)}`)
344
+ return
357
345
  }
358
- return lines.join('\n')
346
+ const gaps = findPermissionGaps(eventAllowlist, grants.permissions)
347
+ if (gaps.length === 0) return
348
+ logger.warn(buildAppPermissionPreflightGuidance(gaps))
359
349
  }
360
350
 
361
351
  function logDeregistrationOutcome(
@@ -368,3 +358,7 @@ function logDeregistrationOutcome(
368
358
  else logger.warn(`[github] webhook detach failed for ${h.repo}#${h.hookId}: ${h.error ?? 'unknown error'}`)
369
359
  }
370
360
  }
361
+
362
+ function defaultSleep(ms: number): Promise<void> {
363
+ return new Promise((resolve) => setTimeout(resolve, ms))
364
+ }
@@ -1,11 +1,18 @@
1
1
  import type { OutboundCallback, OutboundMessage, SendResult } from '@/channels/types'
2
2
 
3
3
  import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
4
+ import {
5
+ buildOutboundPermissionGuidance,
6
+ type GithubAuthType,
7
+ isOutboundPermissionDenial,
8
+ type OutboundEndpointKind,
9
+ } from './permission-guidance'
4
10
 
5
11
  export type GithubOutboundLogger = { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void }
6
12
 
7
13
  export function createGithubOutboundCallback(deps: {
8
14
  token: () => Promise<string>
15
+ authType: GithubAuthType
9
16
  logger: GithubOutboundLogger
10
17
  fetchImpl?: typeof fetch
11
18
  }): OutboundCallback {
@@ -22,19 +29,35 @@ export function createGithubOutboundCallback(deps: {
22
29
  if (target === null) return { ok: false, error: `invalid GitHub chat: ${msg.chat}` }
23
30
 
24
31
  if (target.kind === 'discussion') {
25
- return await postDiscussionComment({ ...deps, fetchImpl, repo, discussionNumber: target.number, body })
32
+ return await postDiscussionComment({
33
+ ...deps,
34
+ fetchImpl,
35
+ repo,
36
+ discussionNumber: target.number,
37
+ body,
38
+ })
26
39
  }
27
40
 
28
- const endpoint =
29
- target.kind === 'pr' && msg.thread !== null && msg.thread !== undefined && msg.thread !== ''
30
- ? `${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/pulls/${target.number}/comments/${encodeURIComponent(msg.thread)}/replies`
31
- : `${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/issues/${target.number}/comments`
32
- return await postJson(fetchImpl, await deps.token(), endpoint, { body })
41
+ const isPrReviewReply = target.kind === 'pr' && msg.thread !== null && msg.thread !== undefined && msg.thread !== ''
42
+ const endpoint = isPrReviewReply
43
+ ? `${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/pulls/${target.number}/comments/${encodeURIComponent(msg.thread ?? '')}/replies`
44
+ : `${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/issues/${target.number}/comments`
45
+ return await postJson(
46
+ fetchImpl,
47
+ await deps.token(),
48
+ endpoint,
49
+ { body },
50
+ {
51
+ authType: deps.authType,
52
+ endpointKind: isPrReviewReply ? 'pr-review-reply' : 'issue-comment',
53
+ },
54
+ )
33
55
  }
34
56
  }
35
57
 
36
58
  async function postDiscussionComment(options: {
37
59
  token: () => Promise<string>
60
+ authType: GithubAuthType
38
61
  fetchImpl: typeof fetch
39
62
  repo: RepoRef
40
63
  discussionNumber: number
@@ -43,14 +66,21 @@ async function postDiscussionComment(options: {
43
66
  const discussionId = await fetchDiscussionId(options)
44
67
  if (!discussionId.ok) return discussionId
45
68
  const mutation = `mutation($discussionId:ID!,$body:String!){addDiscussionComment(input:{discussionId:$discussionId,body:$body}){comment{id}}}`
46
- return await postGraphql(options.fetchImpl, await options.token(), mutation, {
47
- discussionId: discussionId.id,
48
- body: options.body,
49
- })
69
+ return await postGraphql(
70
+ options.fetchImpl,
71
+ await options.token(),
72
+ mutation,
73
+ {
74
+ discussionId: discussionId.id,
75
+ body: options.body,
76
+ },
77
+ { authType: options.authType, endpointKind: 'discussion-comment' },
78
+ )
50
79
  }
51
80
 
52
81
  async function fetchDiscussionId(options: {
53
82
  token: () => Promise<string>
83
+ authType: GithubAuthType
54
84
  fetchImpl: typeof fetch
55
85
  repo: RepoRef
56
86
  discussionNumber: number
@@ -65,6 +95,7 @@ async function fetchDiscussionId(options: {
65
95
  name: options.repo.name,
66
96
  number: options.discussionNumber,
67
97
  },
98
+ { authType: options.authType, endpointKind: 'discussion-comment' },
68
99
  )
69
100
  if (!result.ok) return result
70
101
  const id = result.data.repository?.discussion?.id
@@ -76,8 +107,9 @@ async function postGraphql(
76
107
  token: string,
77
108
  query: string,
78
109
  variables: Record<string, unknown>,
110
+ guidance: { authType: GithubAuthType; endpointKind: OutboundEndpointKind },
79
111
  ): Promise<SendResult> {
80
- const result = await graphql(fetchImpl, token, query, variables)
112
+ const result = await graphql(fetchImpl, token, query, variables, guidance)
81
113
  return result.ok ? { ok: true } : { ok: false, error: result.error }
82
114
  }
83
115
 
@@ -86,6 +118,7 @@ async function graphql<T>(
86
118
  token: string,
87
119
  query: string,
88
120
  variables: Record<string, unknown>,
121
+ guidance: { authType: GithubAuthType; endpointKind: OutboundEndpointKind },
89
122
  ): Promise<{ ok: true; data: T } | { ok: false; error: string }> {
90
123
  try {
91
124
  const response = await fetchImpl(`${GITHUB_API_BASE}/graphql`, {
@@ -95,10 +128,15 @@ async function graphql<T>(
95
128
  })
96
129
  const raw = (await response.json()) as { data?: T; errors?: Array<{ message?: string }> }
97
130
  if (!response.ok || raw.errors !== undefined) {
98
- return {
99
- ok: false,
100
- error: raw.errors?.map((e) => e.message ?? 'unknown').join('; ') ?? `HTTP ${response.status}`,
101
- }
131
+ // GraphQL errors carry a permission-denial in their `errors[].type` =
132
+ // 'FORBIDDEN' or message text. Match on either the HTTP 403 (rare for
133
+ // GraphQL) or the literal denial string in any error message.
134
+ const message = raw.errors?.map((e) => e.message ?? 'unknown').join('; ') ?? `HTTP ${response.status}`
135
+ const baseError = response.ok ? message : `GitHub API ${response.status}: ${message}`
136
+ const decorated = isOutboundPermissionDenial(response.ok ? 403 : response.status, message)
137
+ ? `${baseError}${buildOutboundPermissionGuidance(guidance)}`
138
+ : baseError
139
+ return { ok: false, error: decorated }
102
140
  }
103
141
  if (raw.data === undefined) return { ok: false, error: 'GraphQL response missing data' }
104
142
  return { ok: true, data: raw.data }
@@ -107,7 +145,13 @@ async function graphql<T>(
107
145
  }
108
146
  }
109
147
 
110
- async function postJson(fetchImpl: typeof fetch, token: string, url: string, payload: unknown): Promise<SendResult> {
148
+ async function postJson(
149
+ fetchImpl: typeof fetch,
150
+ token: string,
151
+ url: string,
152
+ payload: unknown,
153
+ guidance: { authType: GithubAuthType; endpointKind: OutboundEndpointKind },
154
+ ): Promise<SendResult> {
111
155
  try {
112
156
  const response = await fetchImpl(url, {
113
157
  method: 'POST',
@@ -116,7 +160,11 @@ async function postJson(fetchImpl: typeof fetch, token: string, url: string, pay
116
160
  })
117
161
  if (response.ok) return { ok: true }
118
162
  const text = await response.text().catch(() => '')
119
- return { ok: false, error: `GitHub API ${response.status}${text !== '' ? `: ${text}` : ''}` }
163
+ const baseError = `GitHub API ${response.status}${text !== '' ? `: ${text}` : ''}`
164
+ const decorated = isOutboundPermissionDenial(response.status, text)
165
+ ? `${baseError}${buildOutboundPermissionGuidance(guidance)}`
166
+ : baseError
167
+ return { ok: false, error: decorated }
120
168
  } catch (err) {
121
169
  return { ok: false, error: describe(err) }
122
170
  }