typeclaw 0.3.0 → 0.4.0

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 (101) hide show
  1. package/README.md +20 -15
  2. package/auth.schema.json +113 -0
  3. package/package.json +2 -1
  4. package/scripts/dump-system-prompt.ts +401 -0
  5. package/secrets.schema.json +113 -0
  6. package/src/agent/index.ts +149 -30
  7. package/src/agent/provider-error.ts +44 -0
  8. package/src/agent/session-meta.ts +43 -0
  9. package/src/agent/session-origin.ts +3 -2
  10. package/src/agent/subagents.ts +8 -0
  11. package/src/agent/system-prompt.ts +70 -35
  12. package/src/bundled-plugins/security/index.ts +3 -2
  13. package/src/channels/adapters/github/auth-app.ts +120 -0
  14. package/src/channels/adapters/github/auth-pat.ts +50 -0
  15. package/src/channels/adapters/github/auth.ts +33 -0
  16. package/src/channels/adapters/github/channel-resolver.ts +30 -0
  17. package/src/channels/adapters/github/dedup.ts +26 -0
  18. package/src/channels/adapters/github/event-allowlist.ts +8 -0
  19. package/src/channels/adapters/github/fetch-attachment.ts +5 -0
  20. package/src/channels/adapters/github/history.ts +63 -0
  21. package/src/channels/adapters/github/inbound.ts +286 -0
  22. package/src/channels/adapters/github/index.ts +286 -0
  23. package/src/channels/adapters/github/managed-path.ts +54 -0
  24. package/src/channels/adapters/github/membership.ts +35 -0
  25. package/src/channels/adapters/github/outbound.ts +145 -0
  26. package/src/channels/adapters/github/webhook-register.ts +349 -0
  27. package/src/channels/manager.ts +94 -9
  28. package/src/channels/router.ts +28 -2
  29. package/src/channels/schema.ts +31 -1
  30. package/src/channels/tunnel-bridge.ts +51 -0
  31. package/src/cli/builtins.ts +28 -0
  32. package/src/cli/channel.ts +511 -25
  33. package/src/cli/container-command-client.ts +244 -0
  34. package/src/cli/cron.ts +173 -0
  35. package/src/cli/host-command-runner.ts +150 -0
  36. package/src/cli/index.ts +42 -1
  37. package/src/cli/init.ts +256 -27
  38. package/src/cli/model.ts +4 -2
  39. package/src/cli/plugin-command-help.ts +49 -0
  40. package/src/cli/plugin-commands-dispatch.ts +112 -0
  41. package/src/cli/plugin-commands.ts +118 -0
  42. package/src/cli/tui.ts +10 -2
  43. package/src/cli/tunnel.ts +533 -0
  44. package/src/cli/ui.ts +8 -3
  45. package/src/cli/usage.ts +30 -2
  46. package/src/config/config.ts +90 -4
  47. package/src/config/reloadable.ts +22 -4
  48. package/src/container/start.ts +30 -3
  49. package/src/cron/bridge.ts +136 -0
  50. package/src/cron/consumer.ts +62 -6
  51. package/src/cron/index.ts +19 -2
  52. package/src/cron/list.ts +105 -0
  53. package/src/cron/scheduler.ts +12 -3
  54. package/src/cron/schema.ts +11 -3
  55. package/src/doctor/checks.ts +0 -50
  56. package/src/init/dockerfile.ts +59 -13
  57. package/src/init/ensure-deps.ts +15 -4
  58. package/src/init/github-webhook-install.ts +109 -0
  59. package/src/init/index.ts +505 -9
  60. package/src/init/run-bun-install.ts +17 -3
  61. package/src/init/run-owner-claim.ts +11 -2
  62. package/src/permissions/builtins.ts +6 -1
  63. package/src/permissions/match-rule.ts +24 -2
  64. package/src/permissions/resolve.ts +1 -0
  65. package/src/plugin/define.ts +42 -1
  66. package/src/plugin/index.ts +18 -3
  67. package/src/plugin/manager.ts +2 -0
  68. package/src/plugin/registry.ts +85 -3
  69. package/src/plugin/types.ts +138 -1
  70. package/src/plugin/zod-introspect.ts +100 -0
  71. package/src/role-claim/match-rule.ts +2 -1
  72. package/src/run/index.ts +119 -4
  73. package/src/secrets/index.ts +1 -1
  74. package/src/secrets/schema.ts +21 -0
  75. package/src/server/command-runner.ts +476 -0
  76. package/src/server/index.ts +393 -15
  77. package/src/shared/index.ts +8 -0
  78. package/src/shared/protocol.ts +80 -1
  79. package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
  80. package/src/skills/typeclaw-config/SKILL.md +27 -26
  81. package/src/skills/typeclaw-cron/SKILL.md +234 -3
  82. package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
  83. package/src/skills/typeclaw-permissions/SKILL.md +5 -4
  84. package/src/skills/typeclaw-plugins/SKILL.md +251 -5
  85. package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
  86. package/src/test-helpers/wait-for.ts +50 -0
  87. package/src/tui/index.ts +35 -4
  88. package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
  89. package/src/tunnels/events.ts +14 -0
  90. package/src/tunnels/index.ts +12 -0
  91. package/src/tunnels/log-ring.ts +54 -0
  92. package/src/tunnels/manager.ts +139 -0
  93. package/src/tunnels/providers/cloudflare-quick.ts +189 -0
  94. package/src/tunnels/providers/external.ts +53 -0
  95. package/src/tunnels/quick-url-parser.ts +5 -0
  96. package/src/tunnels/types.ts +43 -0
  97. package/src/usage/aggregate.ts +30 -1
  98. package/src/usage/index.ts +3 -2
  99. package/src/usage/report.ts +103 -3
  100. package/src/usage/scan.ts +59 -4
  101. package/typeclaw.schema.json +254 -1
