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,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
|
+
}
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import type { ChannelRouter } from '@/channels/router'
|
|
2
|
+
import type { ChannelAdapterConfig, GithubAdapterConfig } from '@/channels/schema'
|
|
3
|
+
import { resolveSecret } from '@/secrets/resolve'
|
|
4
|
+
import type { GithubSecretsBlock } from '@/secrets/schema'
|
|
5
|
+
|
|
6
|
+
import { buildAuthStrategy } from './auth'
|
|
7
|
+
import { createGithubChannelNameResolver } from './channel-resolver'
|
|
8
|
+
import { createDeliveryDedup } from './dedup'
|
|
9
|
+
import { createGithubFetchAttachmentCallback } from './fetch-attachment'
|
|
10
|
+
import { createGithubHistoryCallback } from './history'
|
|
11
|
+
import { createGithubWebhookHandler } from './inbound'
|
|
12
|
+
import { applyManagedPath, buildManagedPath, resolveAgentId } from './managed-path'
|
|
13
|
+
import { createGithubMembershipResolver } from './membership'
|
|
14
|
+
import { createGithubOutboundCallback } from './outbound'
|
|
15
|
+
import { deregisterGithubWebhooks, registerGithubWebhooks, type WebhookRegistrationResult } from './webhook-register'
|
|
16
|
+
|
|
17
|
+
export type GithubAdapterLogger = {
|
|
18
|
+
info: (m: string) => void
|
|
19
|
+
warn: (m: string) => void
|
|
20
|
+
error: (m: string) => void
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type GithubAdapterOptions = {
|
|
24
|
+
router: ChannelRouter
|
|
25
|
+
configRef: () => ChannelAdapterConfig & GithubAdapterConfig
|
|
26
|
+
secrets: GithubSecretsBlock
|
|
27
|
+
agentDir: string
|
|
28
|
+
logger?: GithubAdapterLogger
|
|
29
|
+
fetchImpl?: typeof fetch
|
|
30
|
+
httpListenImpl?: (port: number, handler: (req: Request) => Promise<Response>) => { stop: () => Promise<void> }
|
|
31
|
+
tunnelUrl?: () => string | null
|
|
32
|
+
// Whether a channel-bound tunnel exists in typeclaw.json#tunnels[] for the
|
|
33
|
+
// github channel. Used to distinguish "no tunnel configured (operator opted
|
|
34
|
+
// out)" from "tunnel configured but not producing a URL (something is
|
|
35
|
+
// wrong)" so the skip-registration log can be precise and actionable.
|
|
36
|
+
// Optional so tests that don't exercise the tunnel-status path can omit it.
|
|
37
|
+
tunnelConfiguredForChannel?: () => boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type GithubAdapter = {
|
|
41
|
+
start: () => Promise<void>
|
|
42
|
+
stop: () => Promise<void>
|
|
43
|
+
isConnected: () => boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const consoleLogger: GithubAdapterLogger = {
|
|
47
|
+
info: (m) => console.log(m),
|
|
48
|
+
warn: (m) => console.warn(m),
|
|
49
|
+
error: (m) => console.error(m),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapter {
|
|
53
|
+
const logger = options.logger ?? consoleLogger
|
|
54
|
+
const fetchImpl = options.fetchImpl ?? fetch
|
|
55
|
+
const auth = buildAuthStrategy({ auth: options.secrets.auth, fetchImpl })
|
|
56
|
+
const webhookSecret = resolveSecret(options.secrets.webhookSecret, undefined, process.env)
|
|
57
|
+
if (webhookSecret === undefined || webhookSecret.trim() === '') throw new Error('GitHub webhookSecret is missing')
|
|
58
|
+
|
|
59
|
+
let server: { stop: () => Promise<void> } | null = null
|
|
60
|
+
let selfId: string | null = null
|
|
61
|
+
let selfLogin: string | null = null
|
|
62
|
+
let started = false
|
|
63
|
+
let managedHooks: ReadonlyArray<{ repo: string; hookId: number }> = []
|
|
64
|
+
const workspaceByChat = new Map<string, string>()
|
|
65
|
+
|
|
66
|
+
const rememberWorkspace = (workspace: string, chat: string): void => {
|
|
67
|
+
workspaceByChat.set(chat, workspace)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const tokenFn = async () => {
|
|
71
|
+
const t = await auth.token()
|
|
72
|
+
process.env.GH_TOKEN = t
|
|
73
|
+
return t
|
|
74
|
+
}
|
|
75
|
+
const outbound = createGithubOutboundCallback({ token: tokenFn, logger, fetchImpl })
|
|
76
|
+
const history = createGithubHistoryCallback({
|
|
77
|
+
token: tokenFn,
|
|
78
|
+
fetchImpl,
|
|
79
|
+
workspaceForChat: (chat) => workspaceByChat.get(chat) ?? null,
|
|
80
|
+
})
|
|
81
|
+
const membership = createGithubMembershipResolver({ token: tokenFn, fetchImpl })
|
|
82
|
+
const channelNameResolver = createGithubChannelNameResolver({ token: tokenFn, fetchImpl })
|
|
83
|
+
const fetchAttachment = createGithubFetchAttachmentCallback()
|
|
84
|
+
// No-op typing callback: GitHub has no typing indicator API.
|
|
85
|
+
const typing = async (): Promise<void> => {}
|
|
86
|
+
const dedup = createDeliveryDedup()
|
|
87
|
+
const handler = createGithubWebhookHandler({
|
|
88
|
+
webhookSecret,
|
|
89
|
+
dedup,
|
|
90
|
+
allowlist: () => options.configRef().eventAllowlist,
|
|
91
|
+
selfId: () => selfId,
|
|
92
|
+
selfLogin: () => selfLogin,
|
|
93
|
+
logger,
|
|
94
|
+
route: (message) => {
|
|
95
|
+
rememberWorkspace(message.workspace, message.chat)
|
|
96
|
+
// Ack-first: wrap in Promise.resolve so a synchronous throw inside
|
|
97
|
+
// router.route() cannot prevent the 200 response from being returned.
|
|
98
|
+
void Promise.resolve()
|
|
99
|
+
.then(() => options.router.route(message))
|
|
100
|
+
.catch((err: unknown) => {
|
|
101
|
+
logger.error(`[github] route failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
102
|
+
})
|
|
103
|
+
},
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
async start(): Promise<void> {
|
|
108
|
+
if (started) return
|
|
109
|
+
const self = await auth.getSelf()
|
|
110
|
+
selfId = String(self.id)
|
|
111
|
+
selfLogin = self.login
|
|
112
|
+
// Register all callbacks before binding the HTTP listener so the router
|
|
113
|
+
// is fully wired before any webhook can arrive.
|
|
114
|
+
options.router.registerOutbound('github', outbound)
|
|
115
|
+
options.router.registerTyping('github', typing)
|
|
116
|
+
options.router.registerHistory('github', history)
|
|
117
|
+
options.router.registerMembership('github', membership)
|
|
118
|
+
options.router.registerChannelNameResolver('github', channelNameResolver)
|
|
119
|
+
options.router.registerFetchAttachment('github', fetchAttachment)
|
|
120
|
+
try {
|
|
121
|
+
server = (options.httpListenImpl ?? listenWithBun)(options.configRef().webhookPort, handler)
|
|
122
|
+
} catch (err) {
|
|
123
|
+
// Listener failed — roll back all registrations so stop() is a no-op
|
|
124
|
+
// and the manager can report the failure cleanly.
|
|
125
|
+
options.router.unregisterOutbound('github', outbound)
|
|
126
|
+
options.router.unregisterTyping('github', typing)
|
|
127
|
+
options.router.unregisterHistory('github', history)
|
|
128
|
+
options.router.unregisterMembership('github', membership)
|
|
129
|
+
options.router.unregisterChannelNameResolver('github', channelNameResolver)
|
|
130
|
+
options.router.unregisterFetchAttachment('github', fetchAttachment)
|
|
131
|
+
await auth.dispose()
|
|
132
|
+
delete process.env.GH_TOKEN
|
|
133
|
+
selfId = null
|
|
134
|
+
selfLogin = null
|
|
135
|
+
throw err
|
|
136
|
+
}
|
|
137
|
+
// Seed GH_TOKEN so `gh` CLI calls in the container are pre-authenticated.
|
|
138
|
+
// tokenFn keeps it current on every adapter API call; App tokens refresh
|
|
139
|
+
// automatically when within 5 minutes of expiry.
|
|
140
|
+
process.env.GH_TOKEN = await auth.token()
|
|
141
|
+
started = true
|
|
142
|
+
logger.info(`[github] webhook listening on port ${options.configRef().webhookPort} as @${self.login}`)
|
|
143
|
+
// Repository webhook registration is best-effort: failures are logged
|
|
144
|
+
// per-repo, the adapter stays up. A misconfigured PAT or App that
|
|
145
|
+
// can't manage hooks must not prevent the adapter from accepting
|
|
146
|
+
// events for repos whose hooks are already registered.
|
|
147
|
+
const cfg = options.configRef()
|
|
148
|
+
const repos = cfg.repos ?? []
|
|
149
|
+
const tunnelUrl = options.tunnelUrl?.() ?? null
|
|
150
|
+
if (cfg.webhookUrl !== undefined && tunnelUrl !== null) {
|
|
151
|
+
logger.warn('[github] webhookUrl configured; ignoring tunnel URL for webhook registration')
|
|
152
|
+
}
|
|
153
|
+
const rawUrl = cfg.webhookUrl ?? tunnelUrl
|
|
154
|
+
const managedPath = buildManagedPath(
|
|
155
|
+
resolveAgentId({ containerName: process.env.TYPECLAW_CONTAINER_NAME, agentDir: options.agentDir }),
|
|
156
|
+
)
|
|
157
|
+
const effectiveUrl = rawUrl === null ? null : applyManagedPath(rawUrl, managedPath)
|
|
158
|
+
if (effectiveUrl === null) {
|
|
159
|
+
logSkippedRegistration(logger, {
|
|
160
|
+
tunnelConfigured: options.tunnelConfiguredForChannel?.() ?? false,
|
|
161
|
+
reposCount: repos.length,
|
|
162
|
+
})
|
|
163
|
+
} else if (repos.length > 0) {
|
|
164
|
+
const legacyProviderHostSuffix = detectLegacyProviderHostSuffix(effectiveUrl)
|
|
165
|
+
const registration = await registerGithubWebhooks({
|
|
166
|
+
token: tokenFn,
|
|
167
|
+
webhookUrl: effectiveUrl,
|
|
168
|
+
webhookSecret,
|
|
169
|
+
repos,
|
|
170
|
+
events: cfg.eventAllowlist,
|
|
171
|
+
managedPath,
|
|
172
|
+
...(legacyProviderHostSuffix !== undefined ? { legacyProviderHostSuffix } : {}),
|
|
173
|
+
fetchImpl,
|
|
174
|
+
})
|
|
175
|
+
managedHooks = registration.repos.flatMap((r) =>
|
|
176
|
+
r.action === 'created' || r.action === 'updated' ? [{ repo: r.repo, hookId: r.hookId }] : [],
|
|
177
|
+
)
|
|
178
|
+
logRegistrationOutcome(logger, registration)
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
async stop(): Promise<void> {
|
|
182
|
+
if (!started) return
|
|
183
|
+
started = false
|
|
184
|
+
options.router.unregisterOutbound('github', outbound)
|
|
185
|
+
options.router.unregisterTyping('github', typing)
|
|
186
|
+
options.router.unregisterHistory('github', history)
|
|
187
|
+
options.router.unregisterMembership('github', membership)
|
|
188
|
+
options.router.unregisterChannelNameResolver('github', channelNameResolver)
|
|
189
|
+
options.router.unregisterFetchAttachment('github', fetchAttachment)
|
|
190
|
+
await server?.stop()
|
|
191
|
+
// Detach hooks AFTER closing the listener so any in-flight deliveries
|
|
192
|
+
// from GitHub no longer hit a live receiver while we're tearing down.
|
|
193
|
+
// The token call uses the still-live `auth` strategy; dispose() runs
|
|
194
|
+
// last to clear the cached App-installation token.
|
|
195
|
+
if (managedHooks.length > 0) {
|
|
196
|
+
const deregistration = await deregisterGithubWebhooks({
|
|
197
|
+
token: tokenFn,
|
|
198
|
+
hooks: managedHooks,
|
|
199
|
+
fetchImpl,
|
|
200
|
+
})
|
|
201
|
+
logDeregistrationOutcome(logger, deregistration)
|
|
202
|
+
managedHooks = []
|
|
203
|
+
}
|
|
204
|
+
await auth.dispose()
|
|
205
|
+
delete process.env.GH_TOKEN
|
|
206
|
+
server = null
|
|
207
|
+
selfId = null
|
|
208
|
+
selfLogin = null
|
|
209
|
+
},
|
|
210
|
+
isConnected(): boolean {
|
|
211
|
+
return started && selfLogin !== null
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function listenWithBun(port: number, handler: (req: Request) => Promise<Response>): { stop: () => Promise<void> } {
|
|
217
|
+
const server = Bun.serve({ port, fetch: handler })
|
|
218
|
+
return { stop: async () => server.stop() }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function logSkippedRegistration(
|
|
222
|
+
logger: GithubAdapterLogger,
|
|
223
|
+
context: { tunnelConfigured: boolean; reposCount: number },
|
|
224
|
+
): void {
|
|
225
|
+
if (context.reposCount === 0) {
|
|
226
|
+
logger.info('[github] no repos[] configured; webhook registration skipped')
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
if (context.tunnelConfigured) {
|
|
230
|
+
logger.warn(
|
|
231
|
+
'[github] webhook registration SKIPPED: a tunnel is configured for this channel but produced no URL yet. ' +
|
|
232
|
+
"Check `typeclaw tunnel status` for the tunnel's health (cloudflared binary missing, " +
|
|
233
|
+
'auth failure, network issue). Webhook delivery will not work until the tunnel produces a public URL.',
|
|
234
|
+
)
|
|
235
|
+
return
|
|
236
|
+
}
|
|
237
|
+
logger.warn(
|
|
238
|
+
'[github] webhook registration SKIPPED: no `channels.github.webhookUrl` set and no `tunnels[]` entry ' +
|
|
239
|
+
'binds a public URL to this channel. Add an entry to `tunnels[]` (e.g. `provider: "cloudflare-quick"`) ' +
|
|
240
|
+
'or set `channels.github.webhookUrl` to a public URL to enable webhook delivery.',
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Known tunnel-provider host suffixes whose hostnames rotate per container.
|
|
245
|
+
// A pre-marker hook on one of these is unambiguously a typeclaw orphan from
|
|
246
|
+
// this agent's prior runs (cloudflare-quick is per-container, the host
|
|
247
|
+
// changes every restart, so a stale unmarked *.trycloudflare.com hook
|
|
248
|
+
// pointing at a now-dead host cannot belong to any live service).
|
|
249
|
+
// Extending: add the host suffix here AND verify that hooks on the new
|
|
250
|
+
// provider always look unmarked (no operator-supplied path) before the
|
|
251
|
+
// marker was introduced.
|
|
252
|
+
const LEGACY_TUNNEL_PROVIDER_HOSTS: readonly string[] = ['.trycloudflare.com']
|
|
253
|
+
|
|
254
|
+
function detectLegacyProviderHostSuffix(url: string): string | undefined {
|
|
255
|
+
let parsed: URL
|
|
256
|
+
try {
|
|
257
|
+
parsed = new URL(url)
|
|
258
|
+
} catch {
|
|
259
|
+
return undefined
|
|
260
|
+
}
|
|
261
|
+
for (const suffix of LEGACY_TUNNEL_PROVIDER_HOSTS) {
|
|
262
|
+
if (parsed.host.endsWith(suffix)) return suffix
|
|
263
|
+
}
|
|
264
|
+
return undefined
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function logRegistrationOutcome(logger: GithubAdapterLogger, result: WebhookRegistrationResult): void {
|
|
268
|
+
for (const r of result.repos) {
|
|
269
|
+
if (r.action === 'created') logger.info(`[github] registered webhook ${r.hookId} on ${r.repo}`)
|
|
270
|
+
else if (r.action === 'updated') {
|
|
271
|
+
const tail = r.stalePruned > 0 ? ` (pruned ${r.stalePruned} stale)` : ''
|
|
272
|
+
logger.info(`[github] updated webhook ${r.hookId} on ${r.repo}${tail}`)
|
|
273
|
+
} else logger.warn(`[github] webhook register failed for ${r.repo}: ${r.error}`)
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function logDeregistrationOutcome(
|
|
278
|
+
logger: GithubAdapterLogger,
|
|
279
|
+
result: Awaited<ReturnType<typeof deregisterGithubWebhooks>>,
|
|
280
|
+
): void {
|
|
281
|
+
for (const h of result.hooks) {
|
|
282
|
+
if (h.action === 'deleted') logger.info(`[github] detached webhook ${h.hookId} from ${h.repo}`)
|
|
283
|
+
else if (h.action === 'missing') logger.info(`[github] webhook ${h.hookId} on ${h.repo} already gone`)
|
|
284
|
+
else logger.warn(`[github] webhook detach failed for ${h.repo}#${h.hookId}: ${h.error ?? 'unknown error'}`)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { basename, resolve } from 'node:path'
|
|
2
|
+
|
|
3
|
+
// `v1` is a schema version for the marker layout. Bumping it lets a future
|
|
4
|
+
// change (e.g. embedding a per-repo nonce, switching to a different ownership
|
|
5
|
+
// scheme) coexist with hooks created under earlier versions instead of
|
|
6
|
+
// stranding them. `findManagedHooks` only treats the current version as ours;
|
|
7
|
+
// a v2 rollout would need a one-shot pass that adopts v1 hooks before
|
|
8
|
+
// retiring them.
|
|
9
|
+
const MARKER_PREFIX = '/typeclaw/v1/github/'
|
|
10
|
+
|
|
11
|
+
export function buildManagedPath(agentId: string): string {
|
|
12
|
+
const safe = sanitizeAgentId(agentId)
|
|
13
|
+
return `${MARKER_PREFIX}${safe}`
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// `containerName` (TYPECLAW_CONTAINER_NAME) is the load-bearing identifier
|
|
17
|
+
// inside the container; falls back to the agent folder basename for host-side
|
|
18
|
+
// callers (e.g. eager webhook install at `typeclaw channel add github` time)
|
|
19
|
+
// that don't have the env var set yet. Both resolve to the same string in
|
|
20
|
+
// practice — see `containerNameFromCwd` in src/container/shared.ts.
|
|
21
|
+
export function resolveAgentId(options: { containerName?: string; agentDir: string }): string {
|
|
22
|
+
const fromEnv = options.containerName?.trim()
|
|
23
|
+
if (fromEnv && fromEnv.length > 0) return fromEnv
|
|
24
|
+
return basename(resolve(options.agentDir))
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Append the marker path to a URL that's missing one. The cloudflare-quick
|
|
28
|
+
// tunnel hands us `https://<random>.trycloudflare.com` with no path; we want
|
|
29
|
+
// the marker visible in the resulting webhook URL so a future run of THIS
|
|
30
|
+
// agent can recognize the hook as ours after the hostname rotates.
|
|
31
|
+
//
|
|
32
|
+
// If the URL already has a non-trivial path (user-set webhookUrl), it's
|
|
33
|
+
// returned verbatim. We treat that as "operator owns this URL" — appending
|
|
34
|
+
// our marker would silently change a user-configured webhook URL.
|
|
35
|
+
export function applyManagedPath(rawUrl: string, managedPath: string): string {
|
|
36
|
+
let parsed: URL
|
|
37
|
+
try {
|
|
38
|
+
parsed = new URL(rawUrl)
|
|
39
|
+
} catch {
|
|
40
|
+
return rawUrl
|
|
41
|
+
}
|
|
42
|
+
if (parsed.pathname !== '' && parsed.pathname !== '/') return rawUrl
|
|
43
|
+
parsed.pathname = managedPath
|
|
44
|
+
return parsed.toString()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// `containerNameFromCwd` (src/container/shared.ts) clamps to [a-z0-9_.-];
|
|
48
|
+
// applying the same conservative shape here keeps URL paths well-formed even
|
|
49
|
+
// if a caller passes us an unsanitized identifier from somewhere else.
|
|
50
|
+
function sanitizeAgentId(raw: string): string {
|
|
51
|
+
const trimmed = raw.trim().toLowerCase()
|
|
52
|
+
const cleaned = trimmed.replace(/[^a-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '')
|
|
53
|
+
return cleaned === '' ? 'agent' : cleaned
|
|
54
|
+
}
|