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.
- package/README.md +20 -15
- package/auth.schema.json +113 -0
- package/package.json +1 -1
- package/secrets.schema.json +113 -0
- package/src/agent/session-meta.ts +1 -1
- package/src/agent/session-origin.ts +3 -2
- package/src/bundled-plugins/security/index.ts +3 -2
- package/src/channels/adapters/github/auth-app.ts +120 -0
- package/src/channels/adapters/github/auth-pat.ts +50 -0
- package/src/channels/adapters/github/auth.ts +33 -0
- package/src/channels/adapters/github/channel-resolver.ts +30 -0
- package/src/channels/adapters/github/dedup.ts +26 -0
- package/src/channels/adapters/github/event-allowlist.ts +8 -0
- package/src/channels/adapters/github/fetch-attachment.ts +5 -0
- package/src/channels/adapters/github/history.ts +63 -0
- package/src/channels/adapters/github/inbound.ts +286 -0
- package/src/channels/adapters/github/index.ts +286 -0
- package/src/channels/adapters/github/managed-path.ts +54 -0
- package/src/channels/adapters/github/membership.ts +35 -0
- package/src/channels/adapters/github/outbound.ts +145 -0
- package/src/channels/adapters/github/webhook-register.ts +349 -0
- package/src/channels/manager.ts +94 -9
- package/src/channels/schema.ts +31 -1
- package/src/channels/tunnel-bridge.ts +51 -0
- package/src/cli/builtins.ts +28 -0
- package/src/cli/channel.ts +511 -25
- package/src/cli/container-command-client.ts +244 -0
- package/src/cli/cron.ts +173 -0
- package/src/cli/host-command-runner.ts +150 -0
- package/src/cli/index.ts +42 -1
- package/src/cli/init.ts +256 -27
- package/src/cli/model.ts +4 -2
- package/src/cli/plugin-command-help.ts +49 -0
- package/src/cli/plugin-commands-dispatch.ts +112 -0
- package/src/cli/plugin-commands.ts +118 -0
- package/src/cli/tui.ts +10 -2
- package/src/cli/tunnel.ts +533 -0
- package/src/cli/ui.ts +8 -3
- package/src/config/config.ts +75 -0
- package/src/container/start.ts +30 -3
- package/src/cron/bridge.ts +136 -0
- package/src/cron/consumer.ts +45 -5
- package/src/cron/index.ts +19 -2
- package/src/cron/list.ts +105 -0
- package/src/cron/scheduler.ts +12 -3
- package/src/cron/schema.ts +11 -3
- package/src/doctor/checks.ts +0 -50
- package/src/init/dockerfile.ts +59 -13
- package/src/init/ensure-deps.ts +15 -4
- package/src/init/github-webhook-install.ts +109 -0
- package/src/init/index.ts +505 -9
- package/src/init/run-bun-install.ts +17 -3
- package/src/init/run-owner-claim.ts +11 -2
- package/src/permissions/builtins.ts +6 -1
- package/src/permissions/match-rule.ts +24 -2
- package/src/permissions/resolve.ts +1 -0
- package/src/plugin/define.ts +42 -1
- package/src/plugin/index.ts +18 -3
- package/src/plugin/manager.ts +2 -0
- package/src/plugin/registry.ts +85 -3
- package/src/plugin/types.ts +138 -1
- package/src/plugin/zod-introspect.ts +100 -0
- package/src/role-claim/match-rule.ts +2 -1
- package/src/run/index.ts +110 -3
- package/src/secrets/index.ts +1 -1
- package/src/secrets/schema.ts +21 -0
- package/src/server/command-runner.ts +476 -0
- package/src/server/index.ts +388 -5
- package/src/shared/index.ts +8 -0
- package/src/shared/protocol.ts +80 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
- package/src/skills/typeclaw-config/SKILL.md +27 -26
- package/src/skills/typeclaw-cron/SKILL.md +234 -3
- package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
- package/src/skills/typeclaw-permissions/SKILL.md +5 -4
- package/src/skills/typeclaw-plugins/SKILL.md +251 -5
- package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
- package/src/test-helpers/wait-for.ts +50 -0
- package/src/tui/index.ts +35 -4
- package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
- package/src/tunnels/events.ts +14 -0
- package/src/tunnels/index.ts +12 -0
- package/src/tunnels/log-ring.ts +54 -0
- package/src/tunnels/manager.ts +139 -0
- package/src/tunnels/providers/cloudflare-quick.ts +189 -0
- package/src/tunnels/providers/external.ts +53 -0
- package/src/tunnels/quick-url-parser.ts +5 -0
- package/src/tunnels/types.ts +43 -0
- 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
|
+
}
|