typeclaw 0.17.0 → 0.19.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/auth.schema.json +0 -5
- package/package.json +2 -2
- package/secrets.schema.json +0 -5
- package/src/agent/index.ts +2 -1
- package/src/agent/model-overrides.ts +77 -0
- package/src/agent/plugin-tools.ts +53 -4
- package/src/agent/tools/grant-role.ts +102 -8
- 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-classify.ts +23 -0
- package/src/channels/adapters/discord-bot.ts +22 -4
- package/src/channels/adapters/github/auth-app.ts +49 -26
- package/src/channels/adapters/github/auth-pat.ts +3 -3
- package/src/channels/adapters/github/auth.ts +19 -5
- package/src/channels/adapters/github/channel-resolver.ts +3 -2
- package/src/channels/adapters/github/history.ts +3 -2
- package/src/channels/adapters/github/inbound.ts +30 -55
- package/src/channels/adapters/github/index.ts +147 -43
- package/src/channels/adapters/github/membership.ts +7 -2
- package/src/channels/adapters/github/outbound.ts +6 -2
- package/src/channels/adapters/github/team-membership.ts +4 -2
- package/src/channels/adapters/github/webhook-register.ts +19 -16
- package/src/channels/adapters/slack-bot-slash-commands.ts +78 -1
- package/src/channels/adapters/slack-bot.ts +119 -18
- package/src/channels/commands.ts +10 -0
- package/src/channels/engagement.ts +34 -3
- 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 +155 -37
- package/src/cli/channel.ts +0 -12
- package/src/cli/init.ts +0 -9
- package/src/cli/ui.ts +6 -0
- package/src/commands/index.ts +54 -4
- package/src/init/dockerfile.ts +60 -0
- package/src/init/github-webhook-install.ts +1 -2
- package/src/init/index.ts +4 -10
- package/src/init/validate-api-key.ts +15 -1
- 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 +6 -0
- package/src/secrets/schema.ts +0 -1
- package/src/server/command-runner.ts +14 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +70 -43
|
@@ -1,9 +1,10 @@
|
|
|
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'
|
|
4
5
|
import type { GithubSecretsBlock } from '@/secrets/schema'
|
|
5
6
|
|
|
6
|
-
import { buildAuthStrategy } from './auth'
|
|
7
|
+
import { buildAuthStrategy, type GithubAuthContext } from './auth'
|
|
7
8
|
import { createGithubChannelNameResolver } from './channel-resolver'
|
|
8
9
|
import { createDeliveryDedup } from './dedup'
|
|
9
10
|
import { findPermissionGaps } from './event-permissions'
|
|
@@ -61,6 +62,12 @@ export type GithubAdapterOptions = {
|
|
|
61
62
|
// Test-only: replaces `setInterval` so tests can control when the
|
|
62
63
|
// background refresh fires without waiting on real wall-clock time.
|
|
63
64
|
setInterval?: (handler: () => void, ms: number) => { clear: () => void }
|
|
65
|
+
// Write-side of the GithubTokenBridge. On App-auth start the adapter
|
|
66
|
+
// registers a per-repo minter here so plugin hooks can resolve a token for
|
|
67
|
+
// ad-hoc `gh` commands; it unregisters on stop and on start rollback. PAT
|
|
68
|
+
// auth does not register (the seeded GH_TOKEN already covers every repo a
|
|
69
|
+
// classic PAT can reach, and a fine-grained PAT cannot be re-minted per repo).
|
|
70
|
+
githubTokenBridge?: GithubTokenBridge
|
|
64
71
|
}
|
|
65
72
|
|
|
66
73
|
export type GithubAdapter = {
|
|
@@ -93,35 +100,37 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
93
100
|
let started = false
|
|
94
101
|
let managedHooks: ReadonlyArray<{ repo: string; hookId: number }> = []
|
|
95
102
|
let tokenRefreshTimer: { clear: () => void } | null = null
|
|
103
|
+
let unregisterTokenBridge: (() => void) | null = null
|
|
96
104
|
const workspaceByChat = new Map<string, string>()
|
|
97
105
|
|
|
98
106
|
const rememberWorkspace = (workspace: string, chat: string): void => {
|
|
99
107
|
workspaceByChat.set(chat, workspace)
|
|
100
108
|
}
|
|
101
109
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
110
|
+
// Repo/owner-aware token resolver. A single GitHub App can span multiple
|
|
111
|
+
// installations (one per owner); each consumer passes its repo/owner so the
|
|
112
|
+
// right installation token is minted. Unlike the old single-token path, this
|
|
113
|
+
// does NOT mutate process.env.GH_TOKEN — that global is seeded separately and
|
|
114
|
+
// only when exactly one installation applies (see seedGhTokenIfSingle).
|
|
115
|
+
const authToken = (context?: GithubAuthContext) => auth.token(context)
|
|
107
116
|
const outbound = createGithubOutboundCallback({
|
|
108
|
-
token:
|
|
117
|
+
token: authToken,
|
|
109
118
|
authType: options.secrets.auth.type,
|
|
110
119
|
logger,
|
|
111
120
|
fetchImpl,
|
|
112
121
|
})
|
|
113
122
|
const history = createGithubHistoryCallback({
|
|
114
|
-
token:
|
|
123
|
+
token: authToken,
|
|
115
124
|
fetchImpl,
|
|
116
125
|
workspaceForChat: (chat) => workspaceByChat.get(chat) ?? null,
|
|
117
126
|
})
|
|
118
|
-
const membership = createGithubMembershipResolver({ token:
|
|
119
|
-
const channelNameResolver = createGithubChannelNameResolver({ token:
|
|
127
|
+
const membership = createGithubMembershipResolver({ token: authToken, fetchImpl })
|
|
128
|
+
const channelNameResolver = createGithubChannelNameResolver({ token: authToken, fetchImpl })
|
|
120
129
|
const fetchAttachment = createGithubFetchAttachmentCallback()
|
|
121
130
|
// No-op typing callback: GitHub has no typing indicator API.
|
|
122
131
|
const typing = async (): Promise<void> => {}
|
|
123
132
|
const dedup = createDeliveryDedup()
|
|
124
|
-
const isBotInTeam = createTeamMembershipChecker({ token:
|
|
133
|
+
const isBotInTeam = createTeamMembershipChecker({ token: authToken, fetchImpl })
|
|
125
134
|
const handler = createGithubWebhookHandler({
|
|
126
135
|
webhookSecret,
|
|
127
136
|
dedup,
|
|
@@ -174,35 +183,64 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
174
183
|
selfLogin = null
|
|
175
184
|
throw err
|
|
176
185
|
}
|
|
177
|
-
// Seed GH_TOKEN so `gh` CLI calls in the container are pre-authenticated.
|
|
178
|
-
// tokenFn keeps it current on every adapter API call; App tokens refresh
|
|
179
|
-
// automatically when within 5 minutes of expiry.
|
|
180
|
-
process.env.GH_TOKEN = await auth.token()
|
|
181
186
|
started = true
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
const
|
|
186
|
-
if (
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
187
|
+
// Seed the process-wide GH_TOKEN when it's unambiguous; skip otherwise.
|
|
188
|
+
// See ghTokenSeedDecision for why one owner is required. On skip, authToken
|
|
189
|
+
// still resolves a repo-scoped token per call for the adapter's own traffic.
|
|
190
|
+
const seed = ghTokenSeedDecision(options.secrets.auth.type, options.configRef().repos ?? [])
|
|
191
|
+
if (seed.kind === 'seed') {
|
|
192
|
+
const seedContext = seed.context
|
|
193
|
+
const seedGhToken = async (): Promise<void> => {
|
|
194
|
+
process.env.GH_TOKEN = await auth.token(seedContext)
|
|
195
|
+
}
|
|
196
|
+
await seedGhToken()
|
|
197
|
+
const tokenRefreshIntervalMs = options.tokenRefreshIntervalMs ?? DEFAULT_TOKEN_REFRESH_INTERVAL_MS
|
|
198
|
+
if (tokenRefreshIntervalMs > 0) {
|
|
199
|
+
const refresh = () => {
|
|
200
|
+
seedGhToken().catch((err) => {
|
|
201
|
+
logger.error(
|
|
202
|
+
`[github] periodic token refresh failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
203
|
+
)
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
const setIntervalFn =
|
|
207
|
+
options.setInterval ??
|
|
208
|
+
((handler: () => void, ms: number) => {
|
|
209
|
+
const timer = setInterval(handler, ms)
|
|
210
|
+
return { clear: () => clearInterval(timer) }
|
|
211
|
+
})
|
|
212
|
+
tokenRefreshTimer = setIntervalFn(refresh, tokenRefreshIntervalMs)
|
|
191
213
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
214
|
+
} else {
|
|
215
|
+
logger.info(
|
|
216
|
+
`${GH_TOKEN_SKIP_LOG[seed.reason]} Ad-hoc \`gh\` commands should set a repo-scoped token explicitly.`,
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
if (options.secrets.auth.type === 'app' && options.githubTokenBridge !== undefined) {
|
|
220
|
+
// Gate ad-hoc `gh` minting on the configured repos[]. The slug arrives
|
|
221
|
+
// from an attacker-controllable -R/--repo flag (untrusted PR/issue
|
|
222
|
+
// content can prompt-inject it); without this an injected `-R any/repo`
|
|
223
|
+
// would mint an installation-wide token for any repo the App is installed
|
|
224
|
+
// on — a cross-tenant leak under a multi-owner App. Enforced here, not in
|
|
225
|
+
// the parser, because this adapter is the authority that owns repos[].
|
|
226
|
+
unregisterTokenBridge = options.githubTokenBridge.registerResolver((repoSlug) => {
|
|
227
|
+
const allowed = new Set((options.configRef().repos ?? []).map(canonicalRepoSlug))
|
|
228
|
+
if (!allowed.has(canonicalRepoSlug(repoSlug))) {
|
|
229
|
+
throw new Error(
|
|
230
|
+
`repo \`${repoSlug}\` is not in this agent's configured \`channels.github.repos[]\`; ` +
|
|
231
|
+
'refusing to mint a GitHub App token for it. Target a configured repo, ' +
|
|
232
|
+
'or add it to `repos[]` if the agent is meant to operate there.',
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
return auth.token({ repoSlug })
|
|
236
|
+
})
|
|
199
237
|
}
|
|
200
238
|
logger.info(`[github] webhook listening on port ${options.configRef().webhookPort} as @${self.login}`)
|
|
201
239
|
// Best-effort: App-only preflight that compares the installation's granted
|
|
202
240
|
// permissions against the configured eventAllowlist and warns about gaps.
|
|
203
241
|
// Catches the most common misconfiguration (App installed with the default
|
|
204
242
|
// metadata-only permission set) before any event fires a 403.
|
|
205
|
-
await runAppPermissionPreflight(logger, auth, options.configRef().eventAllowlist)
|
|
243
|
+
await runAppPermissionPreflight(logger, auth, options.configRef().eventAllowlist, options.configRef().repos ?? [])
|
|
206
244
|
// Repository webhook registration is best-effort: failures are logged
|
|
207
245
|
// per-repo, the adapter stays up. A misconfigured PAT or App that
|
|
208
246
|
// can't manage hooks must not prevent the adapter from accepting
|
|
@@ -225,6 +263,9 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
225
263
|
})
|
|
226
264
|
} else if (repos.length > 0) {
|
|
227
265
|
const legacyProviderHostSuffix = detectLegacyProviderHostSuffix(effectiveUrl)
|
|
266
|
+
logger.info(
|
|
267
|
+
`[github] registering webhook for ${repos.length} repo(s) [${repos.join(', ')}] -> ${effectiveUrl} (events: ${cfg.eventAllowlist.join(', ')})`,
|
|
268
|
+
)
|
|
228
269
|
if (webhookRegistrationDelayMs > 0) {
|
|
229
270
|
logger.info(
|
|
230
271
|
`[github] waiting ${webhookRegistrationDelayMs}ms before registering webhook so the Cloudflare edge can warm up`,
|
|
@@ -232,7 +273,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
232
273
|
await sleep(webhookRegistrationDelayMs)
|
|
233
274
|
}
|
|
234
275
|
const registration = await registerGithubWebhooks({
|
|
235
|
-
token:
|
|
276
|
+
token: (repoSlug: string) => auth.token({ repoSlug }),
|
|
236
277
|
webhookUrl: effectiveUrl,
|
|
237
278
|
webhookSecret,
|
|
238
279
|
repos,
|
|
@@ -263,7 +304,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
263
304
|
// last to clear the cached App-installation token.
|
|
264
305
|
if (managedHooks.length > 0) {
|
|
265
306
|
const deregistration = await deregisterGithubWebhooks({
|
|
266
|
-
token:
|
|
307
|
+
token: (repoSlug: string) => auth.token({ repoSlug }),
|
|
267
308
|
hooks: managedHooks,
|
|
268
309
|
fetchImpl,
|
|
269
310
|
})
|
|
@@ -274,6 +315,10 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
274
315
|
tokenRefreshTimer.clear()
|
|
275
316
|
tokenRefreshTimer = null
|
|
276
317
|
}
|
|
318
|
+
if (unregisterTokenBridge !== null) {
|
|
319
|
+
unregisterTokenBridge()
|
|
320
|
+
unregisterTokenBridge = null
|
|
321
|
+
}
|
|
277
322
|
await auth.dispose()
|
|
278
323
|
delete process.env.GH_TOKEN
|
|
279
324
|
server = null
|
|
@@ -367,18 +412,36 @@ async function runAppPermissionPreflight(
|
|
|
367
412
|
logger: GithubAdapterLogger,
|
|
368
413
|
auth: ReturnType<typeof buildAuthStrategy>,
|
|
369
414
|
eventAllowlist: readonly string[],
|
|
415
|
+
repos: readonly string[],
|
|
370
416
|
): Promise<void> {
|
|
371
417
|
if (auth.getInstallationGrants === undefined) return
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
418
|
+
const getGrants = (context: GithubAuthContext | undefined) => auth.getInstallationGrants?.(context)
|
|
419
|
+
// One grants check per distinct owner: installations are owner-scoped, so
|
|
420
|
+
// repos sharing an owner share an installation. The first repo per owner is
|
|
421
|
+
// the resolution key. With no repos, fall back to a single context-free check.
|
|
422
|
+
const reposByOwner = new Map<string, string>()
|
|
423
|
+
for (const repo of repos) {
|
|
424
|
+
const owner = repo.split('/')[0]
|
|
425
|
+
if (owner !== undefined && owner !== '' && !reposByOwner.has(owner)) reposByOwner.set(owner, repo)
|
|
426
|
+
}
|
|
427
|
+
const contexts: Array<{ label: string; context: { repoSlug: string } | undefined }> =
|
|
428
|
+
reposByOwner.size === 0
|
|
429
|
+
? [{ label: 'app', context: undefined }]
|
|
430
|
+
: [...reposByOwner.values()].map((repo) => ({ label: repo, context: { repoSlug: repo } }))
|
|
431
|
+
for (const { label, context } of contexts) {
|
|
432
|
+
let grants
|
|
433
|
+
try {
|
|
434
|
+
grants = await getGrants(context)
|
|
435
|
+
} catch (err) {
|
|
436
|
+
logger.warn(
|
|
437
|
+
`[github] permission preflight skipped for ${label}: ${err instanceof Error ? err.message : String(err)}`,
|
|
438
|
+
)
|
|
439
|
+
continue
|
|
440
|
+
}
|
|
441
|
+
if (grants === undefined) continue
|
|
442
|
+
const gaps = findPermissionGaps(eventAllowlist, grants.permissions)
|
|
443
|
+
if (gaps.length > 0) logger.warn(buildAppPermissionPreflightGuidance(gaps))
|
|
378
444
|
}
|
|
379
|
-
const gaps = findPermissionGaps(eventAllowlist, grants.permissions)
|
|
380
|
-
if (gaps.length === 0) return
|
|
381
|
-
logger.warn(buildAppPermissionPreflightGuidance(gaps))
|
|
382
445
|
}
|
|
383
446
|
|
|
384
447
|
function logDeregistrationOutcome(
|
|
@@ -392,6 +455,47 @@ function logDeregistrationOutcome(
|
|
|
392
455
|
}
|
|
393
456
|
}
|
|
394
457
|
|
|
458
|
+
type GhTokenSeedDecision =
|
|
459
|
+
| { kind: 'seed'; context?: GithubAuthContext }
|
|
460
|
+
| { kind: 'skip'; reason: 'no-repos' | 'multiple-owners' }
|
|
461
|
+
|
|
462
|
+
const GH_TOKEN_SKIP_LOG: Record<'no-repos' | 'multiple-owners', string> = {
|
|
463
|
+
'no-repos':
|
|
464
|
+
'[github] no repos[] configured; GH_TOKEN not seeded globally (cannot prove which App installation to use).',
|
|
465
|
+
'multiple-owners': '[github] repos span multiple owners (multiple App installations); GH_TOKEN not seeded globally.',
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Decides how to seed the process-wide GH_TOKEN. PATs aren't installation-scoped
|
|
469
|
+
// (seed context-free). For App auth we seed from a configured repo slug, which
|
|
470
|
+
// resolves the installation via repos/{owner}/{repo}/installation — the only
|
|
471
|
+
// lookup that works for both org- and user-owned repos. One owner is required:
|
|
472
|
+
// no-repos can't prove an installation, multi-owner needs >1 token.
|
|
473
|
+
function ghTokenSeedDecision(authType: 'pat' | 'app', repos: readonly string[]): GhTokenSeedDecision {
|
|
474
|
+
if (authType === 'pat') return { kind: 'seed' }
|
|
475
|
+
const slugs = [...new Set(repos.filter(isWellFormedSlug))].sort()
|
|
476
|
+
if (slugs.length === 0) return { kind: 'skip', reason: 'no-repos' }
|
|
477
|
+
const owners = new Set(slugs.map((slug) => slug.split('/')[0]))
|
|
478
|
+
if (owners.size > 1) return { kind: 'skip', reason: 'multiple-owners' }
|
|
479
|
+
return { kind: 'seed', context: { repoSlug: slugs[0] } }
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function isWellFormedSlug(repo: string): boolean {
|
|
483
|
+
const [owner, name, ...rest] = repo.split('/')
|
|
484
|
+
return owner !== undefined && owner !== '' && name !== undefined && name !== '' && rest.length === 0
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Canonical form for repos[] allowlist comparison so the gate can't be bypassed
|
|
488
|
+
// by case, a trailing slash, or a `.git` suffix (GitHub treats owner/name
|
|
489
|
+
// case-insensitively). Applied identically to both configured repos[] and the
|
|
490
|
+
// runtime slug before exact Set membership.
|
|
491
|
+
function canonicalRepoSlug(repo: string): string {
|
|
492
|
+
return repo
|
|
493
|
+
.trim()
|
|
494
|
+
.replace(/\/+$/, '')
|
|
495
|
+
.replace(/\.git$/i, '')
|
|
496
|
+
.toLowerCase()
|
|
497
|
+
}
|
|
498
|
+
|
|
395
499
|
function defaultSleep(ms: number): Promise<void> {
|
|
396
500
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
397
501
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { MembershipResolver, MembershipResolverResult } from '@/channels/membership'
|
|
2
2
|
|
|
3
|
+
import type { GithubAuthContext } from './auth'
|
|
3
4
|
import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
|
|
4
5
|
import { parseRepo } from './outbound'
|
|
5
6
|
|
|
6
7
|
export function createGithubMembershipResolver(options: {
|
|
7
|
-
token: () => Promise<string>
|
|
8
|
+
token: (context?: GithubAuthContext) => Promise<string>
|
|
8
9
|
fetchImpl?: typeof fetch
|
|
9
10
|
}): MembershipResolver {
|
|
10
11
|
const fetchImpl = options.fetchImpl ?? fetch
|
|
@@ -16,7 +17,7 @@ export function createGithubMembershipResolver(options: {
|
|
|
16
17
|
const response = await fetchImpl(
|
|
17
18
|
`${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/collaborators?per_page=100`,
|
|
18
19
|
{
|
|
19
|
-
headers: githubJsonHeaders(await options.token()),
|
|
20
|
+
headers: githubJsonHeaders(await options.token({ repoSlug: key.workspace })),
|
|
20
21
|
},
|
|
21
22
|
)
|
|
22
23
|
if (!response.ok) return response.status >= 500 ? { kind: 'transient' } : { kind: 'permanent' }
|
|
@@ -27,6 +28,10 @@ export function createGithubMembershipResolver(options: {
|
|
|
27
28
|
if (user.type === 'Bot') bots++
|
|
28
29
|
else humans++
|
|
29
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).
|
|
30
35
|
return { humans, bots, fetchedAt: Date.now(), truncated: users.length >= 100 }
|
|
31
36
|
} catch {
|
|
32
37
|
return { kind: 'transient' }
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { OutboundCallback, OutboundMessage, SendResult } from '@/channels/types'
|
|
2
2
|
|
|
3
|
+
import type { GithubAuthContext } from './auth'
|
|
3
4
|
import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
|
|
4
5
|
import {
|
|
5
6
|
buildOutboundPermissionGuidance,
|
|
@@ -11,7 +12,7 @@ import {
|
|
|
11
12
|
export type GithubOutboundLogger = { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void }
|
|
12
13
|
|
|
13
14
|
export function createGithubOutboundCallback(deps: {
|
|
14
|
-
token: () => Promise<string>
|
|
15
|
+
token: (context?: GithubAuthContext) => Promise<string>
|
|
15
16
|
authType: GithubAuthType
|
|
16
17
|
logger: GithubOutboundLogger
|
|
17
18
|
fetchImpl?: typeof fetch
|
|
@@ -28,9 +29,12 @@ export function createGithubOutboundCallback(deps: {
|
|
|
28
29
|
const target = parseChat(msg.chat)
|
|
29
30
|
if (target === null) return { ok: false, error: `invalid GitHub chat: ${msg.chat}` }
|
|
30
31
|
|
|
32
|
+
const token = () => deps.token({ repoSlug: msg.workspace })
|
|
33
|
+
|
|
31
34
|
if (target.kind === 'discussion') {
|
|
32
35
|
return await postDiscussionComment({
|
|
33
36
|
...deps,
|
|
37
|
+
token,
|
|
34
38
|
fetchImpl,
|
|
35
39
|
repo,
|
|
36
40
|
discussionNumber: target.number,
|
|
@@ -44,7 +48,7 @@ export function createGithubOutboundCallback(deps: {
|
|
|
44
48
|
: `${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/issues/${target.number}/comments`
|
|
45
49
|
return await postJson(
|
|
46
50
|
fetchImpl,
|
|
47
|
-
await
|
|
51
|
+
await token(),
|
|
48
52
|
endpoint,
|
|
49
53
|
{ body },
|
|
50
54
|
{
|
|
@@ -7,12 +7,14 @@
|
|
|
7
7
|
// request rebuilds it). Errors fall closed (return false): we'd rather drop
|
|
8
8
|
// a real review request than wake the agent on a team the bot isn't in.
|
|
9
9
|
|
|
10
|
+
import type { GithubAuthContext } from './auth'
|
|
11
|
+
|
|
10
12
|
const ACTIVE_MEMBERSHIP_STATE = 'active'
|
|
11
13
|
|
|
12
14
|
export type TeamMembershipChecker = (input: { org: string; slug: string; login: string }) => Promise<boolean>
|
|
13
15
|
|
|
14
16
|
export function createTeamMembershipChecker(options: {
|
|
15
|
-
token: () => Promise<string>
|
|
17
|
+
token: (context?: GithubAuthContext) => Promise<string>
|
|
16
18
|
fetchImpl?: typeof fetch
|
|
17
19
|
}): TeamMembershipChecker {
|
|
18
20
|
const fetchImpl = options.fetchImpl ?? fetch
|
|
@@ -23,7 +25,7 @@ export function createTeamMembershipChecker(options: {
|
|
|
23
25
|
const cached = cache.get(key)
|
|
24
26
|
if (cached !== undefined) return cached
|
|
25
27
|
|
|
26
|
-
const result = await lookup(fetchImpl, await options.token(), org, slug, login)
|
|
28
|
+
const result = await lookup(fetchImpl, await options.token({ owner: org }), org, slug, login)
|
|
27
29
|
cache.set(key, result)
|
|
28
30
|
return result
|
|
29
31
|
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
|
|
2
2
|
|
|
3
3
|
export type RegisterGithubWebhooksOptions = {
|
|
4
|
-
token
|
|
4
|
+
// Resolves an installation token scoped to the given "owner/name" repo. A
|
|
5
|
+
// single GitHub App may span multiple owners (separate installations), so
|
|
6
|
+
// each repo's hook must be created/listed with that repo's own token.
|
|
7
|
+
token: (repoSlug: string) => Promise<string>
|
|
5
8
|
webhookUrl: string
|
|
6
9
|
webhookSecret: string
|
|
7
10
|
repos: readonly string[]
|
|
@@ -51,22 +54,22 @@ export async function registerGithubWebhooks(
|
|
|
51
54
|
options: RegisterGithubWebhooksOptions,
|
|
52
55
|
): Promise<WebhookRegistrationResult> {
|
|
53
56
|
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
57
|
const repos: WebhookRepoResult[] = []
|
|
62
58
|
for (const repo of options.repos) {
|
|
59
|
+
let token: string
|
|
60
|
+
try {
|
|
61
|
+
token = await options.token(repo)
|
|
62
|
+
} catch (err) {
|
|
63
|
+
repos.push({ repo, action: 'failed', error: describe(err) })
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
63
66
|
repos.push(await registerOne(fetchImpl, token, repo, options))
|
|
64
67
|
}
|
|
65
68
|
return { repos }
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
export type DeregisterGithubWebhooksOptions = {
|
|
69
|
-
token: () => Promise<string>
|
|
72
|
+
token: (repoSlug: string) => Promise<string>
|
|
70
73
|
hooks: ReadonlyArray<{ repo: string; hookId: number }>
|
|
71
74
|
fetchImpl?: typeof fetch
|
|
72
75
|
}
|
|
@@ -79,15 +82,15 @@ export async function deregisterGithubWebhooks(
|
|
|
79
82
|
options: DeregisterGithubWebhooksOptions,
|
|
80
83
|
): Promise<WebhookDeregistrationResult> {
|
|
81
84
|
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
85
|
const hooks: WebhookDeregistrationResult['hooks'] = []
|
|
90
86
|
for (const hook of options.hooks) {
|
|
87
|
+
let token: string
|
|
88
|
+
try {
|
|
89
|
+
token = await options.token(hook.repo)
|
|
90
|
+
} catch (err) {
|
|
91
|
+
hooks.push({ ...hook, action: 'failed', error: describe(err) })
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
91
94
|
hooks.push(await deleteOne(fetchImpl, token, hook))
|
|
92
95
|
}
|
|
93
96
|
return { hooks }
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { SlackSocketModeSlashCommandArgs } from 'agent-messenger/slackbot'
|
|
2
2
|
|
|
3
|
+
import type { ExecuteCommandResult } from '@/channels/router'
|
|
3
4
|
import type { ChannelKey } from '@/channels/types'
|
|
4
5
|
|
|
5
6
|
// Slack channel ids: 'C' = public, 'G' = private/legacy multi-party DM,
|
|
@@ -64,13 +65,89 @@ export function parseSlashCommand(
|
|
|
64
65
|
}
|
|
65
66
|
}
|
|
66
67
|
|
|
68
|
+
// Slack blocks native slash commands inside threads ("/stop is not supported
|
|
69
|
+
// in threads. Sorry!"), so the only way to abort a thread-scoped turn from
|
|
70
|
+
// inside that thread is a normal message. We recognise a leading `!` as an
|
|
71
|
+
// alternate command prefix and route it through the same router.executeCommand
|
|
72
|
+
// path as native slashes. The guard is strict: only a first token that resolves
|
|
73
|
+
// to a known command name is rewritten, so casual messages like "!nice work"
|
|
74
|
+
// pass through untouched as regular agent input.
|
|
75
|
+
//
|
|
76
|
+
// Unlike native slash payloads (which never carry a thread and rely on the
|
|
77
|
+
// router's workspace+chat fallback), a thread message carries `thread_ts`,
|
|
78
|
+
// letting us target the exact thread session and skip the ambiguous-match case
|
|
79
|
+
// entirely.
|
|
80
|
+
export const THREAD_COMMAND_PREFIX = '!'
|
|
81
|
+
|
|
82
|
+
export type ThreadCommandInput = {
|
|
83
|
+
text: string
|
|
84
|
+
channel: string
|
|
85
|
+
threadTs: string | null
|
|
86
|
+
isDm: boolean
|
|
87
|
+
teamId: string
|
|
88
|
+
invokerId: string
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type ParseThreadCommandResult =
|
|
92
|
+
| { kind: 'parsed'; command: ParsedSlackSlashCommand }
|
|
93
|
+
| { kind: 'ignore'; reason: 'no-prefix' | 'unknown-command' }
|
|
94
|
+
|
|
95
|
+
// Both ignore reasons (`no-prefix`, `unknown-command`) are non-fatal: the
|
|
96
|
+
// caller lets the message flow through as ordinary agent input.
|
|
97
|
+
export function parseThreadCommand(
|
|
98
|
+
input: ThreadCommandInput,
|
|
99
|
+
knownCommands: ReadonlySet<string>,
|
|
100
|
+
): ParseThreadCommandResult {
|
|
101
|
+
const trimmed = input.text.trimStart()
|
|
102
|
+
if (!trimmed.startsWith(THREAD_COMMAND_PREFIX)) {
|
|
103
|
+
return { kind: 'ignore', reason: 'no-prefix' }
|
|
104
|
+
}
|
|
105
|
+
const firstToken = trimmed.slice(THREAD_COMMAND_PREFIX.length).split(/\s/, 1)[0] ?? ''
|
|
106
|
+
const name = firstToken.toLowerCase()
|
|
107
|
+
if (name === '' || !knownCommands.has(name)) {
|
|
108
|
+
return { kind: 'ignore', reason: 'unknown-command' }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const workspace = input.isDm ? '@dm' : input.teamId
|
|
112
|
+
return {
|
|
113
|
+
kind: 'parsed',
|
|
114
|
+
command: {
|
|
115
|
+
name,
|
|
116
|
+
key: { adapter: 'slack-bot', workspace, chat: input.channel, thread: input.threadTs },
|
|
117
|
+
invokerId: input.invokerId,
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
67
122
|
export const SLACK_SLASH_REPLY_ABORTED = 'Stopped the current turn.'
|
|
68
123
|
export const SLACK_SLASH_REPLY_NO_LIVE_SESSION = 'Nothing to stop — no active turn in this channel.'
|
|
69
124
|
export const SLACK_SLASH_REPLY_FAILED = 'Could not stop the current turn (internal error).'
|
|
70
125
|
export const SLACK_SLASH_REPLY_PERMISSION_DENIED =
|
|
71
126
|
'You do not have permission to stop the current turn in this channel.'
|
|
127
|
+
// Native slash commands cannot be invoked from a thread, so the only way to
|
|
128
|
+
// disambiguate is the `!stop` thread-message fallback — advise that, not the
|
|
129
|
+
// impossible `/stop`-in-thread.
|
|
72
130
|
export const SLACK_SLASH_REPLY_AMBIGUOUS =
|
|
73
|
-
'Multiple active turns in this channel. Reply
|
|
131
|
+
'Multiple active turns in this channel. Reply `!stop` inside the specific thread you want to stop.'
|
|
132
|
+
|
|
133
|
+
// Single outcome→reply mapping shared by the native-slash (ack payload) and
|
|
134
|
+
// `!cmd` thread (postMessage) delivery paths so the two never drift.
|
|
135
|
+
export function commandResultReply(result: ExecuteCommandResult): string {
|
|
136
|
+
switch (result.kind) {
|
|
137
|
+
case 'handled':
|
|
138
|
+
// Dynamic commands (e.g. /help) carry their own reply; static control
|
|
139
|
+
// commands (/stop) leave it undefined and fall back to the fixed string.
|
|
140
|
+
return result.reply ?? SLACK_SLASH_REPLY_ABORTED
|
|
141
|
+
case 'no-live-session':
|
|
142
|
+
return SLACK_SLASH_REPLY_NO_LIVE_SESSION
|
|
143
|
+
case 'permission-denied':
|
|
144
|
+
return SLACK_SLASH_REPLY_PERMISSION_DENIED
|
|
145
|
+
case 'ambiguous':
|
|
146
|
+
return SLACK_SLASH_REPLY_AMBIGUOUS
|
|
147
|
+
case 'unknown-command':
|
|
148
|
+
return SLACK_SLASH_REPLY_FAILED
|
|
149
|
+
}
|
|
150
|
+
}
|
|
74
151
|
|
|
75
152
|
// Slack's ack callback accepts an optional response payload that becomes
|
|
76
153
|
// the user-visible reply. `response_type: 'ephemeral'` keeps the reply
|