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.
Files changed (50) hide show
  1. package/auth.schema.json +0 -5
  2. package/package.json +2 -2
  3. package/secrets.schema.json +0 -5
  4. package/src/agent/index.ts +2 -1
  5. package/src/agent/model-overrides.ts +77 -0
  6. package/src/agent/plugin-tools.ts +53 -4
  7. package/src/agent/tools/grant-role.ts +102 -8
  8. package/src/bundled-plugins/github-cli-auth/gh-command.ts +372 -0
  9. package/src/bundled-plugins/github-cli-auth/index.ts +42 -0
  10. package/src/bundled-plugins/github-cli-auth/token-class.ts +11 -0
  11. package/src/bundled-plugins/reviewer/skills/code-review.ts +18 -1
  12. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +9 -2
  13. package/src/channels/adapters/discord-bot-classify.ts +23 -0
  14. package/src/channels/adapters/discord-bot.ts +22 -4
  15. package/src/channels/adapters/github/auth-app.ts +49 -26
  16. package/src/channels/adapters/github/auth-pat.ts +3 -3
  17. package/src/channels/adapters/github/auth.ts +19 -5
  18. package/src/channels/adapters/github/channel-resolver.ts +3 -2
  19. package/src/channels/adapters/github/history.ts +3 -2
  20. package/src/channels/adapters/github/inbound.ts +30 -55
  21. package/src/channels/adapters/github/index.ts +147 -43
  22. package/src/channels/adapters/github/membership.ts +7 -2
  23. package/src/channels/adapters/github/outbound.ts +6 -2
  24. package/src/channels/adapters/github/team-membership.ts +4 -2
  25. package/src/channels/adapters/github/webhook-register.ts +19 -16
  26. package/src/channels/adapters/slack-bot-slash-commands.ts +78 -1
  27. package/src/channels/adapters/slack-bot.ts +119 -18
  28. package/src/channels/commands.ts +10 -0
  29. package/src/channels/engagement.ts +34 -3
  30. package/src/channels/github-token-bridge.ts +42 -0
  31. package/src/channels/index.ts +6 -0
  32. package/src/channels/manager.ts +6 -0
  33. package/src/channels/membership.ts +9 -0
  34. package/src/channels/router.ts +155 -37
  35. package/src/cli/channel.ts +0 -12
  36. package/src/cli/init.ts +0 -9
  37. package/src/cli/ui.ts +6 -0
  38. package/src/commands/index.ts +54 -4
  39. package/src/init/dockerfile.ts +60 -0
  40. package/src/init/github-webhook-install.ts +1 -2
  41. package/src/init/index.ts +4 -10
  42. package/src/init/validate-api-key.ts +15 -1
  43. package/src/plugin/context.ts +8 -0
  44. package/src/plugin/manager.ts +3 -0
  45. package/src/plugin/types.ts +6 -0
  46. package/src/run/bundled-plugins.ts +9 -0
  47. package/src/run/index.ts +6 -0
  48. package/src/secrets/schema.ts +0 -1
  49. package/src/server/command-runner.ts +14 -0
  50. 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
- const tokenFn = async () => {
103
- const t = await auth.token()
104
- process.env.GH_TOKEN = t
105
- return t
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: tokenFn,
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: tokenFn,
123
+ token: authToken,
115
124
  fetchImpl,
116
125
  workspaceForChat: (chat) => workspaceByChat.get(chat) ?? null,
117
126
  })
118
- const membership = createGithubMembershipResolver({ token: tokenFn, fetchImpl })
119
- const channelNameResolver = createGithubChannelNameResolver({ token: tokenFn, fetchImpl })
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: tokenFn, fetchImpl })
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
- // Keep GH_TOKEN warm even when the adapter is only receiving inbound
183
- // webhooks and not making outbound API calls. This prevents `gh` CLI
184
- // calls from the agent from failing with 401 after the token expires.
185
- const tokenRefreshIntervalMs = options.tokenRefreshIntervalMs ?? DEFAULT_TOKEN_REFRESH_INTERVAL_MS
186
- if (tokenRefreshIntervalMs > 0) {
187
- const refresh = () => {
188
- tokenFn().catch((err) => {
189
- logger.error(`[github] periodic token refresh failed: ${err instanceof Error ? err.message : String(err)}`)
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
- const setIntervalFn =
193
- options.setInterval ??
194
- ((handler: () => void, ms: number) => {
195
- const timer = setInterval(handler, ms)
196
- return { clear: () => clearInterval(timer) }
197
- })
198
- tokenRefreshTimer = setIntervalFn(refresh, tokenRefreshIntervalMs)
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: tokenFn,
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: tokenFn,
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
- let grants
373
- try {
374
- grants = await auth.getInstallationGrants()
375
- } catch (err) {
376
- logger.warn(`[github] permission preflight skipped: ${err instanceof Error ? err.message : String(err)}`)
377
- return
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 deps.token(),
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: () => Promise<string>
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 `/stop` from inside the specific thread you want to stop.'
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