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.
- package/README.md +5 -1
- package/package.json +1 -1
- package/src/agent/index.ts +37 -4
- package/src/agent/multimodal/look-at.ts +8 -0
- package/src/agent/restart-handoff/index.ts +91 -0
- package/src/agent/restart-handoff/paths.ts +11 -0
- package/src/agent/session-origin.ts +30 -10
- package/src/agent/subagent-completion-reminder.ts +4 -2
- package/src/agent/system-prompt.ts +3 -1
- package/src/agent/tools/restart.ts +42 -1
- package/src/agent/tools/skip-response.ts +157 -0
- package/src/bundled-plugins/memory/README.md +18 -2
- package/src/bundled-plugins/memory/index.ts +108 -6
- package/src/bundled-plugins/memory/memory-logger.ts +33 -24
- package/src/bundled-plugins/security/policies/prompt-injection.ts +1 -1
- package/src/channels/adapters/discord-bot-invite.ts +89 -0
- package/src/channels/adapters/github/auth-app.ts +53 -9
- package/src/channels/adapters/github/auth-pat.ts +4 -1
- package/src/channels/adapters/github/auth.ts +10 -0
- package/src/channels/adapters/github/event-permissions.ts +83 -0
- package/src/channels/adapters/github/inbound.ts +126 -1
- package/src/channels/adapters/github/index.ts +60 -66
- package/src/channels/adapters/github/outbound.ts +65 -17
- package/src/channels/adapters/github/permission-guidance.ts +169 -0
- package/src/channels/adapters/github/team-membership.ts +56 -0
- package/src/channels/adapters/kakaotalk-classify.ts +13 -1
- package/src/channels/adapters/kakaotalk.ts +2 -0
- package/src/channels/router.ts +269 -34
- package/src/channels/schema.ts +8 -7
- package/src/channels/types.ts +1 -1
- package/src/cli/channel.ts +138 -52
- package/src/cli/init.ts +139 -100
- package/src/cli/inspect-controller.ts +66 -0
- package/src/cli/inspect.ts +24 -32
- package/src/cli/prompt-pem.ts +113 -0
- package/src/cli/run.ts +24 -5
- package/src/cli/tui.ts +34 -10
- package/src/cli/tunnel.ts +453 -14
- package/src/cli/ui.ts +22 -0
- package/src/compose/discover.ts +5 -0
- package/src/config/config.ts +35 -7
- package/src/config/providers.ts +64 -56
- package/src/init/env-file.ts +66 -0
- package/src/init/hatching.ts +32 -5
- package/src/init/index.ts +131 -39
- package/src/init/validate-api-key.ts +31 -0
- package/src/inspect/index.ts +5 -1
- package/src/inspect/loop.ts +12 -1
- package/src/inspect/replay.ts +15 -1
- package/src/run/codex-fetch-observer.ts +377 -0
- package/src/run/index.ts +14 -2
- package/src/server/command-runner.ts +31 -2
- package/src/server/index.ts +59 -1
- package/src/shared/protocol.ts +1 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +47 -1
- package/src/skills/typeclaw-tunnels/SKILL.md +33 -1
- package/src/tui/index.ts +17 -5
- package/src/tunnels/index.ts +1 -0
- package/src/tunnels/manager.ts +18 -0
- package/src/tunnels/providers/cloudflare-named.ts +224 -0
- package/src/tunnels/types.ts +17 -1
- 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
|
|
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({
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
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
|
-
|
|
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({
|
|
32
|
+
return await postDiscussionComment({
|
|
33
|
+
...deps,
|
|
34
|
+
fetchImpl,
|
|
35
|
+
repo,
|
|
36
|
+
discussionNumber: target.number,
|
|
37
|
+
body,
|
|
38
|
+
})
|
|
26
39
|
}
|
|
27
40
|
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
return await postJson(
|
|
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(
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
}
|