typeclaw 0.16.0 → 0.18.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 +32 -1
- package/src/agent/session-origin.ts +54 -12
- package/src/agent/system-prompt.ts +1 -1
- package/src/agent/tools/grant-role.ts +214 -0
- package/src/channels/adapters/discord-bot-classify.ts +23 -0
- package/src/channels/adapters/discord-bot.ts +1 -0
- 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/index.ts +85 -43
- package/src/channels/adapters/github/membership.ts +3 -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 +76 -1
- package/src/channels/adapters/slack-bot.ts +115 -14
- package/src/channels/router.ts +87 -17
- package/src/cli/channel.ts +0 -12
- package/src/cli/init.ts +0 -9
- package/src/cli/role.ts +10 -1
- package/src/cli/ui.ts +6 -4
- package/src/config/reloadable.ts +10 -3
- package/src/init/github-webhook-install.ts +1 -2
- package/src/init/index.ts +9 -43
- package/src/init/run-owner-claim.ts +21 -3
- package/src/permissions/builtins.ts +14 -4
- package/src/permissions/grant.ts +92 -16
- package/src/permissions/index.ts +8 -2
- package/src/permissions/permissions.ts +9 -0
- package/src/permissions/resolve.ts +10 -0
- package/src/role-claim/index.ts +1 -0
- package/src/role-claim/reload-after-claim.ts +34 -0
- package/src/run/channel-session-factory.ts +6 -1
- package/src/run/index.ts +20 -1
- package/src/sandbox/build.ts +32 -0
- package/src/secrets/schema.ts +0 -1
- package/src/server/command-runner.ts +14 -0
- package/src/skills/typeclaw-channel-github/SKILL.md +15 -3
- package/src/skills/typeclaw-permissions/SKILL.md +11 -3
- package/src/skills/typeclaw-skills/SKILL.md +3 -1
|
@@ -3,15 +3,30 @@ import type { GithubAppAuthBlock, GithubPatAuthBlock } from '@/secrets/schema'
|
|
|
3
3
|
import { AppAuthStrategy } from './auth-app'
|
|
4
4
|
import { PatAuthStrategy } from './auth-pat'
|
|
5
5
|
|
|
6
|
+
// Repo identity threaded through every auth call so App auth can pick the
|
|
7
|
+
// correct installation. `repoSlug` is the canonical input ("owner/name"); App
|
|
8
|
+
// auth resolves it to an installation via GET /repos/{owner}/{repo}/installation
|
|
9
|
+
// and caches the result. PAT auth ignores it entirely. Omitted context means
|
|
10
|
+
// "no specific repo" — App auth then falls back to a single discoverable
|
|
11
|
+
// installation (and errors if the App spans multiple installations).
|
|
12
|
+
export type GithubAuthContext = {
|
|
13
|
+
repoSlug?: string
|
|
14
|
+
// Org login, for operations that aren't repo-scoped (e.g. team-membership
|
|
15
|
+
// lookups under GET /orgs/{org}/...). App auth resolves it to an
|
|
16
|
+
// installation via GET /orgs/{org}/installation. Ignored when repoSlug is set.
|
|
17
|
+
owner?: string
|
|
18
|
+
}
|
|
19
|
+
|
|
6
20
|
export type GithubAuthStrategy = {
|
|
7
|
-
token: () => Promise<string>
|
|
8
|
-
authHeaders: () => Promise<HeadersInit>
|
|
21
|
+
token: (context?: GithubAuthContext) => Promise<string>
|
|
22
|
+
authHeaders: (context?: GithubAuthContext) => Promise<HeadersInit>
|
|
9
23
|
getSelf: () => Promise<GithubSelfUser>
|
|
10
24
|
// App-only: returns the installation's granted-permissions map and declared
|
|
11
25
|
// events so the adapter can preflight against the configured eventAllowlist
|
|
12
26
|
// before any webhook arrives. PATs return access via token scopes, not an
|
|
13
|
-
// installation grant, so they leave this undefined.
|
|
14
|
-
|
|
27
|
+
// installation grant, so they leave this undefined. Context selects which
|
|
28
|
+
// installation to inspect when the App spans multiple owners.
|
|
29
|
+
getInstallationGrants?: (context?: GithubAuthContext) => Promise<GithubInstallationGrants>
|
|
15
30
|
dispose: () => Promise<void>
|
|
16
31
|
}
|
|
17
32
|
|
|
@@ -36,7 +51,6 @@ export function buildAuthStrategy(options: {
|
|
|
36
51
|
return new AppAuthStrategy({
|
|
37
52
|
appId: options.auth.appId,
|
|
38
53
|
privateKey: options.auth.privateKey,
|
|
39
|
-
installationId: options.auth.installationId,
|
|
40
54
|
fetchImpl: options.fetchImpl,
|
|
41
55
|
})
|
|
42
56
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { ChannelNameResolver, ResolvedChannelNames } from '@/channels/types'
|
|
2
2
|
|
|
3
|
+
import type { GithubAuthContext } from './auth'
|
|
3
4
|
import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
|
|
4
5
|
import { parseChat, parseRepo } from './outbound'
|
|
5
6
|
|
|
6
7
|
export function createGithubChannelNameResolver(options: {
|
|
7
|
-
token: () => Promise<string>
|
|
8
|
+
token: (context?: GithubAuthContext) => Promise<string>
|
|
8
9
|
fetchImpl?: typeof fetch
|
|
9
10
|
}): ChannelNameResolver {
|
|
10
11
|
const fetchImpl = options.fetchImpl ?? fetch
|
|
@@ -18,7 +19,7 @@ export function createGithubChannelNameResolver(options: {
|
|
|
18
19
|
const path = chat.kind === 'issue' ? `issues/${chat.number}` : `pulls/${chat.number}`
|
|
19
20
|
try {
|
|
20
21
|
const response = await fetchImpl(`${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/${path}`, {
|
|
21
|
-
headers: githubJsonHeaders(await options.token()),
|
|
22
|
+
headers: githubJsonHeaders(await options.token({ repoSlug: key.workspace })),
|
|
22
23
|
})
|
|
23
24
|
if (!response.ok) return names
|
|
24
25
|
const raw = (await response.json()) as { title?: string }
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { ChannelHistoryMessage, FetchHistoryArgs, FetchHistoryResult, HistoryCallback } from '@/channels/types'
|
|
2
2
|
|
|
3
|
+
import type { GithubAuthContext } from './auth'
|
|
3
4
|
import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
|
|
4
5
|
import { parseChat, parseRepo } from './outbound'
|
|
5
6
|
|
|
6
7
|
export function createGithubHistoryCallback(options: {
|
|
7
|
-
token: () => Promise<string>
|
|
8
|
+
token: (context?: GithubAuthContext) => Promise<string>
|
|
8
9
|
workspaceForChat: (chat: string) => string | null
|
|
9
10
|
fetchImpl?: typeof fetch
|
|
10
11
|
}): HistoryCallback {
|
|
@@ -26,7 +27,7 @@ export function createGithubHistoryCallback(options: {
|
|
|
26
27
|
const response = await fetchImpl(
|
|
27
28
|
`${endpoint}?per_page=${Math.min(Math.max(args.limit, 1), 100)}&direction=desc${cursor}`,
|
|
28
29
|
{
|
|
29
|
-
headers: githubJsonHeaders(await options.token()),
|
|
30
|
+
headers: githubJsonHeaders(await options.token({ repoSlug: workspace })),
|
|
30
31
|
},
|
|
31
32
|
)
|
|
32
33
|
if (!response.ok) return { ok: false, error: `GitHub history ${response.status}` }
|
|
@@ -3,7 +3,7 @@ import type { ChannelAdapterConfig, GithubAdapterConfig } from '@/channels/schem
|
|
|
3
3
|
import { resolveSecret } from '@/secrets/resolve'
|
|
4
4
|
import type { GithubSecretsBlock } from '@/secrets/schema'
|
|
5
5
|
|
|
6
|
-
import { buildAuthStrategy } from './auth'
|
|
6
|
+
import { buildAuthStrategy, type GithubAuthContext } from './auth'
|
|
7
7
|
import { createGithubChannelNameResolver } from './channel-resolver'
|
|
8
8
|
import { createDeliveryDedup } from './dedup'
|
|
9
9
|
import { findPermissionGaps } from './event-permissions'
|
|
@@ -99,29 +99,30 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
99
99
|
workspaceByChat.set(chat, workspace)
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
102
|
+
// Repo/owner-aware token resolver. A single GitHub App can span multiple
|
|
103
|
+
// installations (one per owner); each consumer passes its repo/owner so the
|
|
104
|
+
// right installation token is minted. Unlike the old single-token path, this
|
|
105
|
+
// does NOT mutate process.env.GH_TOKEN — that global is seeded separately and
|
|
106
|
+
// only when exactly one installation applies (see seedGhTokenIfSingle).
|
|
107
|
+
const authToken = (context?: GithubAuthContext) => auth.token(context)
|
|
107
108
|
const outbound = createGithubOutboundCallback({
|
|
108
|
-
token:
|
|
109
|
+
token: authToken,
|
|
109
110
|
authType: options.secrets.auth.type,
|
|
110
111
|
logger,
|
|
111
112
|
fetchImpl,
|
|
112
113
|
})
|
|
113
114
|
const history = createGithubHistoryCallback({
|
|
114
|
-
token:
|
|
115
|
+
token: authToken,
|
|
115
116
|
fetchImpl,
|
|
116
117
|
workspaceForChat: (chat) => workspaceByChat.get(chat) ?? null,
|
|
117
118
|
})
|
|
118
|
-
const membership = createGithubMembershipResolver({ token:
|
|
119
|
-
const channelNameResolver = createGithubChannelNameResolver({ token:
|
|
119
|
+
const membership = createGithubMembershipResolver({ token: authToken, fetchImpl })
|
|
120
|
+
const channelNameResolver = createGithubChannelNameResolver({ token: authToken, fetchImpl })
|
|
120
121
|
const fetchAttachment = createGithubFetchAttachmentCallback()
|
|
121
122
|
// No-op typing callback: GitHub has no typing indicator API.
|
|
122
123
|
const typing = async (): Promise<void> => {}
|
|
123
124
|
const dedup = createDeliveryDedup()
|
|
124
|
-
const isBotInTeam = createTeamMembershipChecker({ token:
|
|
125
|
+
const isBotInTeam = createTeamMembershipChecker({ token: authToken, fetchImpl })
|
|
125
126
|
const handler = createGithubWebhookHandler({
|
|
126
127
|
webhookSecret,
|
|
127
128
|
dedup,
|
|
@@ -174,35 +175,48 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
174
175
|
selfLogin = null
|
|
175
176
|
throw err
|
|
176
177
|
}
|
|
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
178
|
started = true
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
179
|
+
// GH_TOKEN is a single process-wide env var the container's `gh` CLI
|
|
180
|
+
// reads, but a GitHub App spanning multiple owners has no single correct
|
|
181
|
+
// token. Seed/refresh it only when exactly one repo is configured (PAT,
|
|
182
|
+
// or App with one unambiguous installation). With multiple repos we skip
|
|
183
|
+
// the global seed: ad-hoc `gh` calls must target a specific repo, and the
|
|
184
|
+
// adapter's own API calls always resolve a repo-scoped token via authToken.
|
|
185
|
+
const ghTokenRepo = ghTokenSeedRepo(options.configRef().repos ?? [])
|
|
186
|
+
const seedGhToken = async (): Promise<void> => {
|
|
187
|
+
process.env.GH_TOKEN = await auth.token(ghTokenRepo === null ? undefined : { repoSlug: ghTokenRepo })
|
|
188
|
+
}
|
|
189
|
+
if (ghTokenRepo !== null || options.secrets.auth.type === 'pat') {
|
|
190
|
+
await seedGhToken()
|
|
191
|
+
const tokenRefreshIntervalMs = options.tokenRefreshIntervalMs ?? DEFAULT_TOKEN_REFRESH_INTERVAL_MS
|
|
192
|
+
if (tokenRefreshIntervalMs > 0) {
|
|
193
|
+
const refresh = () => {
|
|
194
|
+
seedGhToken().catch((err) => {
|
|
195
|
+
logger.error(
|
|
196
|
+
`[github] periodic token refresh failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
197
|
+
)
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
const setIntervalFn =
|
|
201
|
+
options.setInterval ??
|
|
202
|
+
((handler: () => void, ms: number) => {
|
|
203
|
+
const timer = setInterval(handler, ms)
|
|
204
|
+
return { clear: () => clearInterval(timer) }
|
|
205
|
+
})
|
|
206
|
+
tokenRefreshTimer = setIntervalFn(refresh, tokenRefreshIntervalMs)
|
|
191
207
|
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
})
|
|
198
|
-
tokenRefreshTimer = setIntervalFn(refresh, tokenRefreshIntervalMs)
|
|
208
|
+
} else {
|
|
209
|
+
logger.info(
|
|
210
|
+
'[github] multiple repos configured across possibly-different owners; GH_TOKEN not seeded globally. ' +
|
|
211
|
+
'Ad-hoc `gh` commands should set a repo-scoped token explicitly.',
|
|
212
|
+
)
|
|
199
213
|
}
|
|
200
214
|
logger.info(`[github] webhook listening on port ${options.configRef().webhookPort} as @${self.login}`)
|
|
201
215
|
// Best-effort: App-only preflight that compares the installation's granted
|
|
202
216
|
// permissions against the configured eventAllowlist and warns about gaps.
|
|
203
217
|
// Catches the most common misconfiguration (App installed with the default
|
|
204
218
|
// metadata-only permission set) before any event fires a 403.
|
|
205
|
-
await runAppPermissionPreflight(logger, auth, options.configRef().eventAllowlist)
|
|
219
|
+
await runAppPermissionPreflight(logger, auth, options.configRef().eventAllowlist, options.configRef().repos ?? [])
|
|
206
220
|
// Repository webhook registration is best-effort: failures are logged
|
|
207
221
|
// per-repo, the adapter stays up. A misconfigured PAT or App that
|
|
208
222
|
// can't manage hooks must not prevent the adapter from accepting
|
|
@@ -225,6 +239,9 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
225
239
|
})
|
|
226
240
|
} else if (repos.length > 0) {
|
|
227
241
|
const legacyProviderHostSuffix = detectLegacyProviderHostSuffix(effectiveUrl)
|
|
242
|
+
logger.info(
|
|
243
|
+
`[github] registering webhook for ${repos.length} repo(s) [${repos.join(', ')}] -> ${effectiveUrl} (events: ${cfg.eventAllowlist.join(', ')})`,
|
|
244
|
+
)
|
|
228
245
|
if (webhookRegistrationDelayMs > 0) {
|
|
229
246
|
logger.info(
|
|
230
247
|
`[github] waiting ${webhookRegistrationDelayMs}ms before registering webhook so the Cloudflare edge can warm up`,
|
|
@@ -232,7 +249,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
232
249
|
await sleep(webhookRegistrationDelayMs)
|
|
233
250
|
}
|
|
234
251
|
const registration = await registerGithubWebhooks({
|
|
235
|
-
token:
|
|
252
|
+
token: (repoSlug: string) => auth.token({ repoSlug }),
|
|
236
253
|
webhookUrl: effectiveUrl,
|
|
237
254
|
webhookSecret,
|
|
238
255
|
repos,
|
|
@@ -263,7 +280,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
263
280
|
// last to clear the cached App-installation token.
|
|
264
281
|
if (managedHooks.length > 0) {
|
|
265
282
|
const deregistration = await deregisterGithubWebhooks({
|
|
266
|
-
token:
|
|
283
|
+
token: (repoSlug: string) => auth.token({ repoSlug }),
|
|
267
284
|
hooks: managedHooks,
|
|
268
285
|
fetchImpl,
|
|
269
286
|
})
|
|
@@ -367,18 +384,36 @@ async function runAppPermissionPreflight(
|
|
|
367
384
|
logger: GithubAdapterLogger,
|
|
368
385
|
auth: ReturnType<typeof buildAuthStrategy>,
|
|
369
386
|
eventAllowlist: readonly string[],
|
|
387
|
+
repos: readonly string[],
|
|
370
388
|
): Promise<void> {
|
|
371
389
|
if (auth.getInstallationGrants === undefined) return
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
390
|
+
const getGrants = (context: GithubAuthContext | undefined) => auth.getInstallationGrants?.(context)
|
|
391
|
+
// One grants check per distinct owner: installations are owner-scoped, so
|
|
392
|
+
// repos sharing an owner share an installation. The first repo per owner is
|
|
393
|
+
// the resolution key. With no repos, fall back to a single context-free check.
|
|
394
|
+
const reposByOwner = new Map<string, string>()
|
|
395
|
+
for (const repo of repos) {
|
|
396
|
+
const owner = repo.split('/')[0]
|
|
397
|
+
if (owner !== undefined && owner !== '' && !reposByOwner.has(owner)) reposByOwner.set(owner, repo)
|
|
398
|
+
}
|
|
399
|
+
const contexts: Array<{ label: string; context: { repoSlug: string } | undefined }> =
|
|
400
|
+
reposByOwner.size === 0
|
|
401
|
+
? [{ label: 'app', context: undefined }]
|
|
402
|
+
: [...reposByOwner.values()].map((repo) => ({ label: repo, context: { repoSlug: repo } }))
|
|
403
|
+
for (const { label, context } of contexts) {
|
|
404
|
+
let grants
|
|
405
|
+
try {
|
|
406
|
+
grants = await getGrants(context)
|
|
407
|
+
} catch (err) {
|
|
408
|
+
logger.warn(
|
|
409
|
+
`[github] permission preflight skipped for ${label}: ${err instanceof Error ? err.message : String(err)}`,
|
|
410
|
+
)
|
|
411
|
+
continue
|
|
412
|
+
}
|
|
413
|
+
if (grants === undefined) continue
|
|
414
|
+
const gaps = findPermissionGaps(eventAllowlist, grants.permissions)
|
|
415
|
+
if (gaps.length > 0) logger.warn(buildAppPermissionPreflightGuidance(gaps))
|
|
378
416
|
}
|
|
379
|
-
const gaps = findPermissionGaps(eventAllowlist, grants.permissions)
|
|
380
|
-
if (gaps.length === 0) return
|
|
381
|
-
logger.warn(buildAppPermissionPreflightGuidance(gaps))
|
|
382
417
|
}
|
|
383
418
|
|
|
384
419
|
function logDeregistrationOutcome(
|
|
@@ -392,6 +427,13 @@ function logDeregistrationOutcome(
|
|
|
392
427
|
}
|
|
393
428
|
}
|
|
394
429
|
|
|
430
|
+
// Two repos under the same owner share an installation and could in principle
|
|
431
|
+
// share a global GH_TOKEN, but the marginal value doesn't justify the special
|
|
432
|
+
// case — only a single configured repo yields an unambiguous seed.
|
|
433
|
+
function ghTokenSeedRepo(repos: readonly string[]): string | null {
|
|
434
|
+
return repos.length === 1 ? (repos[0] ?? null) : null
|
|
435
|
+
}
|
|
436
|
+
|
|
395
437
|
function defaultSleep(ms: number): Promise<void> {
|
|
396
438
|
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
397
439
|
}
|
|
@@ -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' }
|
|
@@ -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,87 @@ 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
|
+
return SLACK_SLASH_REPLY_ABORTED
|
|
139
|
+
case 'no-live-session':
|
|
140
|
+
return SLACK_SLASH_REPLY_NO_LIVE_SESSION
|
|
141
|
+
case 'permission-denied':
|
|
142
|
+
return SLACK_SLASH_REPLY_PERMISSION_DENIED
|
|
143
|
+
case 'ambiguous':
|
|
144
|
+
return SLACK_SLASH_REPLY_AMBIGUOUS
|
|
145
|
+
case 'unknown-command':
|
|
146
|
+
return SLACK_SLASH_REPLY_FAILED
|
|
147
|
+
}
|
|
148
|
+
}
|
|
74
149
|
|
|
75
150
|
// Slack's ack callback accepts an optional response payload that becomes
|
|
76
151
|
// the user-visible reply. `response_type: 'ephemeral'` keeps the reply
|