typeclaw 0.3.1 → 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 (89) hide show
  1. package/README.md +20 -15
  2. package/auth.schema.json +113 -0
  3. package/package.json +1 -1
  4. package/secrets.schema.json +113 -0
  5. package/src/agent/session-meta.ts +1 -1
  6. package/src/agent/session-origin.ts +3 -2
  7. package/src/bundled-plugins/security/index.ts +3 -2
  8. package/src/channels/adapters/github/auth-app.ts +120 -0
  9. package/src/channels/adapters/github/auth-pat.ts +50 -0
  10. package/src/channels/adapters/github/auth.ts +33 -0
  11. package/src/channels/adapters/github/channel-resolver.ts +30 -0
  12. package/src/channels/adapters/github/dedup.ts +26 -0
  13. package/src/channels/adapters/github/event-allowlist.ts +8 -0
  14. package/src/channels/adapters/github/fetch-attachment.ts +5 -0
  15. package/src/channels/adapters/github/history.ts +63 -0
  16. package/src/channels/adapters/github/inbound.ts +286 -0
  17. package/src/channels/adapters/github/index.ts +286 -0
  18. package/src/channels/adapters/github/managed-path.ts +54 -0
  19. package/src/channels/adapters/github/membership.ts +35 -0
  20. package/src/channels/adapters/github/outbound.ts +145 -0
  21. package/src/channels/adapters/github/webhook-register.ts +349 -0
  22. package/src/channels/manager.ts +94 -9
  23. package/src/channels/schema.ts +31 -1
  24. package/src/channels/tunnel-bridge.ts +51 -0
  25. package/src/cli/builtins.ts +28 -0
  26. package/src/cli/channel.ts +511 -25
  27. package/src/cli/container-command-client.ts +244 -0
  28. package/src/cli/cron.ts +173 -0
  29. package/src/cli/host-command-runner.ts +150 -0
  30. package/src/cli/index.ts +42 -1
  31. package/src/cli/init.ts +256 -27
  32. package/src/cli/model.ts +4 -2
  33. package/src/cli/plugin-command-help.ts +49 -0
  34. package/src/cli/plugin-commands-dispatch.ts +112 -0
  35. package/src/cli/plugin-commands.ts +118 -0
  36. package/src/cli/tui.ts +10 -2
  37. package/src/cli/tunnel.ts +533 -0
  38. package/src/cli/ui.ts +8 -3
  39. package/src/config/config.ts +75 -0
  40. package/src/container/start.ts +30 -3
  41. package/src/cron/bridge.ts +136 -0
  42. package/src/cron/consumer.ts +45 -5
  43. package/src/cron/index.ts +19 -2
  44. package/src/cron/list.ts +105 -0
  45. package/src/cron/scheduler.ts +12 -3
  46. package/src/cron/schema.ts +11 -3
  47. package/src/doctor/checks.ts +0 -50
  48. package/src/init/dockerfile.ts +59 -13
  49. package/src/init/ensure-deps.ts +15 -4
  50. package/src/init/github-webhook-install.ts +109 -0
  51. package/src/init/index.ts +505 -9
  52. package/src/init/run-bun-install.ts +17 -3
  53. package/src/init/run-owner-claim.ts +11 -2
  54. package/src/permissions/builtins.ts +6 -1
  55. package/src/permissions/match-rule.ts +24 -2
  56. package/src/permissions/resolve.ts +1 -0
  57. package/src/plugin/define.ts +42 -1
  58. package/src/plugin/index.ts +18 -3
  59. package/src/plugin/manager.ts +2 -0
  60. package/src/plugin/registry.ts +85 -3
  61. package/src/plugin/types.ts +138 -1
  62. package/src/plugin/zod-introspect.ts +100 -0
  63. package/src/role-claim/match-rule.ts +2 -1
  64. package/src/run/index.ts +110 -3
  65. package/src/secrets/index.ts +1 -1
  66. package/src/secrets/schema.ts +21 -0
  67. package/src/server/command-runner.ts +476 -0
  68. package/src/server/index.ts +388 -5
  69. package/src/shared/index.ts +8 -0
  70. package/src/shared/protocol.ts +80 -1
  71. package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
  72. package/src/skills/typeclaw-config/SKILL.md +27 -26
  73. package/src/skills/typeclaw-cron/SKILL.md +234 -3
  74. package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
  75. package/src/skills/typeclaw-permissions/SKILL.md +5 -4
  76. package/src/skills/typeclaw-plugins/SKILL.md +251 -5
  77. package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
  78. package/src/test-helpers/wait-for.ts +50 -0
  79. package/src/tui/index.ts +35 -4
  80. package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
  81. package/src/tunnels/events.ts +14 -0
  82. package/src/tunnels/index.ts +12 -0
  83. package/src/tunnels/log-ring.ts +54 -0
  84. package/src/tunnels/manager.ts +139 -0
  85. package/src/tunnels/providers/cloudflare-quick.ts +189 -0
  86. package/src/tunnels/providers/external.ts +53 -0
  87. package/src/tunnels/quick-url-parser.ts +5 -0
  88. package/src/tunnels/types.ts +43 -0
  89. package/typeclaw.schema.json +254 -1