@@ -25,8 +25,9 @@ export { SECURITY_PERMISSIONS, type SecurityPermission } from './permissions'
25
25
  // it's the only carrier.
26
26
  const BYPASS_ROLE_HINT = {
27
27
  [SECURITY_PERMISSIONS.bypassSecretExfilBash]: 'owner and trusted have it by default',
28
- [SECURITY_PERMISSIONS.bypassGitExfil]: 'only owner has it by default',
29
- [SECURITY_PERMISSIONS.bypassGitRemoteTainted]: 'only owner has it by default',
28
+ [SECURITY_PERMISSIONS.bypassGitExfil]: 'owner and trusted have it by default',
29
+ [SECURITY_PERMISSIONS.bypassGitRemoteTainted]:
30
+ 'only owner has it by default (trusted intentionally does not, so the two-step taint defense still fires)',
30
31
  [SECURITY_PERMISSIONS.bypassSecretExfilRead]: 'only owner has it by default',
31
32
  [SECURITY_PERMISSIONS.bypassSsrf]: 'only owner has it by default',
32
33
  [SECURITY_PERMISSIONS.bypassSessionSearchSecrets]: 'only owner has it by default',
@@ -0,0 +1,120 @@
1
+ import { resolveSecret, type Secret } from '@/secrets/resolve'
2
+
3
+ import type { GithubAuthStrategy, GithubSelfUser } from './auth'
4
+ import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
5
+
6
+ export class AppAuthStrategy implements GithubAuthStrategy {
7
+ private readonly appId: number
8
+ private readonly privateKeyPem: string
9
+ private readonly installationId: number | null
10
+ private readonly fetchImpl: typeof fetch
11
+ private cachedToken: { value: string; expiresAt: number } | null = null
12
+ private resolvedInstallationId: number | null = null
13
+ private _selfUser: GithubSelfUser | null = null
14
+
15
+ constructor(options: { appId: number; privateKey: Secret; installationId?: number; fetchImpl?: typeof fetch }) {
16
+ const privateKeyPem = resolveSecret(options.privateKey, undefined, process.env)
17
+ if (privateKeyPem === undefined || privateKeyPem.trim() === '') throw new Error('GitHub App private key is missing')
18
+ this.appId = options.appId
19
+ this.privateKeyPem = privateKeyPem
20
+ this.installationId = options.installationId ?? null
21
+ this.fetchImpl = options.fetchImpl ?? fetch
22
+ }
23
+
24
+ async token(): Promise<string> {
25
+ if (this.cachedToken && Date.now() < this.cachedToken.expiresAt - 5 * 60 * 1000) {
26
+ return this.cachedToken.value
27
+ }
28
+ const jwt = await this.mintJwt()
29
+ const installId = await this.resolveInstallationId(jwt)
30
+ const response = await this.fetchImpl(`${GITHUB_API_BASE}/app/installations/${installId}/access_tokens`, {
31
+ method: 'POST',
32
+ headers: githubJsonHeaders(jwt),
33
+ })
34
+ if (!response.ok) throw new Error(`GitHub App token mint failed: ${response.status}`)
35
+ const raw = (await response.json()) as { token?: unknown; expires_at?: unknown }
36
+ if (typeof raw.token !== 'string') throw new Error('GitHub App token response missing token')
37
+ const expiresAt = typeof raw.expires_at === 'string' ? Date.parse(raw.expires_at) : Date.now() + 60 * 60 * 1000
38
+ this.cachedToken = { value: raw.token, expiresAt }
39
+ return raw.token
40
+ }
41
+
42
+ async authHeaders(): Promise<HeadersInit> {
43
+ return githubJsonHeaders(await this.token())
44
+ }
45
+
46
+ async getSelf(): Promise<GithubSelfUser> {
47
+ if (this._selfUser) return this._selfUser
48
+ const jwt = await this.mintJwt()
49
+ const appResponse = await this.fetchImpl(`${GITHUB_API_BASE}/app`, { headers: githubJsonHeaders(jwt) })
50
+ if (!appResponse.ok) throw new Error(`GitHub App preflight failed: ${appResponse.status}`)
51
+ const app = (await appResponse.json()) as { slug?: unknown }
52
+ if (typeof app.slug !== 'string') throw new Error('GitHub /app response missing slug')
53
+
54
+ const botLogin = `${app.slug}[bot]`
55
+ const userResponse = await this.fetchImpl(`${GITHUB_API_BASE}/users/${encodeURIComponent(botLogin)}`, {
56
+ headers: githubJsonHeaders(jwt),
57
+ })
58
+ if (!userResponse.ok) throw new Error(`GitHub bot user lookup failed: ${userResponse.status}`)
59
+ const user = (await userResponse.json()) as { id?: unknown; login?: unknown }
60
+ if (typeof user.id !== 'number' || typeof user.login !== 'string') {
61
+ throw new Error('GitHub bot user response missing id/login')
62
+ }
63
+ this._selfUser = { id: user.id, login: user.login }
64
+ return this._selfUser
65
+ }
66
+
67
+ async dispose(): Promise<void> {
68
+ this.cachedToken = null
69
+ }
70
+
71
+ private async mintJwt(): Promise<string> {
72
+ const now = Math.floor(Date.now() / 1000)
73
+ const iat = now - 60
74
+ const exp = iat + 600
75
+ const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))
76
+ const payload = base64url(JSON.stringify({ iat, exp, iss: this.appId }))
77
+ const signingInput = `${header}.${payload}`
78
+ const key = await importRsaPrivateKey(this.privateKeyPem)
79
+ const signature = await crypto.subtle.sign(
80
+ { name: 'RSASSA-PKCS1-v1_5' },
81
+ key,
82
+ new TextEncoder().encode(signingInput),
83
+ )
84
+ return `${signingInput}.${base64url(Buffer.from(signature))}`
85
+ }
86
+
87
+ private async resolveInstallationId(jwt: string): Promise<number> {
88
+ if (this.resolvedInstallationId !== null) return this.resolvedInstallationId
89
+ if (this.installationId !== null) {
90
+ this.resolvedInstallationId = this.installationId
91
+ return this.installationId
92
+ }
93
+ const response = await this.fetchImpl(`${GITHUB_API_BASE}/app/installations`, { headers: githubJsonHeaders(jwt) })
94
+ if (!response.ok) throw new Error(`GitHub App installations fetch failed: ${response.status}`)
95
+ const list = (await response.json()) as Array<{ id?: unknown }>
96
+ if (list.length === 0) throw new Error('GitHub App has no installations')
97
+ if (list.length > 1) {
98
+ const ids = list.map((installation) => installation.id).join(', ')
99
+ throw new Error(`GitHub App has multiple installations (${ids}); set installationId in secrets.json`)
100
+ }
101
+ const id = list[0]?.id
102
+ if (typeof id !== 'number') throw new Error('GitHub App installation missing id')
103
+ this.resolvedInstallationId = id
104
+ return id
105
+ }
106
+ }
107
+
108
+ function base64url(input: string | Buffer): string {
109
+ const buf = typeof input === 'string' ? Buffer.from(input) : input
110
+ return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
111
+ }
112
+
113
+ async function importRsaPrivateKey(pem: string): Promise<CryptoKey> {
114
+ const b64 = pem
115
+ .replace(/-----BEGIN [^-]+-----/, '')
116
+ .replace(/-----END [^-]+-----/, '')
117
+ .replace(/\s/g, '')
118
+ const der = Buffer.from(b64, 'base64')
119
+ return await crypto.subtle.importKey('pkcs8', der, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign'])
120
+ }
@@ -0,0 +1,50 @@
1
+ import { resolveSecret, type Secret } from '@/secrets/resolve'
2
+
3
+ import type { GithubAuthStrategy, GithubSelfUser } from './auth'
4
+
5
+ export const GITHUB_API_BASE = 'https://api.github.com'
6
+
7
+ export class PatAuthStrategy implements GithubAuthStrategy {
8
+ private readonly _token: string
9
+ private readonly fetchImpl: typeof fetch
10
+
11
+ constructor(options: { token: Secret; fetchImpl?: typeof fetch }) {
12
+ const token = resolveSecret(options.token, undefined, process.env)
13
+ if (token === undefined || token.trim() === '') throw new Error('GitHub PAT token is missing')
14
+ this._token = token
15
+ this.fetchImpl = options.fetchImpl ?? fetch
16
+ }
17
+
18
+ async token(): Promise<string> {
19
+ return this._token
20
+ }
21
+
22
+ async authHeaders(): Promise<HeadersInit> {
23
+ return githubJsonHeaders(this._token)
24
+ }
25
+
26
+ async getSelf(): Promise<GithubSelfUser> {
27
+ const response = await this.fetchImpl(`${GITHUB_API_BASE}/user`, { headers: await this.authHeaders() })
28
+ if (!response.ok) {
29
+ const body = await response.text().catch(() => '')
30
+ throw new Error(`GitHub PAT authentication failed: ${response.status}${body !== '' ? ` ${body}` : ''}`)
31
+ }
32
+ const raw = (await response.json()) as { login?: unknown; id?: unknown }
33
+ if (typeof raw.login !== 'string' || typeof raw.id !== 'number') {
34
+ throw new Error('GitHub /user response did not include login/id')
35
+ }
36
+ return { login: raw.login, id: raw.id }
37
+ }
38
+
39
+ async dispose(): Promise<void> {}
40
+ }
41
+
42
+ export function githubJsonHeaders(token: string): HeadersInit {
43
+ return {
44
+ Authorization: `Bearer ${token}`,
45
+ Accept: 'application/vnd.github+json',
46
+ 'Content-Type': 'application/json',
47
+ 'X-GitHub-Api-Version': '2022-11-28',
48
+ 'User-Agent': 'typeclaw-github-channel',
49
+ }
50
+ }
@@ -0,0 +1,33 @@
1
+ import type { GithubAppAuthBlock, GithubPatAuthBlock } from '@/secrets/schema'
2
+
3
+ import { AppAuthStrategy } from './auth-app'
4
+ import { PatAuthStrategy } from './auth-pat'
5
+
6
+ export type GithubAuthStrategy = {
7
+ token: () => Promise<string>
8
+ authHeaders: () => Promise<HeadersInit>
9
+ getSelf: () => Promise<GithubSelfUser>
10
+ dispose: () => Promise<void>
11
+ }
12
+
13
+ export type GithubSelfUser = {
14
+ login: string
15
+ id: number
16
+ }
17
+
18
+ export function buildAuthStrategy(options: {
19
+ auth: GithubPatAuthBlock | GithubAppAuthBlock
20
+ fetchImpl?: typeof fetch
21
+ }): GithubAuthStrategy {
22
+ switch (options.auth.type) {
23
+ case 'pat':
24
+ return new PatAuthStrategy({ token: options.auth.token, fetchImpl: options.fetchImpl })
25
+ case 'app':
26
+ return new AppAuthStrategy({
27
+ appId: options.auth.appId,
28
+ privateKey: options.auth.privateKey,
29
+ installationId: options.auth.installationId,
30
+ fetchImpl: options.fetchImpl,
31
+ })
32
+ }
33
+ }
@@ -0,0 +1,30 @@
1
+ import type { ChannelNameResolver, ResolvedChannelNames } from '@/channels/types'
2
+
3
+ import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
4
+ import { parseChat, parseRepo } from './outbound'
5
+
6
+ export function createGithubChannelNameResolver(options: {
7
+ token: () => Promise<string>
8
+ fetchImpl?: typeof fetch
9
+ }): ChannelNameResolver {
10
+ const fetchImpl = options.fetchImpl ?? fetch
11
+ return async (key): Promise<ResolvedChannelNames> => {
12
+ if (key.adapter !== 'github') return {}
13
+ const repo = parseRepo(key.workspace)
14
+ const chat = parseChat(key.chat)
15
+ if (repo === null || chat === null) return {}
16
+ const names: ResolvedChannelNames = { workspaceName: key.workspace }
17
+ if (chat.kind === 'discussion') return names
18
+ const path = chat.kind === 'issue' ? `issues/${chat.number}` : `pulls/${chat.number}`
19
+ try {
20
+ const response = await fetchImpl(`${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/${path}`, {
21
+ headers: githubJsonHeaders(await options.token()),
22
+ })
23
+ if (!response.ok) return names
24
+ const raw = (await response.json()) as { title?: string }
25
+ return raw.title !== undefined ? { ...names, chatName: raw.title } : names
26
+ } catch {
27
+ return names
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,26 @@
1
+ export type DeliveryDedup = {
2
+ has: (deliveryId: string) => boolean
3
+ add: (deliveryId: string) => void
4
+ size: () => number
5
+ }
6
+
7
+ export function createDeliveryDedup(limit = 1000): DeliveryDedup {
8
+ const seen = new Map<string, true>()
9
+ return {
10
+ has(deliveryId: string): boolean {
11
+ return seen.has(deliveryId)
12
+ },
13
+ add(deliveryId: string): void {
14
+ if (seen.has(deliveryId)) seen.delete(deliveryId)
15
+ seen.set(deliveryId, true)
16
+ while (seen.size > limit) {
17
+ const oldest = seen.keys().next().value
18
+ if (oldest === undefined) break
19
+ seen.delete(oldest)
20
+ }
21
+ },
22
+ size(): number {
23
+ return seen.size
24
+ },
25
+ }
26
+ }
@@ -0,0 +1,8 @@
1
+ export function githubEventKey(event: string, action: unknown): string {
2
+ return typeof action === 'string' && action.length > 0 ? `${event}.${action}` : event
3
+ }
4
+
5
+ export function isGithubEventAllowed(allowlist: readonly string[], event: string, action: unknown): boolean {
6
+ const key = githubEventKey(event, action)
7
+ return allowlist.includes(key) || allowlist.includes(event)
8
+ }
@@ -0,0 +1,5 @@
1
+ import type { FetchAttachmentCallback } from '@/channels/types'
2
+
3
+ export function createGithubFetchAttachmentCallback(): FetchAttachmentCallback {
4
+ return async () => ({ ok: false, error: 'github-bot-does-not-support-attachments' })
5
+ }
@@ -0,0 +1,63 @@
1
+ import type { ChannelHistoryMessage, FetchHistoryArgs, FetchHistoryResult, HistoryCallback } from '@/channels/types'
2
+
3
+ import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
4
+ import { parseChat, parseRepo } from './outbound'
5
+
6
+ export function createGithubHistoryCallback(options: {
7
+ token: () => Promise<string>
8
+ workspaceForChat: (chat: string) => string | null
9
+ fetchImpl?: typeof fetch
10
+ }): HistoryCallback {
11
+ const fetchImpl = options.fetchImpl ?? fetch
12
+ return async (args: FetchHistoryArgs): Promise<FetchHistoryResult> => {
13
+ const workspace = options.workspaceForChat(args.chat)
14
+ if (workspace === null)
15
+ return { ok: false, error: 'github history unavailable until this chat receives an inbound' }
16
+ const repo = parseRepo(workspace)
17
+ const chat = parseChat(args.chat)
18
+ if (repo === null || chat === null) return { ok: false, error: 'invalid github history target' }
19
+ if (chat.kind === 'discussion') return { ok: false, error: 'github discussion history not supported yet' }
20
+ const endpoint =
21
+ chat.kind === 'pr' && args.thread !== null
22
+ ? `${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/pulls/${chat.number}/comments`
23
+ : `${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/issues/${chat.number}/comments`
24
+ try {
25
+ const cursor = args.cursor !== undefined && args.cursor !== '' ? `&page=${encodeURIComponent(args.cursor)}` : ''
26
+ const response = await fetchImpl(
27
+ `${endpoint}?per_page=${Math.min(Math.max(args.limit, 1), 100)}&direction=desc${cursor}`,
28
+ {
29
+ headers: githubJsonHeaders(await options.token()),
30
+ },
31
+ )
32
+ if (!response.ok) return { ok: false, error: `GitHub history ${response.status}` }
33
+ const raw = (await response.json()) as GithubComment[]
34
+ const link = response.headers.get('link') ?? ''
35
+ const nextCursor = /[?&]page=(\d+)[^>]*>; rel="next"/.exec(link)?.[1]
36
+ return nextCursor !== undefined
37
+ ? { ok: true, messages: raw.map(mapComment), nextCursor }
38
+ : { ok: true, messages: raw.map(mapComment) }
39
+ } catch (err) {
40
+ return { ok: false, error: err instanceof Error ? err.message : String(err) }
41
+ }
42
+ }
43
+ }
44
+
45
+ type GithubComment = {
46
+ id: number
47
+ body?: string
48
+ created_at?: string
49
+ user?: { id?: number; login?: string; type?: string }
50
+ }
51
+
52
+ function mapComment(comment: GithubComment): ChannelHistoryMessage {
53
+ const login = comment.user?.login ?? 'unknown'
54
+ return {
55
+ externalMessageId: String(comment.id),
56
+ authorId: String(comment.user?.id ?? login),
57
+ authorName: login,
58
+ text: comment.body ?? '',
59
+ ts: comment.created_at !== undefined ? Date.parse(comment.created_at) || 0 : 0,
60
+ isBot: comment.user?.type === 'Bot',
61
+ replyToBotMessageId: null,
62
+ }
63
+ }
@@ -0,0 +1,286 @@
1
+ import { createHmac, timingSafeEqual } from 'node:crypto'
2
+
3
+ import type { InboundMessage } from '@/channels/types'
4
+
5
+ import type { DeliveryDedup } from './dedup'
6
+ import { isGithubEventAllowed } from './event-allowlist'
7
+
8
+ export type GithubInboundLogger = { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void }
9
+
10
+ export type GithubWebhookHandlerOptions = {
11
+ webhookSecret: string
12
+ dedup: DeliveryDedup
13
+ allowlist: () => readonly string[]
14
+ selfId: () => string | null
15
+ selfLogin: () => string | null
16
+ route: (message: InboundMessage) => void
17
+ logger: GithubInboundLogger
18
+ }
19
+
20
+ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions): (req: Request) => Promise<Response> {
21
+ return async (req: Request): Promise<Response> => {
22
+ if (req.method !== 'POST') return new Response('method not allowed', { status: 405 })
23
+ const body = await req.text()
24
+ const signature = req.headers.get('x-hub-signature-256') ?? ''
25
+ if (!(await verifySignature(body, options.webhookSecret, signature))) {
26
+ options.logger.warn('[github] webhook rejected: bad signature')
27
+ return new Response('bad signature', { status: 401 })
28
+ }
29
+
30
+ const delivery = req.headers.get('x-github-delivery') ?? ''
31
+ if (delivery !== '' && options.dedup.has(delivery)) {
32
+ options.logger.info(`[github] duplicate delivery ignored id=${delivery}`)
33
+ return ok()
34
+ }
35
+
36
+ const event = req.headers.get('x-github-event') ?? ''
37
+ const payload = parseJson(body)
38
+ if (payload === null) return ok()
39
+ const action = readString(payload, 'action')
40
+ if (!isGithubEventAllowed(options.allowlist(), event, action)) return ok()
41
+
42
+ const selfId = options.selfId()
43
+ const author = readAuthor(payload)
44
+ if (selfId !== null && author !== null && String(author.id) === selfId) return ok()
45
+
46
+ const classified = classifyGithubInbound(event, payload, options.selfLogin())
47
+ if (classified === null) return ok()
48
+
49
+ if (delivery !== '') options.dedup.add(delivery)
50
+ options.route(classified)
51
+ return ok()
52
+ }
53
+ }
54
+
55
+ export async function verifySignature(body: string, secret: string, sigHeader: string): Promise<boolean> {
56
+ const expected = `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`
57
+ const a = Buffer.from(expected)
58
+ const b = Buffer.from(sigHeader)
59
+ if (a.length !== b.length) return false
60
+ return timingSafeEqual(a, b)
61
+ }
62
+
63
+ export function classifyGithubInbound(
64
+ event: string,
65
+ payload: Record<string, unknown>,
66
+ selfLogin: string | null,
67
+ ): InboundMessage | null {
68
+ const repository = readRepository(payload)
69
+ if (repository === null) return null
70
+ const base = {
71
+ adapter: 'github' as const,
72
+ workspace: `${repository.owner}/${repository.name}`,
73
+ isDm: false,
74
+ mentionsOthers: false,
75
+ replyToOtherMessageId: null,
76
+ }
77
+
78
+ if (event === 'issue_comment') {
79
+ const issue = readRecord(payload.issue)
80
+ const comment = readRecord(payload.comment)
81
+ if (issue === null || comment === null) return null
82
+ const number = readNumber(issue, 'number')
83
+ const id = readNumber(comment, 'id')
84
+ if (number === null || id === null) return null
85
+ const isPullRequest = readRecord(issue.pull_request) !== null
86
+ const user = readUser(comment.user)
87
+ return buildInbound(
88
+ { ...base, chat: `${isPullRequest ? 'pr' : 'issue'}:${number}`, thread: null },
89
+ comment.body,
90
+ id,
91
+ user,
92
+ selfLogin,
93
+ comment.created_at,
94
+ )
95
+ }
96
+
97
+ if (event === 'pull_request_review_comment') {
98
+ const pr = readRecord(payload.pull_request)
99
+ const comment = readRecord(payload.comment)
100
+ if (pr === null || comment === null) return null
101
+ const number = readNumber(pr, 'number')
102
+ const id = readNumber(comment, 'id')
103
+ if (number === null || id === null) return null
104
+ const root = readNumber(comment, 'in_reply_to_id') ?? id
105
+ return buildInbound(
106
+ { ...base, chat: `pr:${number}`, thread: String(root) },
107
+ comment.body,
108
+ id,
109
+ readUser(comment.user),
110
+ selfLogin,
111
+ comment.created_at,
112
+ )
113
+ }
114
+
115
+ if (event === 'discussion_comment') {
116
+ const discussion = readRecord(payload.discussion)
117
+ const comment = readRecord(payload.comment)
118
+ if (discussion === null || comment === null) return null
119
+ const number = readNumber(discussion, 'number')
120
+ const id = readNumber(comment, 'id')
121
+ if (number === null || id === null) return null
122
+ return buildInbound(
123
+ { ...base, chat: `discussion:${number}`, thread: null },
124
+ comment.body,
125
+ id,
126
+ readUser(comment.user),
127
+ selfLogin,
128
+ comment.created_at,
129
+ )
130
+ }
131
+
132
+ if (event === 'issues') {
133
+ const issue = readRecord(payload.issue)
134
+ if (issue === null) return null
135
+ const number = readNumber(issue, 'number')
136
+ const id = readNumber(issue, 'id') ?? number
137
+ if (number === null || id === null) return null
138
+ return buildInbound(
139
+ { ...base, chat: `issue:${number}`, thread: null },
140
+ issue.body,
141
+ id,
142
+ readUser(issue.user),
143
+ selfLogin,
144
+ issue.created_at,
145
+ )
146
+ }
147
+
148
+ if (event === 'pull_request') {
149
+ const pr = readRecord(payload.pull_request)
150
+ if (pr === null) return null
151
+ const number = readNumber(pr, 'number')
152
+ const id = readNumber(pr, 'id') ?? number
153
+ if (number === null || id === null) return null
154
+ return buildInbound(
155
+ { ...base, chat: `pr:${number}`, thread: null },
156
+ pr.body,
157
+ id,
158
+ readUser(pr.user),
159
+ selfLogin,
160
+ pr.created_at,
161
+ )
162
+ }
163
+
164
+ if (event === 'pull_request_review') {
165
+ const pr = readRecord(payload.pull_request)
166
+ const review = readRecord(payload.review)
167
+ if (pr === null || review === null) return null
168
+ const number = readNumber(pr, 'number')
169
+ const id = readNumber(review, 'id')
170
+ if (number === null || id === null) return null
171
+ return buildInbound(
172
+ { ...base, chat: `pr:${number}`, thread: null },
173
+ review.body,
174
+ id,
175
+ readUser(review.user),
176
+ selfLogin,
177
+ review.submitted_at,
178
+ )
179
+ }
180
+
181
+ if (event === 'discussion') {
182
+ const discussion = readRecord(payload.discussion)
183
+ if (discussion === null) return null
184
+ const number = readNumber(discussion, 'number')
185
+ const id = readNumber(discussion, 'id') ?? number
186
+ if (number === null || id === null) return null
187
+ return buildInbound(
188
+ { ...base, chat: `discussion:${number}`, thread: null },
189
+ discussion.body,
190
+ id,
191
+ readUser(discussion.user),
192
+ selfLogin,
193
+ discussion.created_at,
194
+ )
195
+ }
196
+
197
+ return null
198
+ }
199
+
200
+ function buildInbound(
201
+ key: Pick<
202
+ InboundMessage,
203
+ 'adapter' | 'workspace' | 'chat' | 'thread' | 'isDm' | 'mentionsOthers' | 'replyToOtherMessageId'
204
+ >,
205
+ rawText: unknown,
206
+ id: number,
207
+ user: GithubUser | null,
208
+ selfLogin: string | null,
209
+ rawTs: unknown,
210
+ ): InboundMessage | null {
211
+ if (user === null) return null
212
+ const text = typeof rawText === 'string' ? rawText : ''
213
+ return {
214
+ ...key,
215
+ text,
216
+ externalMessageId: String(id),
217
+ authorId: String(user.id),
218
+ authorName: user.login,
219
+ authorIsBot: user.type === 'Bot',
220
+ isBotMention: selfLogin !== null && text.includes(`@${selfLogin}`),
221
+ replyToBotMessageId: null,
222
+ ts: typeof rawTs === 'string' ? Date.parse(rawTs) || 0 : 0,
223
+ }
224
+ }
225
+
226
+ function readRepository(payload: Record<string, unknown>): { owner: string; name: string } | null {
227
+ const repository = readRecord(payload.repository)
228
+ const owner = readRecord(repository?.owner)
229
+ const ownerLogin = readString(owner, 'login')
230
+ const name = readString(repository, 'name')
231
+ if (ownerLogin === null || name === null) return null
232
+ return { owner: ownerLogin, name }
233
+ }
234
+
235
+ function readAuthor(payload: Record<string, unknown>): GithubUser | null {
236
+ const candidates = [payload.comment, payload.issue, payload.pull_request, payload.discussion, payload.review]
237
+ for (const candidate of candidates) {
238
+ const user = readUser(readRecord(candidate)?.user)
239
+ if (user !== null) return user
240
+ }
241
+ return null
242
+ }
243
+
244
+ type GithubUser = { login: string; id: number; type?: string }
245
+
246
+ function readUser(value: unknown): GithubUser | null {
247
+ const user = readRecord(value)
248
+ const login = readString(user, 'login')
249
+ const id = readNumber(user, 'id')
250
+ if (login === null || id === null) return null
251
+ const type = readString(user, 'type') ?? undefined
252
+ return { login, id, ...(type !== undefined ? { type } : {}) }
253
+ }
254
+
255
+ function parseJson(body: string): Record<string, unknown> | null {
256
+ try {
257
+ const parsed = JSON.parse(body) as unknown
258
+ return readRecord(parsed)
259
+ } catch {
260
+ return null
261
+ }
262
+ }
263
+
264
+ function readRecord(value: unknown): Record<string, unknown> | null {
265
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
266
+ ? (value as Record<string, unknown>)
267
+ : null
268
+ }
269
+
270
+ function readString(obj: Record<string, unknown> | null, key: string): string | null {
271
+ const value = obj?.[key]
272
+ return typeof value === 'string' ? value : null
273
+ }
274
+
275
+ function readNumber(obj: Record<string, unknown> | null, key: string): number | null {
276
+ const value = obj?.[key]
277
+ return typeof value === 'number' && Number.isFinite(value) ? value : null
278
+ }
279
+
280
+ function ok(): Response {
281
+ return new Response('ok', { status: 200 })
282
+ }
283
+
284
+ function describe(err: unknown): string {
285
+ return err instanceof Error ? err.message : String(err)
286
+ }