typeclaw 0.18.0 → 0.20.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/package.json +1 -1
- package/src/agent/index.ts +9 -1
- package/src/agent/live-subagents.ts +4 -0
- package/src/agent/model-overrides.ts +77 -0
- package/src/agent/plugin-tools.ts +53 -4
- package/src/agent/session-origin.ts +32 -10
- package/src/agent/tools/channel-react.ts +79 -0
- package/src/agent/tools/grant-role.ts +102 -8
- package/src/agent/tools/spawn-subagent.ts +1 -0
- package/src/agent/tools/subagent-access.ts +67 -0
- package/src/agent/tools/subagent-cancel.ts +11 -6
- package/src/agent/tools/subagent-output.ts +10 -2
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +372 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +42 -0
- package/src/bundled-plugins/github-cli-auth/token-class.ts +11 -0
- package/src/bundled-plugins/reviewer/skills/code-review.ts +18 -1
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +9 -2
- package/src/channels/adapters/discord-bot.ts +242 -7
- package/src/channels/adapters/github/inbound.ts +40 -55
- package/src/channels/adapters/github/index.ts +89 -18
- package/src/channels/adapters/github/membership.ts +4 -0
- package/src/channels/adapters/github/permission-guidance.ts +20 -1
- package/src/channels/adapters/github/reactions.ts +142 -0
- package/src/channels/adapters/slack-bot-slash-commands.ts +3 -1
- package/src/channels/adapters/slack-bot.ts +4 -4
- package/src/channels/commands.ts +10 -0
- package/src/channels/engagement.ts +30 -2
- package/src/channels/github-token-bridge.ts +42 -0
- package/src/channels/index.ts +6 -0
- package/src/channels/manager.ts +6 -0
- package/src/channels/membership.ts +9 -0
- package/src/channels/router.ts +295 -42
- package/src/channels/types.ts +42 -0
- package/src/cli/inspect.ts +3 -0
- package/src/cli/ui.ts +6 -0
- package/src/commands/index.ts +54 -4
- package/src/init/dockerfile.ts +60 -0
- package/src/init/validate-api-key.ts +15 -1
- package/src/inspect/loop.ts +12 -1
- package/src/permissions/permissions.ts +24 -0
- package/src/plugin/context.ts +8 -0
- package/src/plugin/manager.ts +3 -0
- package/src/plugin/types.ts +6 -0
- package/src/run/bundled-plugins.ts +9 -0
- package/src/run/index.ts +4 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +80 -43
|
@@ -42,6 +42,7 @@ import {
|
|
|
42
42
|
// commands here is the documented extension point: declare the entry here,
|
|
43
43
|
// then add the matching handler in createChannelRouter's command registry.
|
|
44
44
|
const SLASH_COMMANDS: readonly DiscordCommandDeclaration[] = [
|
|
45
|
+
{ name: 'help', description: 'List available commands' },
|
|
45
46
|
{ name: 'stop', description: 'Abort the current turn in this channel' },
|
|
46
47
|
]
|
|
47
48
|
const SLASH_COMMAND_NAMES: ReadonlySet<string> = new Set(SLASH_COMMANDS.map((c) => c.name))
|
|
@@ -147,6 +148,116 @@ type DiscordGuildPreview = {
|
|
|
147
148
|
|
|
148
149
|
type DiscordGuildMember = {
|
|
149
150
|
user?: { id?: string; bot?: boolean }
|
|
151
|
+
roles?: string[]
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
type DiscordPermissionOverwrite = {
|
|
155
|
+
id?: string
|
|
156
|
+
type?: number
|
|
157
|
+
allow?: string
|
|
158
|
+
deny?: string
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Discord channel `type` values that are threads. A thread's own
|
|
162
|
+
// permission_overwrites are empty and its `parent_id` points at its parent
|
|
163
|
+
// channel (not a category), so the normal "compute from this channel's
|
|
164
|
+
// overwrites" path would treat a thread as fully public. We refuse to count
|
|
165
|
+
// threads channel-scoped and fall back instead — fail-closed.
|
|
166
|
+
const DISCORD_THREAD_CHANNEL_TYPES: ReadonlySet<number> = new Set([10, 11, 12])
|
|
167
|
+
|
|
168
|
+
type DiscordChannelObject = {
|
|
169
|
+
type?: number
|
|
170
|
+
permission_overwrites?: DiscordPermissionOverwrite[]
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
type DiscordRole = {
|
|
174
|
+
id?: string
|
|
175
|
+
permissions?: string
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
type DiscordGuildObject = {
|
|
179
|
+
owner_id?: string
|
|
180
|
+
roles?: DiscordRole[]
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Discord permission bits. Discord serialises permission bitsets as decimal
|
|
184
|
+
// STRINGS that can exceed Number.MAX_SAFE_INTEGER, so all bit math is done in
|
|
185
|
+
// BigInt. Only the two bits this resolver needs are named.
|
|
186
|
+
const DISCORD_PERMISSION_VIEW_CHANNEL = 0x400n
|
|
187
|
+
const DISCORD_PERMISSION_ADMINISTRATOR = 0x8n
|
|
188
|
+
|
|
189
|
+
function parsePermissionBits(raw: string | undefined): bigint {
|
|
190
|
+
if (raw === undefined || raw === '') return 0n
|
|
191
|
+
try {
|
|
192
|
+
return BigInt(raw)
|
|
193
|
+
} catch {
|
|
194
|
+
return 0n
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Computes whether a single member can VIEW_CHANNEL on a normal guild channel,
|
|
199
|
+
// following Discord's documented resolution order:
|
|
200
|
+
// base = @everyone role perms OR all of the member's role perms
|
|
201
|
+
// → owner or ADMINISTRATOR short-circuits to visible
|
|
202
|
+
// → @everyone channel overwrite (clear deny bits, then set allow bits)
|
|
203
|
+
// → all matching role overwrites combined (OR every deny, OR every allow;
|
|
204
|
+
// apply denies then allows)
|
|
205
|
+
// → member-specific overwrite (clear deny, set allow)
|
|
206
|
+
// Returns null when the data is insufficient to decide (a member references a
|
|
207
|
+
// role absent from the guild role map), so the caller can fail closed rather
|
|
208
|
+
// than guess.
|
|
209
|
+
function memberCanViewChannel(args: {
|
|
210
|
+
guildId: string
|
|
211
|
+
ownerId: string | undefined
|
|
212
|
+
rolePermissions: ReadonlyMap<string, bigint>
|
|
213
|
+
overwrites: ReadonlyMap<string, { allow: bigint; deny: bigint }>
|
|
214
|
+
memberId: string | undefined
|
|
215
|
+
memberRoleIds: readonly string[]
|
|
216
|
+
}): boolean | null {
|
|
217
|
+
const { guildId, ownerId, rolePermissions, overwrites, memberId, memberRoleIds } = args
|
|
218
|
+
|
|
219
|
+
if (memberId !== undefined && ownerId !== undefined && memberId === ownerId) return true
|
|
220
|
+
|
|
221
|
+
// Discord's `@everyone` role id always equals the guild id, so the guild id
|
|
222
|
+
// keys both the base @everyone permissions and the @everyone overwrite.
|
|
223
|
+
let base = rolePermissions.get(guildId) ?? 0n
|
|
224
|
+
for (const roleId of memberRoleIds) {
|
|
225
|
+
const perms = rolePermissions.get(roleId)
|
|
226
|
+
if (perms === undefined) return null
|
|
227
|
+
base |= perms
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if ((base & DISCORD_PERMISSION_ADMINISTRATOR) !== 0n) return true
|
|
231
|
+
|
|
232
|
+
let perms = base
|
|
233
|
+
|
|
234
|
+
const everyoneOverwrite = overwrites.get(guildId)
|
|
235
|
+
if (everyoneOverwrite !== undefined) {
|
|
236
|
+
perms &= ~everyoneOverwrite.deny
|
|
237
|
+
perms |= everyoneOverwrite.allow
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let roleDeny = 0n
|
|
241
|
+
let roleAllow = 0n
|
|
242
|
+
for (const roleId of memberRoleIds) {
|
|
243
|
+
const overwrite = overwrites.get(roleId)
|
|
244
|
+
if (overwrite !== undefined) {
|
|
245
|
+
roleDeny |= overwrite.deny
|
|
246
|
+
roleAllow |= overwrite.allow
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
perms &= ~roleDeny
|
|
250
|
+
perms |= roleAllow
|
|
251
|
+
|
|
252
|
+
if (memberId !== undefined) {
|
|
253
|
+
const memberOverwrite = overwrites.get(memberId)
|
|
254
|
+
if (memberOverwrite !== undefined) {
|
|
255
|
+
perms &= ~memberOverwrite.deny
|
|
256
|
+
perms |= memberOverwrite.allow
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return (perms & DISCORD_PERMISSION_VIEW_CHANNEL) !== 0n
|
|
150
261
|
}
|
|
151
262
|
|
|
152
263
|
export function createDiscordMembershipResolver(deps: {
|
|
@@ -206,14 +317,136 @@ export function createDiscordMembershipResolver(deps: {
|
|
|
206
317
|
return members.failure
|
|
207
318
|
}
|
|
208
319
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
320
|
+
// Guild `/members` returns the whole guild, not the channel. A bot or
|
|
321
|
+
// human that cannot VIEW_CHANNEL the target channel is not in the room and
|
|
322
|
+
// not a prompt-injection surface, so it must not be counted — otherwise a
|
|
323
|
+
// private channel of "operator + agent bot" inside a multi-bot guild reads
|
|
324
|
+
// as bots>1 and the grant_role relaxation (provesOnlyAgentBotPresent)
|
|
325
|
+
// false-refuses. Resolve channel visibility for each member; on ANY
|
|
326
|
+
// inability to decide, fall back rather than over- or under-count.
|
|
327
|
+
// `key.thread ?? key.chat` mirrors the history callback's channel-id
|
|
328
|
+
// resolution: today Discord stores a thread's own snowflake in `chat` with
|
|
329
|
+
// `thread` null, but a future caller that passes `chat=parent,
|
|
330
|
+
// thread=threadId` must scope visibility to the thread, not the parent.
|
|
331
|
+
const scoped = await scopeMembersToChannel({
|
|
332
|
+
fetchFn,
|
|
333
|
+
token: deps.token,
|
|
334
|
+
logger: deps.logger,
|
|
335
|
+
guildId: key.workspace,
|
|
336
|
+
channelId: key.thread ?? key.chat,
|
|
337
|
+
members: members.value,
|
|
338
|
+
})
|
|
339
|
+
if (scoped === 'fallback') return await fallback()
|
|
340
|
+
|
|
341
|
+
return scoped.everyHumanIdentified
|
|
342
|
+
? {
|
|
343
|
+
humans: scoped.humans,
|
|
344
|
+
bots: scoped.bots,
|
|
345
|
+
fetchedAt: now(),
|
|
346
|
+
truncated: false,
|
|
347
|
+
humanMemberIds: scoped.humanMemberIds,
|
|
348
|
+
}
|
|
349
|
+
: { humans: scoped.humans, bots: scoped.bots, fetchedAt: now(), truncated: false }
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
type ScopedMembership = {
|
|
354
|
+
humans: number
|
|
355
|
+
bots: number
|
|
356
|
+
humanMemberIds: string[]
|
|
357
|
+
everyHumanIdentified: boolean
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Filters a guild member list down to those who can VIEW_CHANNEL the target
|
|
361
|
+
// channel, then counts humans/bots over that visible set. Returns 'fallback'
|
|
362
|
+
// when channel visibility cannot be computed completely (channel/guild fetch
|
|
363
|
+
// failure, a member referencing an unknown role, or a thread channel whose
|
|
364
|
+
// visibility this resolver does not model) — the caller then derives from
|
|
365
|
+
// history (truncated:true), keeping every security consumer fail-closed.
|
|
366
|
+
async function scopeMembersToChannel(args: {
|
|
367
|
+
fetchFn: typeof fetch
|
|
368
|
+
token: string
|
|
369
|
+
logger: DiscordBotAdapterLogger
|
|
370
|
+
guildId: string
|
|
371
|
+
channelId: string
|
|
372
|
+
members: readonly DiscordGuildMember[]
|
|
373
|
+
}): Promise<ScopedMembership | 'fallback'> {
|
|
374
|
+
const { fetchFn, token, logger, guildId, channelId, members } = args
|
|
375
|
+
|
|
376
|
+
const [channel, guild] = await Promise.all([
|
|
377
|
+
fetchDiscordJson<DiscordChannelObject>(fetchFn, `${DISCORD_API_BASE}/channels/${channelId}`, token),
|
|
378
|
+
fetchDiscordJson<DiscordGuildObject>(fetchFn, `${DISCORD_API_BASE}/guilds/${guildId}`, token),
|
|
379
|
+
])
|
|
380
|
+
if (!channel.ok) {
|
|
381
|
+
logger.warn(
|
|
382
|
+
`[discord-bot] membership channel=${channelId} fetch failed: ${channel.reason}; deriving from recent message authors`,
|
|
383
|
+
)
|
|
384
|
+
return 'fallback'
|
|
385
|
+
}
|
|
386
|
+
if (!guild.ok) {
|
|
387
|
+
logger.warn(
|
|
388
|
+
`[discord-bot] membership guild=${guildId} fetch failed: ${guild.reason}; deriving from recent message authors`,
|
|
389
|
+
)
|
|
390
|
+
return 'fallback'
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
if (channel.value.type !== undefined && DISCORD_THREAD_CHANNEL_TYPES.has(channel.value.type)) {
|
|
394
|
+
// A thread inherits visibility from its parent channel and (for private
|
|
395
|
+
// threads) an explicit member list; we do not model either here. Fall
|
|
396
|
+
// back rather than treat the thread as world-visible.
|
|
397
|
+
logger.warn(
|
|
398
|
+
`[discord-bot] membership channel=${channelId} is a thread; deriving from recent message authors instead of channel-scoped enumeration`,
|
|
399
|
+
)
|
|
400
|
+
return 'fallback'
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const rolePermissions = new Map<string, bigint>()
|
|
404
|
+
for (const role of guild.value.roles ?? []) {
|
|
405
|
+
if (role.id !== undefined) rolePermissions.set(role.id, parsePermissionBits(role.permissions))
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const overwrites = new Map<string, { allow: bigint; deny: bigint }>()
|
|
409
|
+
for (const overwrite of channel.value.permission_overwrites ?? []) {
|
|
410
|
+
if (overwrite.id === undefined) continue
|
|
411
|
+
overwrites.set(overwrite.id, {
|
|
412
|
+
allow: parsePermissionBits(overwrite.allow),
|
|
413
|
+
deny: parsePermissionBits(overwrite.deny),
|
|
414
|
+
})
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
let humans = 0
|
|
418
|
+
let bots = 0
|
|
419
|
+
const humanMemberIds: string[] = []
|
|
420
|
+
let everyHumanIdentified = true
|
|
421
|
+
|
|
422
|
+
for (const member of members) {
|
|
423
|
+
const visible = memberCanViewChannel({
|
|
424
|
+
guildId,
|
|
425
|
+
ownerId: guild.value.owner_id,
|
|
426
|
+
rolePermissions,
|
|
427
|
+
overwrites,
|
|
428
|
+
memberId: member.user?.id,
|
|
429
|
+
memberRoleIds: member.roles ?? [],
|
|
430
|
+
})
|
|
431
|
+
if (visible === null) {
|
|
432
|
+
logger.warn(
|
|
433
|
+
`[discord-bot] membership channel=${channelId} member references unknown role; deriving from recent message authors`,
|
|
434
|
+
)
|
|
435
|
+
return 'fallback'
|
|
436
|
+
}
|
|
437
|
+
if (!visible) continue
|
|
438
|
+
|
|
439
|
+
if (member.user?.bot === true) {
|
|
440
|
+
bots++
|
|
441
|
+
continue
|
|
214
442
|
}
|
|
215
|
-
|
|
443
|
+
humans++
|
|
444
|
+
const userId = member.user?.id
|
|
445
|
+
if (userId === undefined) everyHumanIdentified = false
|
|
446
|
+
else humanMemberIds.push(userId)
|
|
216
447
|
}
|
|
448
|
+
|
|
449
|
+
return { humans, bots, humanMemberIds, everyHumanIdentified }
|
|
217
450
|
}
|
|
218
451
|
|
|
219
452
|
type DiscordFetchResult<T> =
|
|
@@ -508,7 +741,9 @@ export function createInteractionHandler(
|
|
|
508
741
|
})
|
|
509
742
|
const replyContent =
|
|
510
743
|
result.kind === 'handled'
|
|
511
|
-
?
|
|
744
|
+
? // Dynamic commands (e.g. /help) carry their own reply; static
|
|
745
|
+
// control commands (/stop) fall back to the fixed confirmation.
|
|
746
|
+
(result.reply ?? STOP_REPLY_ABORTED)
|
|
512
747
|
: result.kind === 'no-live-session'
|
|
513
748
|
? STOP_REPLY_NO_LIVE_SESSION
|
|
514
749
|
: result.kind === 'permission-denied'
|
|
@@ -4,6 +4,7 @@ import type { InboundMessage } from '@/channels/types'
|
|
|
4
4
|
|
|
5
5
|
import type { DeliveryDedup } from './dedup'
|
|
6
6
|
import { isGithubEventAllowed } from './event-allowlist'
|
|
7
|
+
import { encodeGithubReactionRef, type GithubReactionTarget } from './reactions'
|
|
7
8
|
|
|
8
9
|
export type GithubInboundLogger = { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void }
|
|
9
10
|
|
|
@@ -13,8 +14,8 @@ export type GithubWebhookHandlerOptions = {
|
|
|
13
14
|
allowlist: () => readonly string[]
|
|
14
15
|
selfId: () => string | null
|
|
15
16
|
selfLogin: () => string | null
|
|
16
|
-
// Defaults to 'pat' when omitted.
|
|
17
|
-
//
|
|
17
|
+
// Defaults to 'pat' when omitted. In 'app' mode classifyReviewRequest also
|
|
18
|
+
// matches the App's decoy reviewer login; see resolveDecoyReviewerLogin.
|
|
18
19
|
authType?: () => 'pat' | 'app'
|
|
19
20
|
route: (message: InboundMessage) => void
|
|
20
21
|
logger: GithubInboundLogger
|
|
@@ -109,6 +110,7 @@ export function classifyGithubInbound(
|
|
|
109
110
|
user,
|
|
110
111
|
selfLogin,
|
|
111
112
|
comment.created_at,
|
|
113
|
+
{ kind: 'issue-comment', owner: repository.owner, repo: repository.name, commentId: id },
|
|
112
114
|
)
|
|
113
115
|
}
|
|
114
116
|
|
|
@@ -127,6 +129,7 @@ export function classifyGithubInbound(
|
|
|
127
129
|
readUser(comment.user),
|
|
128
130
|
selfLogin,
|
|
129
131
|
comment.created_at,
|
|
132
|
+
{ kind: 'pr-review-comment', owner: repository.owner, repo: repository.name, commentId: id },
|
|
130
133
|
)
|
|
131
134
|
}
|
|
132
135
|
|
|
@@ -144,6 +147,7 @@ export function classifyGithubInbound(
|
|
|
144
147
|
readUser(comment.user),
|
|
145
148
|
selfLogin,
|
|
146
149
|
comment.created_at,
|
|
150
|
+
null,
|
|
147
151
|
)
|
|
148
152
|
}
|
|
149
153
|
|
|
@@ -160,6 +164,7 @@ export function classifyGithubInbound(
|
|
|
160
164
|
readUser(issue.user),
|
|
161
165
|
selfLogin,
|
|
162
166
|
issue.created_at,
|
|
167
|
+
{ kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
|
|
163
168
|
)
|
|
164
169
|
}
|
|
165
170
|
|
|
@@ -178,17 +183,10 @@ export function classifyGithubInbound(
|
|
|
178
183
|
number,
|
|
179
184
|
base,
|
|
180
185
|
selfLogin,
|
|
186
|
+
authType: options?.authType ?? 'pat',
|
|
181
187
|
teamIsBotMember: options?.teamIsBotMember,
|
|
182
188
|
})
|
|
183
189
|
}
|
|
184
|
-
// A GitHub App cannot be added to a PR's requested_reviewers, so it never
|
|
185
|
-
// receives a review_requested event targeting itself. The opened event is
|
|
186
|
-
// the only signal it can act on, so in App mode an opened PR is promoted to
|
|
187
|
-
// a review request. A PAT-backed bot is a real user that can be requested,
|
|
188
|
-
// so it waits for the explicit request instead of reviewing every PR.
|
|
189
|
-
if (action === 'opened' && options?.authType === 'app') {
|
|
190
|
-
return classifyOpenedAsReview({ payload, pr, number, base, selfLogin })
|
|
191
|
-
}
|
|
192
190
|
return buildInbound(
|
|
193
191
|
{ ...base, chat: `pr:${number}`, thread: null },
|
|
194
192
|
pr.body,
|
|
@@ -196,6 +194,7 @@ export function classifyGithubInbound(
|
|
|
196
194
|
readUser(pr.user),
|
|
197
195
|
selfLogin,
|
|
198
196
|
pr.created_at,
|
|
197
|
+
{ kind: 'issue', owner: repository.owner, repo: repository.name, issueNumber: number },
|
|
199
198
|
)
|
|
200
199
|
}
|
|
201
200
|
|
|
@@ -213,6 +212,7 @@ export function classifyGithubInbound(
|
|
|
213
212
|
readUser(review.user),
|
|
214
213
|
selfLogin,
|
|
215
214
|
review.submitted_at,
|
|
215
|
+
null,
|
|
216
216
|
)
|
|
217
217
|
}
|
|
218
218
|
|
|
@@ -229,6 +229,7 @@ export function classifyGithubInbound(
|
|
|
229
229
|
readUser(discussion.user),
|
|
230
230
|
selfLogin,
|
|
231
231
|
discussion.created_at,
|
|
232
|
+
null,
|
|
232
233
|
)
|
|
233
234
|
}
|
|
234
235
|
|
|
@@ -242,23 +243,46 @@ type ReviewRequestInput = {
|
|
|
242
243
|
number: number
|
|
243
244
|
base: Pick<InboundMessage, 'adapter' | 'workspace' | 'isDm' | 'mentionsOthers' | 'replyToOtherMessageId'>
|
|
244
245
|
selfLogin: string | null
|
|
246
|
+
authType: 'pat' | 'app'
|
|
245
247
|
teamIsBotMember: boolean | undefined
|
|
246
248
|
}
|
|
247
249
|
|
|
250
|
+
// A GitHub App can never be a `requested_reviewer` — that field only holds
|
|
251
|
+
// real user accounts, and the App actor (`slug[bot]`) is not one. The
|
|
252
|
+
// supported workaround is a decoy user account named after the App that an
|
|
253
|
+
// operator requests instead (see docs/content/docs/internals/github-decoy-reviewer.mdx).
|
|
254
|
+
// Its login is, by convention, the App slug — i.e. `selfLogin` with the
|
|
255
|
+
// `[bot]` suffix removed (`my-app[bot]` → `my-app`). This is the single seam
|
|
256
|
+
// where that login is resolved: when the decoy account's real login diverges
|
|
257
|
+
// from the slug, a future config field replaces this derivation without
|
|
258
|
+
// touching the matcher. PAT auth has no decoy (the bot IS a real user that can
|
|
259
|
+
// be requested directly), so it returns null.
|
|
260
|
+
const BOT_LOGIN_SUFFIX = '[bot]'
|
|
261
|
+
|
|
262
|
+
function resolveDecoyReviewerLogin(selfLogin: string, authType: 'pat' | 'app'): string | null {
|
|
263
|
+
if (authType !== 'app') return null
|
|
264
|
+
if (!selfLogin.endsWith(BOT_LOGIN_SUFFIX)) return null
|
|
265
|
+
const slug = selfLogin.slice(0, -BOT_LOGIN_SUFFIX.length)
|
|
266
|
+
return slug !== '' ? slug : null
|
|
267
|
+
}
|
|
268
|
+
|
|
248
269
|
function classifyReviewRequest(input: ReviewRequestInput): InboundMessage | null {
|
|
249
|
-
const { action, payload, pr, number, base, selfLogin, teamIsBotMember } = input
|
|
270
|
+
const { action, payload, pr, number, base, selfLogin, authType, teamIsBotMember } = input
|
|
250
271
|
if (selfLogin === null) return null
|
|
272
|
+
const decoyLogin = resolveDecoyReviewerLogin(selfLogin, authType)
|
|
251
273
|
const sender = readUser(payload.sender)
|
|
252
274
|
if (sender === null) return null
|
|
253
|
-
// Self-loop guard: if the bot
|
|
275
|
+
// Self-loop guard: if the bot (or its decoy) requested/un-requested the
|
|
254
276
|
// review, drop the event. The bot adding itself as a reviewer would
|
|
255
277
|
// otherwise wake a fresh session every time it self-assigns.
|
|
256
|
-
if (sender.login === selfLogin) return null
|
|
278
|
+
if (sender.login === selfLogin || (decoyLogin !== null && sender.login === decoyLogin)) return null
|
|
257
279
|
|
|
258
280
|
const requestedUser = readUser(payload.requested_reviewer)
|
|
259
281
|
const requestedTeam = readReviewerTeam(payload.requested_team)
|
|
260
282
|
|
|
261
|
-
const isMeAsUser =
|
|
283
|
+
const isMeAsUser =
|
|
284
|
+
requestedUser !== null &&
|
|
285
|
+
(requestedUser.login === selfLogin || (decoyLogin !== null && requestedUser.login === decoyLogin))
|
|
262
286
|
const isMyTeam = requestedTeam !== null && teamIsBotMember === true
|
|
263
287
|
if (!isMeAsUser && !isMyTeam) return null
|
|
264
288
|
|
|
@@ -303,47 +327,6 @@ function classifyReviewRequest(input: ReviewRequestInput): InboundMessage | null
|
|
|
303
327
|
}
|
|
304
328
|
}
|
|
305
329
|
|
|
306
|
-
type OpenedAsReviewInput = {
|
|
307
|
-
payload: Record<string, unknown>
|
|
308
|
-
pr: Record<string, unknown>
|
|
309
|
-
number: number
|
|
310
|
-
base: Pick<InboundMessage, 'adapter' | 'workspace' | 'isDm' | 'mentionsOthers' | 'replyToOtherMessageId'>
|
|
311
|
-
selfLogin: string | null
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
function classifyOpenedAsReview(input: OpenedAsReviewInput): InboundMessage | null {
|
|
315
|
-
const { payload, pr, number, base, selfLogin } = input
|
|
316
|
-
if (selfLogin === null) return null
|
|
317
|
-
const sender = readUser(payload.sender)
|
|
318
|
-
if (sender === null) return null
|
|
319
|
-
if (sender.login === selfLogin) return null
|
|
320
|
-
|
|
321
|
-
const title = readString(pr, 'title') ?? `#${number}`
|
|
322
|
-
const head = readString(readRecord(pr.head), 'ref')
|
|
323
|
-
const baseRef = readString(readRecord(pr.base), 'ref')
|
|
324
|
-
const branchSegment = head !== null && baseRef !== null ? ` Branch: ${head} → ${baseRef}.` : ''
|
|
325
|
-
const text =
|
|
326
|
-
`@${sender.login} requested your review on PR #${number}: "${title}".${branchSegment}` +
|
|
327
|
-
' Please review the changes line-by-line and post your feedback.'
|
|
328
|
-
|
|
329
|
-
const updatedAt = readString(pr, 'updated_at') ?? ''
|
|
330
|
-
const prId = readNumber(pr, 'id') ?? number
|
|
331
|
-
|
|
332
|
-
return {
|
|
333
|
-
...base,
|
|
334
|
-
chat: `pr:${number}`,
|
|
335
|
-
thread: null,
|
|
336
|
-
text,
|
|
337
|
-
externalMessageId: `pr-${prId}-opened-${updatedAt}`,
|
|
338
|
-
authorId: String(sender.id),
|
|
339
|
-
authorName: sender.login,
|
|
340
|
-
authorIsBot: sender.type === 'Bot',
|
|
341
|
-
isBotMention: true,
|
|
342
|
-
replyToBotMessageId: null,
|
|
343
|
-
ts: updatedAt !== '' ? Date.parse(updatedAt) || 0 : 0,
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
330
|
export type GithubReviewerTeam = { slug: string; id: number; org: string | null }
|
|
348
331
|
|
|
349
332
|
export function readReviewerTeam(value: unknown): GithubReviewerTeam | null {
|
|
@@ -365,6 +348,7 @@ function buildInbound(
|
|
|
365
348
|
user: GithubUser | null,
|
|
366
349
|
selfLogin: string | null,
|
|
367
350
|
rawTs: unknown,
|
|
351
|
+
reactionTarget: GithubReactionTarget | null,
|
|
368
352
|
): InboundMessage | null {
|
|
369
353
|
if (user === null) return null
|
|
370
354
|
const text = typeof rawText === 'string' ? rawText : ''
|
|
@@ -372,6 +356,7 @@ function buildInbound(
|
|
|
372
356
|
...key,
|
|
373
357
|
text,
|
|
374
358
|
externalMessageId: String(id),
|
|
359
|
+
...(reactionTarget !== null ? { reactionRef: encodeGithubReactionRef(reactionTarget) } : {}),
|
|
375
360
|
authorId: String(user.id),
|
|
376
361
|
authorName: user.login,
|
|
377
362
|
authorIsBot: user.type === 'Bot',
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { GithubTokenBridge } from '@/channels/github-token-bridge'
|
|
1
2
|
import type { ChannelRouter } from '@/channels/router'
|
|
2
3
|
import type { ChannelAdapterConfig, GithubAdapterConfig } from '@/channels/schema'
|
|
3
4
|
import { resolveSecret } from '@/secrets/resolve'
|
|
@@ -18,6 +19,7 @@ import {
|
|
|
18
19
|
buildPermissionGuidance,
|
|
19
20
|
parseListHooksPermissionStatus,
|
|
20
21
|
} from './permission-guidance'
|
|
22
|
+
import { createGithubReactionCallback } from './reactions'
|
|
21
23
|
import { createTeamMembershipChecker } from './team-membership'
|
|
22
24
|
import { deregisterGithubWebhooks, registerGithubWebhooks, type WebhookRegistrationResult } from './webhook-register'
|
|
23
25
|
|
|
@@ -61,6 +63,12 @@ export type GithubAdapterOptions = {
|
|
|
61
63
|
// Test-only: replaces `setInterval` so tests can control when the
|
|
62
64
|
// background refresh fires without waiting on real wall-clock time.
|
|
63
65
|
setInterval?: (handler: () => void, ms: number) => { clear: () => void }
|
|
66
|
+
// Write-side of the GithubTokenBridge. On App-auth start the adapter
|
|
67
|
+
// registers a per-repo minter here so plugin hooks can resolve a token for
|
|
68
|
+
// ad-hoc `gh` commands; it unregisters on stop and on start rollback. PAT
|
|
69
|
+
// auth does not register (the seeded GH_TOKEN already covers every repo a
|
|
70
|
+
// classic PAT can reach, and a fine-grained PAT cannot be re-minted per repo).
|
|
71
|
+
githubTokenBridge?: GithubTokenBridge
|
|
64
72
|
}
|
|
65
73
|
|
|
66
74
|
export type GithubAdapter = {
|
|
@@ -93,6 +101,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
93
101
|
let started = false
|
|
94
102
|
let managedHooks: ReadonlyArray<{ repo: string; hookId: number }> = []
|
|
95
103
|
let tokenRefreshTimer: { clear: () => void } | null = null
|
|
104
|
+
let unregisterTokenBridge: (() => void) | null = null
|
|
96
105
|
const workspaceByChat = new Map<string, string>()
|
|
97
106
|
|
|
98
107
|
const rememberWorkspace = (workspace: string, chat: string): void => {
|
|
@@ -111,6 +120,11 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
111
120
|
logger,
|
|
112
121
|
fetchImpl,
|
|
113
122
|
})
|
|
123
|
+
const reaction = createGithubReactionCallback({
|
|
124
|
+
token: authToken,
|
|
125
|
+
authType: options.secrets.auth.type,
|
|
126
|
+
fetchImpl,
|
|
127
|
+
})
|
|
114
128
|
const history = createGithubHistoryCallback({
|
|
115
129
|
token: authToken,
|
|
116
130
|
fetchImpl,
|
|
@@ -153,6 +167,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
153
167
|
// Register all callbacks before binding the HTTP listener so the router
|
|
154
168
|
// is fully wired before any webhook can arrive.
|
|
155
169
|
options.router.registerOutbound('github', outbound)
|
|
170
|
+
options.router.registerReaction('github', reaction)
|
|
156
171
|
options.router.registerTyping('github', typing)
|
|
157
172
|
options.router.registerHistory('github', history)
|
|
158
173
|
options.router.registerMembership('github', membership)
|
|
@@ -164,6 +179,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
164
179
|
// Listener failed — roll back all registrations so stop() is a no-op
|
|
165
180
|
// and the manager can report the failure cleanly.
|
|
166
181
|
options.router.unregisterOutbound('github', outbound)
|
|
182
|
+
options.router.unregisterReaction('github', reaction)
|
|
167
183
|
options.router.unregisterTyping('github', typing)
|
|
168
184
|
options.router.unregisterHistory('github', history)
|
|
169
185
|
options.router.unregisterMembership('github', membership)
|
|
@@ -176,17 +192,15 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
176
192
|
throw err
|
|
177
193
|
}
|
|
178
194
|
started = true
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
189
|
-
if (ghTokenRepo !== null || options.secrets.auth.type === 'pat') {
|
|
195
|
+
// Seed the process-wide GH_TOKEN when it's unambiguous; skip otherwise.
|
|
196
|
+
// See ghTokenSeedDecision for why one owner is required. On skip, authToken
|
|
197
|
+
// still resolves a repo-scoped token per call for the adapter's own traffic.
|
|
198
|
+
const seed = ghTokenSeedDecision(options.secrets.auth.type, options.configRef().repos ?? [])
|
|
199
|
+
if (seed.kind === 'seed') {
|
|
200
|
+
const seedContext = seed.context
|
|
201
|
+
const seedGhToken = async (): Promise<void> => {
|
|
202
|
+
process.env.GH_TOKEN = await auth.token(seedContext)
|
|
203
|
+
}
|
|
190
204
|
await seedGhToken()
|
|
191
205
|
const tokenRefreshIntervalMs = options.tokenRefreshIntervalMs ?? DEFAULT_TOKEN_REFRESH_INTERVAL_MS
|
|
192
206
|
if (tokenRefreshIntervalMs > 0) {
|
|
@@ -207,10 +221,28 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
207
221
|
}
|
|
208
222
|
} else {
|
|
209
223
|
logger.info(
|
|
210
|
-
|
|
211
|
-
'Ad-hoc `gh` commands should set a repo-scoped token explicitly.',
|
|
224
|
+
`${GH_TOKEN_SKIP_LOG[seed.reason]} Ad-hoc \`gh\` commands should set a repo-scoped token explicitly.`,
|
|
212
225
|
)
|
|
213
226
|
}
|
|
227
|
+
if (options.secrets.auth.type === 'app' && options.githubTokenBridge !== undefined) {
|
|
228
|
+
// Gate ad-hoc `gh` minting on the configured repos[]. The slug arrives
|
|
229
|
+
// from an attacker-controllable -R/--repo flag (untrusted PR/issue
|
|
230
|
+
// content can prompt-inject it); without this an injected `-R any/repo`
|
|
231
|
+
// would mint an installation-wide token for any repo the App is installed
|
|
232
|
+
// on — a cross-tenant leak under a multi-owner App. Enforced here, not in
|
|
233
|
+
// the parser, because this adapter is the authority that owns repos[].
|
|
234
|
+
unregisterTokenBridge = options.githubTokenBridge.registerResolver((repoSlug) => {
|
|
235
|
+
const allowed = new Set((options.configRef().repos ?? []).map(canonicalRepoSlug))
|
|
236
|
+
if (!allowed.has(canonicalRepoSlug(repoSlug))) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
`repo \`${repoSlug}\` is not in this agent's configured \`channels.github.repos[]\`; ` +
|
|
239
|
+
'refusing to mint a GitHub App token for it. Target a configured repo, ' +
|
|
240
|
+
'or add it to `repos[]` if the agent is meant to operate there.',
|
|
241
|
+
)
|
|
242
|
+
}
|
|
243
|
+
return auth.token({ repoSlug })
|
|
244
|
+
})
|
|
245
|
+
}
|
|
214
246
|
logger.info(`[github] webhook listening on port ${options.configRef().webhookPort} as @${self.login}`)
|
|
215
247
|
// Best-effort: App-only preflight that compares the installation's granted
|
|
216
248
|
// permissions against the configured eventAllowlist and warns about gaps.
|
|
@@ -268,6 +300,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
268
300
|
if (!started) return
|
|
269
301
|
started = false
|
|
270
302
|
options.router.unregisterOutbound('github', outbound)
|
|
303
|
+
options.router.unregisterReaction('github', reaction)
|
|
271
304
|
options.router.unregisterTyping('github', typing)
|
|
272
305
|
options.router.unregisterHistory('github', history)
|
|
273
306
|
options.router.unregisterMembership('github', membership)
|
|
@@ -291,6 +324,10 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
291
324
|
tokenRefreshTimer.clear()
|
|
292
325
|
tokenRefreshTimer = null
|
|
293
326
|
}
|
|
327
|
+
if (unregisterTokenBridge !== null) {
|
|
328
|
+
unregisterTokenBridge()
|
|
329
|
+
unregisterTokenBridge = null
|
|
330
|
+
}
|
|
294
331
|
await auth.dispose()
|
|
295
332
|
delete process.env.GH_TOKEN
|
|
296
333
|
server = null
|
|
@@ -427,11 +464,45 @@ function logDeregistrationOutcome(
|
|
|
427
464
|
}
|
|
428
465
|
}
|
|
429
466
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
467
|
+
type GhTokenSeedDecision =
|
|
468
|
+
| { kind: 'seed'; context?: GithubAuthContext }
|
|
469
|
+
| { kind: 'skip'; reason: 'no-repos' | 'multiple-owners' }
|
|
470
|
+
|
|
471
|
+
const GH_TOKEN_SKIP_LOG: Record<'no-repos' | 'multiple-owners', string> = {
|
|
472
|
+
'no-repos':
|
|
473
|
+
'[github] no repos[] configured; GH_TOKEN not seeded globally (cannot prove which App installation to use).',
|
|
474
|
+
'multiple-owners': '[github] repos span multiple owners (multiple App installations); GH_TOKEN not seeded globally.',
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Decides how to seed the process-wide GH_TOKEN. PATs aren't installation-scoped
|
|
478
|
+
// (seed context-free). For App auth we seed from a configured repo slug, which
|
|
479
|
+
// resolves the installation via repos/{owner}/{repo}/installation — the only
|
|
480
|
+
// lookup that works for both org- and user-owned repos. One owner is required:
|
|
481
|
+
// no-repos can't prove an installation, multi-owner needs >1 token.
|
|
482
|
+
function ghTokenSeedDecision(authType: 'pat' | 'app', repos: readonly string[]): GhTokenSeedDecision {
|
|
483
|
+
if (authType === 'pat') return { kind: 'seed' }
|
|
484
|
+
const slugs = [...new Set(repos.filter(isWellFormedSlug))].sort()
|
|
485
|
+
if (slugs.length === 0) return { kind: 'skip', reason: 'no-repos' }
|
|
486
|
+
const owners = new Set(slugs.map((slug) => slug.split('/')[0]))
|
|
487
|
+
if (owners.size > 1) return { kind: 'skip', reason: 'multiple-owners' }
|
|
488
|
+
return { kind: 'seed', context: { repoSlug: slugs[0] } }
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function isWellFormedSlug(repo: string): boolean {
|
|
492
|
+
const [owner, name, ...rest] = repo.split('/')
|
|
493
|
+
return owner !== undefined && owner !== '' && name !== undefined && name !== '' && rest.length === 0
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Canonical form for repos[] allowlist comparison so the gate can't be bypassed
|
|
497
|
+
// by case, a trailing slash, or a `.git` suffix (GitHub treats owner/name
|
|
498
|
+
// case-insensitively). Applied identically to both configured repos[] and the
|
|
499
|
+
// runtime slug before exact Set membership.
|
|
500
|
+
function canonicalRepoSlug(repo: string): string {
|
|
501
|
+
return repo
|
|
502
|
+
.trim()
|
|
503
|
+
.replace(/\/+$/, '')
|
|
504
|
+
.replace(/\.git$/i, '')
|
|
505
|
+
.toLowerCase()
|
|
435
506
|
}
|
|
436
507
|
|
|
437
508
|
function defaultSleep(ms: number): Promise<void> {
|
|
@@ -28,6 +28,10 @@ export function createGithubMembershipResolver(options: {
|
|
|
28
28
|
if (user.type === 'Bot') bots++
|
|
29
29
|
else humans++
|
|
30
30
|
}
|
|
31
|
+
// Counts only, no humanMemberIds: GitHub membership is the repo
|
|
32
|
+
// collaborator list, a different population from the authors that can
|
|
33
|
+
// comment into a PR/issue turn, so it is not a basis for the channel
|
|
34
|
+
// grant-role relaxation (see provesOnlyAgentBotPresent in grant-role.ts).
|
|
31
35
|
return { humans, bots, fetchedAt: Date.now(), truncated: users.length >= 100 }
|
|
32
36
|
} catch {
|
|
33
37
|
return { kind: 'transient' }
|