@@ -0,0 +1,35 @@
1
+ import type { MembershipResolver, MembershipResolverResult } from '@/channels/membership'
2
+
3
+ import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
4
+ import { parseRepo } from './outbound'
5
+
6
+ export function createGithubMembershipResolver(options: {
7
+ token: () => Promise<string>
8
+ fetchImpl?: typeof fetch
9
+ }): MembershipResolver {
10
+ const fetchImpl = options.fetchImpl ?? fetch
11
+ return async (key): Promise<MembershipResolverResult> => {
12
+ if (key.adapter !== 'github') return { kind: 'permanent' }
13
+ const repo = parseRepo(key.workspace)
14
+ if (repo === null) return { kind: 'permanent' }
15
+ try {
16
+ const response = await fetchImpl(
17
+ `${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/collaborators?per_page=100`,
18
+ {
19
+ headers: githubJsonHeaders(await options.token()),
20
+ },
21
+ )
22
+ if (!response.ok) return response.status >= 500 ? { kind: 'transient' } : { kind: 'permanent' }
23
+ const users = (await response.json()) as Array<{ type?: string }>
24
+ let bots = 0
25
+ let humans = 0
26
+ for (const user of users) {
27
+ if (user.type === 'Bot') bots++
28
+ else humans++
29
+ }
30
+ return { humans, bots, fetchedAt: Date.now(), truncated: users.length >= 100 }
31
+ } catch {
32
+ return { kind: 'transient' }
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,145 @@
1
+ import type { OutboundCallback, OutboundMessage, SendResult } from '@/channels/types'
2
+
3
+ import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
4
+
5
+ export type GithubOutboundLogger = { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void }
6
+
7
+ export function createGithubOutboundCallback(deps: {
8
+ token: () => Promise<string>
9
+ logger: GithubOutboundLogger
10
+ fetchImpl?: typeof fetch
11
+ }): OutboundCallback {
12
+ const fetchImpl = deps.fetchImpl ?? fetch
13
+ return async (msg: OutboundMessage): Promise<SendResult> => {
14
+ if (msg.adapter !== 'github') return { ok: false, error: `unknown adapter: ${msg.adapter}` }
15
+ if ((msg.attachments ?? []).length > 0) return { ok: false, error: 'github-bot-does-not-support-attachments' }
16
+ const body = msg.text ?? ''
17
+ if (body === '') return { ok: false, error: 'message has neither text nor attachments' }
18
+
19
+ const repo = parseRepo(msg.workspace)
20
+ if (repo === null) return { ok: false, error: `invalid GitHub workspace: ${msg.workspace}` }
21
+ const target = parseChat(msg.chat)
22
+ if (target === null) return { ok: false, error: `invalid GitHub chat: ${msg.chat}` }
23
+
24
+ if (target.kind === 'discussion') {
25
+ return await postDiscussionComment({ ...deps, fetchImpl, repo, discussionNumber: target.number, body })
26
+ }
27
+
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 })
33
+ }
34
+ }
35
+
36
+ async function postDiscussionComment(options: {
37
+ token: () => Promise<string>
38
+ fetchImpl: typeof fetch
39
+ repo: RepoRef
40
+ discussionNumber: number
41
+ body: string
42
+ }): Promise<SendResult> {
43
+ const discussionId = await fetchDiscussionId(options)
44
+ if (!discussionId.ok) return discussionId
45
+ 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
+ })
50
+ }
51
+
52
+ async function fetchDiscussionId(options: {
53
+ token: () => Promise<string>
54
+ fetchImpl: typeof fetch
55
+ repo: RepoRef
56
+ discussionNumber: number
57
+ }): Promise<{ ok: true; id: string } | { ok: false; error: string }> {
58
+ const query = `query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){discussion(number:$number){id}}}`
59
+ const result = await graphql<{ repository?: { discussion?: { id?: string } | null } }>(
60
+ options.fetchImpl,
61
+ await options.token(),
62
+ query,
63
+ {
64
+ owner: options.repo.owner,
65
+ name: options.repo.name,
66
+ number: options.discussionNumber,
67
+ },
68
+ )
69
+ if (!result.ok) return result
70
+ const id = result.data.repository?.discussion?.id
71
+ return typeof id === 'string' && id !== '' ? { ok: true, id } : { ok: false, error: 'discussion not found' }
72
+ }
73
+
74
+ async function postGraphql(
75
+ fetchImpl: typeof fetch,
76
+ token: string,
77
+ query: string,
78
+ variables: Record<string, unknown>,
79
+ ): Promise<SendResult> {
80
+ const result = await graphql(fetchImpl, token, query, variables)
81
+ return result.ok ? { ok: true } : { ok: false, error: result.error }
82
+ }
83
+
84
+ async function graphql<T>(
85
+ fetchImpl: typeof fetch,
86
+ token: string,
87
+ query: string,
88
+ variables: Record<string, unknown>,
89
+ ): Promise<{ ok: true; data: T } | { ok: false; error: string }> {
90
+ try {
91
+ const response = await fetchImpl(`${GITHUB_API_BASE}/graphql`, {
92
+ method: 'POST',
93
+ headers: githubJsonHeaders(token),
94
+ body: JSON.stringify({ query, variables }),
95
+ })
96
+ const raw = (await response.json()) as { data?: T; errors?: Array<{ message?: string }> }
97
+ 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
+ }
102
+ }
103
+ if (raw.data === undefined) return { ok: false, error: 'GraphQL response missing data' }
104
+ return { ok: true, data: raw.data }
105
+ } catch (err) {
106
+ return { ok: false, error: describe(err) }
107
+ }
108
+ }
109
+
110
+ async function postJson(fetchImpl: typeof fetch, token: string, url: string, payload: unknown): Promise<SendResult> {
111
+ try {
112
+ const response = await fetchImpl(url, {
113
+ method: 'POST',
114
+ headers: githubJsonHeaders(token),
115
+ body: JSON.stringify(payload),
116
+ })
117
+ if (response.ok) return { ok: true }
118
+ const text = await response.text().catch(() => '')
119
+ return { ok: false, error: `GitHub API ${response.status}${text !== '' ? `: ${text}` : ''}` }
120
+ } catch (err) {
121
+ return { ok: false, error: describe(err) }
122
+ }
123
+ }
124
+
125
+ type RepoRef = { owner: string; name: string }
126
+ type ChatRef = { kind: 'issue' | 'pr' | 'discussion'; number: number }
127
+
128
+ export function parseRepo(workspace: string): RepoRef | null {
129
+ const [owner, name, extra] = workspace.split('/')
130
+ if (!owner || !name || extra !== undefined) return null
131
+ return { owner, name }
132
+ }
133
+
134
+ export function parseChat(chat: string): ChatRef | null {
135
+ const [kind, rawNumber] = chat.split(':')
136
+ const number = Number(rawNumber)
137
+ if ((kind !== 'issue' && kind !== 'pr' && kind !== 'discussion') || !Number.isInteger(number) || number <= 0) {
138
+ return null
139
+ }
140
+ return { kind, number }
141
+ }
142
+
143
+ function describe(err: unknown): string {
144
+ return err instanceof Error ? err.message : String(err)
145
+ }
@@ -0,0 +1,349 @@
1
+ import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
2
+
3
+ export type RegisterGithubWebhooksOptions = {
4
+ token: () => Promise<string>
5
+ webhookUrl: string
6
+ webhookSecret: string
7
+ repos: readonly string[]
8
+ events: readonly string[]
9
+ // Stable, hostname-agnostic marker embedded in the webhook URL's path so
10
+ // hooks created by this agent in past runs can be recognized as ours even
11
+ // after the host part of the URL has rotated (e.g. cloudflare-quick tunnels
12
+ // mint a fresh `*.trycloudflare.com` on every container restart).
13
+ //
14
+ // When set, any hook whose `config.url` URL.pathname ends with this exact
15
+ // string is considered owned by this agent: at register time we PATCH the
16
+ // first such hook to the current URL and delete the rest as stale orphans.
17
+ //
18
+ // Convention: `/typeclaw/v1/github/<containerName>` — see
19
+ // `buildManagedPath` in `./managed-path.ts`. The path is appended onto
20
+ // tunnel-derived URLs by the adapter; user-set `webhookUrl` is kept
21
+ // verbatim (the operator is in control of their own URL — we trust them
22
+ // not to point two agents at the same URL).
23
+ //
24
+ // Omitted means the legacy URL-equality path is used (no orphan cleanup).
25
+ // The adapter always passes it in production; the option stays optional so
26
+ // direct unit-test calls can opt out of the cleanup logic.
27
+ managedPath?: string
28
+ // Opt-in legacy-orphan cleanup for hooks created before the marker existed.
29
+ // When set (e.g. `.trycloudflare.com`), the lister ALSO claims any hook
30
+ // whose URL host endsWith this suffix AND whose pathname is empty or `/`
31
+ // (unmarked = necessarily pre-fix). The adapter passes this only when the
32
+ // CURRENT effective URL itself lives on the same provider domain, so an
33
+ // agent on an external/self-hosted tunnel can never claim a colleague's
34
+ // cloudflare-quick hook. Hooks with a non-trivial path are still skipped
35
+ // unconditionally so a foreign service that happens to also use
36
+ // *.trycloudflare.com with its own path stays safe.
37
+ legacyProviderHostSuffix?: string
38
+ fetchImpl?: typeof fetch
39
+ }
40
+
41
+ export type WebhookRepoResult =
42
+ | { repo: string; action: 'created'; hookId: number }
43
+ | { repo: string; action: 'updated'; hookId: number; stalePruned: number }
44
+ | { repo: string; action: 'failed'; error: string }
45
+
46
+ export type WebhookRegistrationResult = {
47
+ repos: WebhookRepoResult[]
48
+ }
49
+
50
+ export async function registerGithubWebhooks(
51
+ options: RegisterGithubWebhooksOptions,
52
+ ): Promise<WebhookRegistrationResult> {
53
+ const fetchImpl = options.fetchImpl ?? fetch
54
+ let token: string
55
+ try {
56
+ token = await options.token()
57
+ } catch (err) {
58
+ const error = describe(err)
59
+ return { repos: options.repos.map((repo) => ({ repo, action: 'failed' as const, error })) }
60
+ }
61
+ const repos: WebhookRepoResult[] = []
62
+ for (const repo of options.repos) {
63
+ repos.push(await registerOne(fetchImpl, token, repo, options))
64
+ }
65
+ return { repos }
66
+ }
67
+
68
+ export type DeregisterGithubWebhooksOptions = {
69
+ token: () => Promise<string>
70
+ hooks: ReadonlyArray<{ repo: string; hookId: number }>
71
+ fetchImpl?: typeof fetch
72
+ }
73
+
74
+ export type WebhookDeregistrationResult = {
75
+ hooks: Array<{ repo: string; hookId: number; action: 'deleted' | 'missing' | 'failed'; error?: string }>
76
+ }
77
+
78
+ export async function deregisterGithubWebhooks(
79
+ options: DeregisterGithubWebhooksOptions,
80
+ ): Promise<WebhookDeregistrationResult> {
81
+ const fetchImpl = options.fetchImpl ?? fetch
82
+ let token: string
83
+ try {
84
+ token = await options.token()
85
+ } catch (err) {
86
+ const error = describe(err)
87
+ return { hooks: options.hooks.map((h) => ({ ...h, action: 'failed', error })) }
88
+ }
89
+ const hooks: WebhookDeregistrationResult['hooks'] = []
90
+ for (const hook of options.hooks) {
91
+ hooks.push(await deleteOne(fetchImpl, token, hook))
92
+ }
93
+ return { hooks }
94
+ }
95
+
96
+ async function registerOne(
97
+ fetchImpl: typeof fetch,
98
+ token: string,
99
+ repo: string,
100
+ options: RegisterGithubWebhooksOptions,
101
+ ): Promise<WebhookRepoResult> {
102
+ const parsed = parseRepoSlug(repo)
103
+ if (parsed === null) {
104
+ return { repo, action: 'failed', error: `invalid repo slug: "${repo}" (expected owner/name)` }
105
+ }
106
+ try {
107
+ const owned = await findManagedHooks(
108
+ fetchImpl,
109
+ token,
110
+ parsed,
111
+ options.webhookUrl,
112
+ options.managedPath,
113
+ options.legacyProviderHostSuffix,
114
+ )
115
+ if (owned.length === 0) {
116
+ const hookId = await createHook(fetchImpl, token, parsed, options)
117
+ return { repo, action: 'created', hookId }
118
+ }
119
+ // Sort by id ascending so the canonical kept hook is deterministic
120
+ // (oldest = lowest id wins). This makes successive runs converge on the
121
+ // same hookId for the same repo, which is friendlier to anyone
122
+ // inspecting the repo's webhook list.
123
+ const [keep, ...stale] = owned.slice().sort((a, b) => a - b)
124
+ await updateHook(fetchImpl, token, parsed, keep!, options)
125
+ let stalePruned = 0
126
+ for (const id of stale) {
127
+ const ok = await tryDeleteHook(fetchImpl, token, parsed, id)
128
+ if (ok) stalePruned++
129
+ }
130
+ return { repo, action: 'updated', hookId: keep!, stalePruned }
131
+ } catch (err) {
132
+ return { repo, action: 'failed', error: describe(err) }
133
+ }
134
+ }
135
+
136
+ async function deleteOne(
137
+ fetchImpl: typeof fetch,
138
+ token: string,
139
+ hook: { repo: string; hookId: number },
140
+ ): Promise<WebhookDeregistrationResult['hooks'][number]> {
141
+ const parsed = parseRepoSlug(hook.repo)
142
+ if (parsed === null) {
143
+ return { ...hook, action: 'failed', error: `invalid repo slug: "${hook.repo}"` }
144
+ }
145
+ try {
146
+ const response = await fetchImpl(`${GITHUB_API_BASE}/repos/${parsed.owner}/${parsed.name}/hooks/${hook.hookId}`, {
147
+ method: 'DELETE',
148
+ headers: githubJsonHeaders(token),
149
+ })
150
+ if (response.status === 404) return { ...hook, action: 'missing' }
151
+ if (!response.ok) {
152
+ const body = await response.text().catch(() => '')
153
+ return {
154
+ ...hook,
155
+ action: 'failed',
156
+ error: `delete hook failed: ${response.status}${body !== '' ? ` ${body}` : ''}`,
157
+ }
158
+ }
159
+ return { ...hook, action: 'deleted' }
160
+ } catch (err) {
161
+ return { ...hook, action: 'failed', error: describe(err) }
162
+ }
163
+ }
164
+
165
+ // Best-effort stale-hook prune. We don't surface 404/403/etc. as a register
166
+ // failure because the primary keep-hook is already updated; an inability to
167
+ // delete a stale orphan is a soft warning at most. Caller counts successful
168
+ // prunes for the log line.
169
+ async function tryDeleteHook(fetchImpl: typeof fetch, token: string, repo: RepoSlug, hookId: number): Promise<boolean> {
170
+ try {
171
+ const response = await fetchImpl(`${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/hooks/${hookId}`, {
172
+ method: 'DELETE',
173
+ headers: githubJsonHeaders(token),
174
+ })
175
+ // 404 = already gone; treat as a successful prune for log-summary purposes
176
+ // (the orphan is no longer on the repo, which is what we wanted).
177
+ return response.ok || response.status === 404
178
+ } catch {
179
+ return false
180
+ }
181
+ }
182
+
183
+ type RepoSlug = { owner: string; name: string }
184
+
185
+ function parseRepoSlug(slug: string): RepoSlug | null {
186
+ const parts = slug.split('/')
187
+ if (parts.length !== 2) return null
188
+ const [owner, name] = parts
189
+ if (!owner || !name) return null
190
+ if (!REPO_SEGMENT.test(owner) || !REPO_SEGMENT.test(name)) return null
191
+ return { owner, name }
192
+ }
193
+
194
+ const REPO_SEGMENT = /^[A-Za-z0-9._-]+$/
195
+
196
+ // Returns the hookIds of every hook owned by this agent on `repo`, in the
197
+ // order GitHub returned them. Ownership is the union of three rules:
198
+ //
199
+ // 1. `config.url === webhookUrl` — the live URL match. Covers the
200
+ // common case (user-set webhookUrl, or a tunnel URL that hasn't
201
+ // rotated since the last register).
202
+ //
203
+ // 2. `URL(config.url).pathname` ends with `managedPath` — the
204
+ // hostname-agnostic path-marker match. Covers hooks that THIS agent
205
+ // created in a previous run whose tunnel host has since rotated.
206
+ // Skipped when `managedPath` is omitted (legacy callers).
207
+ //
208
+ // 3. (Opt-in via `legacyProviderHostSuffix`) `URL(config.url).host` ends
209
+ // with the supplied suffix AND pathname is empty or `/`. Covers the
210
+ // pre-marker orphans the user reported in the bug. Tightly bounded:
211
+ // same provider domain only, unmarked hooks only.
212
+ //
213
+ // Hooks whose `config.url` isn't a parseable URL are ignored. Hooks
214
+ // without an `id` are ignored.
215
+ async function findManagedHooks(
216
+ fetchImpl: typeof fetch,
217
+ token: string,
218
+ repo: RepoSlug,
219
+ webhookUrl: string,
220
+ managedPath: string | undefined,
221
+ legacyProviderHostSuffix: string | undefined,
222
+ ): Promise<number[]> {
223
+ const response = await fetchImpl(`${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/hooks?per_page=100`, {
224
+ method: 'GET',
225
+ headers: githubJsonHeaders(token),
226
+ })
227
+ if (!response.ok) {
228
+ const body = await response.text().catch(() => '')
229
+ throw new Error(`list hooks failed: ${response.status}${body !== '' ? ` ${body}` : ''}`)
230
+ }
231
+ const hooks = (await response.json()) as Array<{ id?: unknown; config?: { url?: unknown } }>
232
+ const owned: number[] = []
233
+ for (const hook of hooks) {
234
+ if (typeof hook.id !== 'number') continue
235
+ const url = hook.config?.url
236
+ if (typeof url !== 'string') continue
237
+ if (url === webhookUrl) {
238
+ owned.push(hook.id)
239
+ continue
240
+ }
241
+ if (managedPath !== undefined && hookPathMatchesMarker(url, managedPath)) {
242
+ owned.push(hook.id)
243
+ continue
244
+ }
245
+ if (legacyProviderHostSuffix !== undefined && hookIsUnmarkedOnProvider(url, legacyProviderHostSuffix)) {
246
+ owned.push(hook.id)
247
+ }
248
+ }
249
+ return owned
250
+ }
251
+
252
+ function hookPathMatchesMarker(rawUrl: string, marker: string): boolean {
253
+ let parsed: URL
254
+ try {
255
+ parsed = new URL(rawUrl)
256
+ } catch {
257
+ return false
258
+ }
259
+ // Suffix match on pathname only (not the full URL). Rotating Cloudflare
260
+ // hostnames change `parsed.host`; the marker survives in `parsed.pathname`.
261
+ // Suffix (not equality) so a future reverse-proxy that prepends a path
262
+ // prefix doesn't break recognition.
263
+ return parsed.pathname === marker || parsed.pathname.endsWith(marker)
264
+ }
265
+
266
+ function hookIsUnmarkedOnProvider(rawUrl: string, hostSuffix: string): boolean {
267
+ let parsed: URL
268
+ try {
269
+ parsed = new URL(rawUrl)
270
+ } catch {
271
+ return false
272
+ }
273
+ // Empty pathname (rare, depends on URL parser) or root only. Anything
274
+ // with a real path is treated as user-controlled and left alone.
275
+ const unmarked = parsed.pathname === '' || parsed.pathname === '/'
276
+ // hostSuffix must start with a dot OR be the full host — guards against
277
+ // `foo.com` accidentally matching `evilfoo.com`.
278
+ const onProvider = parsed.host === hostSuffix || (hostSuffix.startsWith('.') && parsed.host.endsWith(hostSuffix))
279
+ return unmarked && onProvider
280
+ }
281
+
282
+ async function createHook(
283
+ fetchImpl: typeof fetch,
284
+ token: string,
285
+ repo: RepoSlug,
286
+ options: RegisterGithubWebhooksOptions,
287
+ ): Promise<number> {
288
+ const response = await fetchImpl(`${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/hooks`, {
289
+ method: 'POST',
290
+ headers: githubJsonHeaders(token),
291
+ body: JSON.stringify(buildHookPayload(options, { includeName: true })),
292
+ })
293
+ if (!response.ok) {
294
+ const body = await response.text().catch(() => '')
295
+ throw new Error(`create hook failed: ${response.status}${body !== '' ? ` ${body}` : ''}`)
296
+ }
297
+ const raw = (await response.json()) as { id?: unknown }
298
+ if (typeof raw.id !== 'number') throw new Error('create hook response missing id')
299
+ return raw.id
300
+ }
301
+
302
+ async function updateHook(
303
+ fetchImpl: typeof fetch,
304
+ token: string,
305
+ repo: RepoSlug,
306
+ hookId: number,
307
+ options: RegisterGithubWebhooksOptions,
308
+ ): Promise<void> {
309
+ const response = await fetchImpl(`${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/hooks/${hookId}`, {
310
+ method: 'PATCH',
311
+ headers: githubJsonHeaders(token),
312
+ body: JSON.stringify(buildHookPayload(options, { includeName: false })),
313
+ })
314
+ if (!response.ok) {
315
+ const body = await response.text().catch(() => '')
316
+ throw new Error(`update hook failed: ${response.status}${body !== '' ? ` ${body}` : ''}`)
317
+ }
318
+ }
319
+
320
+ function buildHookPayload(
321
+ options: RegisterGithubWebhooksOptions,
322
+ { includeName }: { includeName: boolean },
323
+ ): Record<string, unknown> {
324
+ const payload: Record<string, unknown> = {
325
+ active: true,
326
+ events: toCoarseEvents(options.events),
327
+ config: {
328
+ url: options.webhookUrl,
329
+ content_type: 'json',
330
+ secret: options.webhookSecret,
331
+ insecure_ssl: '0',
332
+ },
333
+ }
334
+ if (includeName) payload.name = 'web'
335
+ return payload
336
+ }
337
+
338
+ function toCoarseEvents(events: readonly string[]): string[] {
339
+ const seen = new Set<string>()
340
+ for (const e of events) {
341
+ const coarse = e.split('.')[0]
342
+ if (coarse && coarse.length > 0) seen.add(coarse)
343
+ }
344
+ return [...seen]
345
+ }
346
+
347
+ function describe(err: unknown): string {
348
+ return err instanceof Error ? err.message : String(err)
349
+ }