typeclaw 0.18.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/package.json +1 -1
- 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.ts +21 -4
- package/src/channels/adapters/github/inbound.ts +30 -55
- package/src/channels/adapters/github/index.ts +80 -18
- package/src/channels/adapters/github/membership.ts +4 -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 +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/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/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 +70 -43
|
@@ -13,8 +13,8 @@ export type GithubWebhookHandlerOptions = {
|
|
|
13
13
|
allowlist: () => readonly string[]
|
|
14
14
|
selfId: () => string | null
|
|
15
15
|
selfLogin: () => string | null
|
|
16
|
-
// Defaults to 'pat' when omitted.
|
|
17
|
-
//
|
|
16
|
+
// Defaults to 'pat' when omitted. In 'app' mode classifyReviewRequest also
|
|
17
|
+
// matches the App's decoy reviewer login; see resolveDecoyReviewerLogin.
|
|
18
18
|
authType?: () => 'pat' | 'app'
|
|
19
19
|
route: (message: InboundMessage) => void
|
|
20
20
|
logger: GithubInboundLogger
|
|
@@ -178,17 +178,10 @@ export function classifyGithubInbound(
|
|
|
178
178
|
number,
|
|
179
179
|
base,
|
|
180
180
|
selfLogin,
|
|
181
|
+
authType: options?.authType ?? 'pat',
|
|
181
182
|
teamIsBotMember: options?.teamIsBotMember,
|
|
182
183
|
})
|
|
183
184
|
}
|
|
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
185
|
return buildInbound(
|
|
193
186
|
{ ...base, chat: `pr:${number}`, thread: null },
|
|
194
187
|
pr.body,
|
|
@@ -242,23 +235,46 @@ type ReviewRequestInput = {
|
|
|
242
235
|
number: number
|
|
243
236
|
base: Pick<InboundMessage, 'adapter' | 'workspace' | 'isDm' | 'mentionsOthers' | 'replyToOtherMessageId'>
|
|
244
237
|
selfLogin: string | null
|
|
238
|
+
authType: 'pat' | 'app'
|
|
245
239
|
teamIsBotMember: boolean | undefined
|
|
246
240
|
}
|
|
247
241
|
|
|
242
|
+
// A GitHub App can never be a `requested_reviewer` — that field only holds
|
|
243
|
+
// real user accounts, and the App actor (`slug[bot]`) is not one. The
|
|
244
|
+
// supported workaround is a decoy user account named after the App that an
|
|
245
|
+
// operator requests instead (see docs/content/docs/internals/github-decoy-reviewer.mdx).
|
|
246
|
+
// Its login is, by convention, the App slug — i.e. `selfLogin` with the
|
|
247
|
+
// `[bot]` suffix removed (`my-app[bot]` → `my-app`). This is the single seam
|
|
248
|
+
// where that login is resolved: when the decoy account's real login diverges
|
|
249
|
+
// from the slug, a future config field replaces this derivation without
|
|
250
|
+
// touching the matcher. PAT auth has no decoy (the bot IS a real user that can
|
|
251
|
+
// be requested directly), so it returns null.
|
|
252
|
+
const BOT_LOGIN_SUFFIX = '[bot]'
|
|
253
|
+
|
|
254
|
+
function resolveDecoyReviewerLogin(selfLogin: string, authType: 'pat' | 'app'): string | null {
|
|
255
|
+
if (authType !== 'app') return null
|
|
256
|
+
if (!selfLogin.endsWith(BOT_LOGIN_SUFFIX)) return null
|
|
257
|
+
const slug = selfLogin.slice(0, -BOT_LOGIN_SUFFIX.length)
|
|
258
|
+
return slug !== '' ? slug : null
|
|
259
|
+
}
|
|
260
|
+
|
|
248
261
|
function classifyReviewRequest(input: ReviewRequestInput): InboundMessage | null {
|
|
249
|
-
const { action, payload, pr, number, base, selfLogin, teamIsBotMember } = input
|
|
262
|
+
const { action, payload, pr, number, base, selfLogin, authType, teamIsBotMember } = input
|
|
250
263
|
if (selfLogin === null) return null
|
|
264
|
+
const decoyLogin = resolveDecoyReviewerLogin(selfLogin, authType)
|
|
251
265
|
const sender = readUser(payload.sender)
|
|
252
266
|
if (sender === null) return null
|
|
253
|
-
// Self-loop guard: if the bot
|
|
267
|
+
// Self-loop guard: if the bot (or its decoy) requested/un-requested the
|
|
254
268
|
// review, drop the event. The bot adding itself as a reviewer would
|
|
255
269
|
// otherwise wake a fresh session every time it self-assigns.
|
|
256
|
-
if (sender.login === selfLogin) return null
|
|
270
|
+
if (sender.login === selfLogin || (decoyLogin !== null && sender.login === decoyLogin)) return null
|
|
257
271
|
|
|
258
272
|
const requestedUser = readUser(payload.requested_reviewer)
|
|
259
273
|
const requestedTeam = readReviewerTeam(payload.requested_team)
|
|
260
274
|
|
|
261
|
-
const isMeAsUser =
|
|
275
|
+
const isMeAsUser =
|
|
276
|
+
requestedUser !== null &&
|
|
277
|
+
(requestedUser.login === selfLogin || (decoyLogin !== null && requestedUser.login === decoyLogin))
|
|
262
278
|
const isMyTeam = requestedTeam !== null && teamIsBotMember === true
|
|
263
279
|
if (!isMeAsUser && !isMyTeam) return null
|
|
264
280
|
|
|
@@ -303,47 +319,6 @@ function classifyReviewRequest(input: ReviewRequestInput): InboundMessage | null
|
|
|
303
319
|
}
|
|
304
320
|
}
|
|
305
321
|
|
|
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
322
|
export type GithubReviewerTeam = { slug: string; id: number; org: string | null }
|
|
348
323
|
|
|
349
324
|
export function readReviewerTeam(value: unknown): GithubReviewerTeam | null {
|
|
@@ -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'
|
|
@@ -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,6 +100,7 @@ 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 => {
|
|
@@ -176,17 +184,15 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
176
184
|
throw err
|
|
177
185
|
}
|
|
178
186
|
started = true
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
}
|
|
189
|
-
if (ghTokenRepo !== null || options.secrets.auth.type === 'pat') {
|
|
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
|
+
}
|
|
190
196
|
await seedGhToken()
|
|
191
197
|
const tokenRefreshIntervalMs = options.tokenRefreshIntervalMs ?? DEFAULT_TOKEN_REFRESH_INTERVAL_MS
|
|
192
198
|
if (tokenRefreshIntervalMs > 0) {
|
|
@@ -207,10 +213,28 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
207
213
|
}
|
|
208
214
|
} else {
|
|
209
215
|
logger.info(
|
|
210
|
-
|
|
211
|
-
'Ad-hoc `gh` commands should set a repo-scoped token explicitly.',
|
|
216
|
+
`${GH_TOKEN_SKIP_LOG[seed.reason]} Ad-hoc \`gh\` commands should set a repo-scoped token explicitly.`,
|
|
212
217
|
)
|
|
213
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
|
+
})
|
|
237
|
+
}
|
|
214
238
|
logger.info(`[github] webhook listening on port ${options.configRef().webhookPort} as @${self.login}`)
|
|
215
239
|
// Best-effort: App-only preflight that compares the installation's granted
|
|
216
240
|
// permissions against the configured eventAllowlist and warns about gaps.
|
|
@@ -291,6 +315,10 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
291
315
|
tokenRefreshTimer.clear()
|
|
292
316
|
tokenRefreshTimer = null
|
|
293
317
|
}
|
|
318
|
+
if (unregisterTokenBridge !== null) {
|
|
319
|
+
unregisterTokenBridge()
|
|
320
|
+
unregisterTokenBridge = null
|
|
321
|
+
}
|
|
294
322
|
await auth.dispose()
|
|
295
323
|
delete process.env.GH_TOKEN
|
|
296
324
|
server = null
|
|
@@ -427,11 +455,45 @@ function logDeregistrationOutcome(
|
|
|
427
455
|
}
|
|
428
456
|
}
|
|
429
457
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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()
|
|
435
497
|
}
|
|
436
498
|
|
|
437
499
|
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' }
|
|
@@ -135,7 +135,9 @@ export const SLACK_SLASH_REPLY_AMBIGUOUS =
|
|
|
135
135
|
export function commandResultReply(result: ExecuteCommandResult): string {
|
|
136
136
|
switch (result.kind) {
|
|
137
137
|
case 'handled':
|
|
138
|
-
|
|
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
|
|
139
141
|
case 'no-live-session':
|
|
140
142
|
return SLACK_SLASH_REPLY_NO_LIVE_SESSION
|
|
141
143
|
case 'permission-denied':
|
|
@@ -51,7 +51,7 @@ import { slackTsToMillis } from './slack-bot-time'
|
|
|
51
51
|
// slash_commands events we route vs drop. The ui.test.ts manifest-drift
|
|
52
52
|
// test asserts equality between this set and SLACK_APP_MANIFEST.features.
|
|
53
53
|
// slash_commands so the two can never silently diverge.
|
|
54
|
-
export const SLACK_SLASH_COMMAND_NAMES: ReadonlySet<string> = new Set(['stop'])
|
|
54
|
+
export const SLACK_SLASH_COMMAND_NAMES: ReadonlySet<string> = new Set(['help', 'stop'])
|
|
55
55
|
|
|
56
56
|
// Resolvers fall back to the raw id on failure, so a name equal to the id
|
|
57
57
|
// means resolution failed; we render the bare id rather than `id(id)`. The
|
|
@@ -459,14 +459,14 @@ export function createSlackMembershipResolver(deps: {
|
|
|
459
459
|
}
|
|
460
460
|
|
|
461
461
|
let bots = 0
|
|
462
|
-
|
|
462
|
+
const humanMemberIds: string[] = []
|
|
463
463
|
for (const userId of members.value.members ?? []) {
|
|
464
464
|
const cached = userBotCache.get(userId)
|
|
465
465
|
const isBot = cached ?? (await resolveSlackUserIsBot(fetchFn, deps.token, userId, deps.logger, userBotCache))
|
|
466
466
|
if (isBot) bots++
|
|
467
|
-
else
|
|
467
|
+
else humanMemberIds.push(userId)
|
|
468
468
|
}
|
|
469
|
-
return { humans, bots, fetchedAt: now(), truncated: false }
|
|
469
|
+
return { humans: humanMemberIds.length, bots, fetchedAt: now(), truncated: false, humanMemberIds }
|
|
470
470
|
}
|
|
471
471
|
}
|
|
472
472
|
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { CommandInfo } from '@/commands'
|
|
2
|
+
|
|
3
|
+
// Generated from registry metadata so the listing can never drift from the
|
|
4
|
+
// actual command set. The `/` prefix is canonical across every surface; Slack
|
|
5
|
+
// threads accept the `!` alias for the same names.
|
|
6
|
+
export function formatChannelCommandHelp(commands: readonly CommandInfo[]): string {
|
|
7
|
+
if (commands.length === 0) return 'No commands are available.'
|
|
8
|
+
const lines = commands.map((command) => `/${command.name} — ${command.description}`)
|
|
9
|
+
return ['Available commands:', ...lines].join('\n')
|
|
10
|
+
}
|
|
@@ -81,11 +81,25 @@ export type EngagementInput = {
|
|
|
81
81
|
export function decideEngagement(input: EngagementInput): EngagementDecision {
|
|
82
82
|
const { message, config, key, ledger, now, participants, selfAliases, botInThread } = input
|
|
83
83
|
|
|
84
|
+
// The human count drives both the sticky-credit gate (below) and the
|
|
85
|
+
// solo-human fallback (bottom). Compute it once, up front. Peer bots are
|
|
86
|
+
// excluded — a 1-human-N-bot room is still "solo" for engagement purposes.
|
|
87
|
+
const effectiveHumans = countEffectiveHumans(participants, input.membership, now)
|
|
88
|
+
const multiHumanGroup = isMultiHumanGroup(message.isDm, effectiveHumans)
|
|
89
|
+
|
|
84
90
|
if (config.trigger.includes('dm') && message.isDm) return 'engage'
|
|
85
91
|
if (config.trigger.includes('mention') && message.isBotMention) return 'engage'
|
|
86
92
|
if (config.trigger.includes('reply') && message.replyToBotMessageId !== null) return 'engage'
|
|
87
93
|
|
|
88
|
-
|
|
94
|
+
// Sticky credit. ALWAYS consume when present (the credit stays one-shot,
|
|
95
|
+
// so a later membership change can't resurrect stale conversational
|
|
96
|
+
// credit), but only let it FORCE engagement outside a multi-human group.
|
|
97
|
+
// In a group, sticky alone no longer wakes the bot on every follow-up —
|
|
98
|
+
// the author must re-address us (mention/reply/alias) to re-engage. This
|
|
99
|
+
// narrows an existing permissive rule in the exact context where it's
|
|
100
|
+
// harmful; it is NOT a new bot-specific gate (peer bots and humans are
|
|
101
|
+
// treated identically, via `multiHumanGroup`). See engagement.mdx.
|
|
102
|
+
if (config.stickiness !== 'off' && ledger.consume(key, message.authorId, now) && !multiHumanGroup) {
|
|
89
103
|
return 'engage'
|
|
90
104
|
}
|
|
91
105
|
|
|
@@ -169,13 +183,30 @@ export function decideEngagement(input: EngagementInput): EngagementDecision {
|
|
|
169
183
|
// peer's first message it's caught forever.
|
|
170
184
|
if (textTargetsAnyPeerBot(message.text, participants)) return 'observe'
|
|
171
185
|
|
|
172
|
-
const persistedHumans = participants.filter((p) => p.isBot !== true).length
|
|
173
|
-
const effectiveHumans = resolveEffectiveHumans(persistedHumans, input.membership, now)
|
|
174
186
|
if (effectiveHumans <= 1 && !message.authorIsBot) return 'engage'
|
|
175
187
|
|
|
176
188
|
return 'observe'
|
|
177
189
|
}
|
|
178
190
|
|
|
191
|
+
export function countEffectiveHumans(
|
|
192
|
+
participants: readonly ChannelParticipant[],
|
|
193
|
+
membership: MembershipCount | null,
|
|
194
|
+
now: number,
|
|
195
|
+
): number {
|
|
196
|
+
const persistedHumans = participants.filter((p) => p.isBot !== true).length
|
|
197
|
+
return resolveEffectiveHumans(persistedHumans, membership, now)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// A multi-human group is the one place where the chatty "reply to every
|
|
201
|
+
// follow-up" behavior (sticky credit, and the prompt's default eagerness) is
|
|
202
|
+
// wrong. DMs — 1:1, or platform group-DMs reached via the `dm` trigger — and
|
|
203
|
+
// solo-human channels keep the back-and-forth. The router reuses this to
|
|
204
|
+
// decide both sticky suppression and the group-chat prompt nudge, so the two
|
|
205
|
+
// stay in lockstep off one definition.
|
|
206
|
+
export function isMultiHumanGroup(isDm: boolean, effectiveHumans: number): boolean {
|
|
207
|
+
return !isDm && effectiveHumans > 1
|
|
208
|
+
}
|
|
209
|
+
|
|
179
210
|
function textTargetsAnyPeerBot(text: string, participants: readonly ChannelParticipant[]): boolean {
|
|
180
211
|
const haystack = text.toLocaleLowerCase()
|
|
181
212
|
for (const p of participants) {
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Decoupled from ChannelRouter on purpose: minting a token for an arbitrary
|
|
2
|
+
// bash `gh` command is adjacent to channels but is not routing, and a global
|
|
3
|
+
// singleton would leak resolver state across tests. One instance is created in
|
|
4
|
+
// run/index.ts and threaded to both the plugin loader and the channel manager.
|
|
5
|
+
|
|
6
|
+
export type GithubTokenResolveResult = { kind: 'token'; token: string } | { kind: 'unavailable'; reason: string }
|
|
7
|
+
|
|
8
|
+
export type ResolveGithubTokenForRepo = (repoSlug: string) => Promise<GithubTokenResolveResult>
|
|
9
|
+
|
|
10
|
+
export type GithubTokenBridge = {
|
|
11
|
+
resolveTokenForRepo: ResolveGithubTokenForRepo
|
|
12
|
+
registerResolver: (resolver: (repoSlug: string) => Promise<string>) => () => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const NO_RESOLVER_REASON =
|
|
16
|
+
'GitHub App token unavailable; the GitHub channel adapter is not running or failed to start. ' +
|
|
17
|
+
'Check `typeclaw logs` and `secrets.json#channels.github`.'
|
|
18
|
+
|
|
19
|
+
export function createGithubTokenBridge(): GithubTokenBridge {
|
|
20
|
+
let current: ((repoSlug: string) => Promise<string>) | null = null
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
resolveTokenForRepo: async (repoSlug) => {
|
|
24
|
+
const resolver = current
|
|
25
|
+
if (resolver === null) return { kind: 'unavailable', reason: NO_RESOLVER_REASON }
|
|
26
|
+
try {
|
|
27
|
+
const token = await resolver(repoSlug)
|
|
28
|
+
return { kind: 'token', token }
|
|
29
|
+
} catch (err) {
|
|
30
|
+
return { kind: 'unavailable', reason: err instanceof Error ? err.message : String(err) }
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
registerResolver: (resolver) => {
|
|
34
|
+
current = resolver
|
|
35
|
+
return () => {
|
|
36
|
+
// Only clear if still the active resolver: a stop() racing a newer
|
|
37
|
+
// start() must not wipe the newer registration.
|
|
38
|
+
if (current === resolver) current = null
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/channels/index.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
export { createChannelManager, type ChannelManager, type ChannelManagerOptions } from './manager'
|
|
2
|
+
export {
|
|
3
|
+
createGithubTokenBridge,
|
|
4
|
+
type GithubTokenBridge,
|
|
5
|
+
type GithubTokenResolveResult,
|
|
6
|
+
type ResolveGithubTokenForRepo,
|
|
7
|
+
} from './github-token-bridge'
|
|
2
8
|
export {
|
|
3
9
|
createChannelRouter,
|
|
4
10
|
type ChannelRouter,
|
package/src/channels/manager.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { createGithubAdapter, type GithubAdapter } from './adapters/github'
|
|
|
12
12
|
import { createKakaotalkAdapter, type KakaotalkAdapter } from './adapters/kakaotalk'
|
|
13
13
|
import { createSlackBotAdapter, type SlackBotAdapter } from './adapters/slack-bot'
|
|
14
14
|
import { createTelegramBotAdapter, type TelegramBotAdapter } from './adapters/telegram-bot'
|
|
15
|
+
import type { GithubTokenBridge } from './github-token-bridge'
|
|
15
16
|
import { createChannelRouter, type ChannelRouter, type ClaimHandler, type CreateSessionForChannel } from './router'
|
|
16
17
|
import {
|
|
17
18
|
ADAPTER_IDS,
|
|
@@ -84,6 +85,10 @@ export type ChannelManagerOptions = {
|
|
|
84
85
|
// Production wiring (`src/run/index.ts`) always passes the agent's
|
|
85
86
|
// Stream; tests typically omit it.
|
|
86
87
|
stream?: Stream
|
|
88
|
+
// Write-side of the GithubTokenBridge. The github adapter publishes its
|
|
89
|
+
// per-repo App token minter here on start (App auth only) so plugin hooks
|
|
90
|
+
// can resolve a token for ad-hoc `gh` commands. Tests omit it.
|
|
91
|
+
githubTokenBridge?: GithubTokenBridge
|
|
87
92
|
}
|
|
88
93
|
|
|
89
94
|
export type ChannelManager = {
|
|
@@ -199,6 +204,7 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
|
|
|
199
204
|
logger,
|
|
200
205
|
tunnelUrl: () => options.tunnelUrlForChannel?.('github') ?? null,
|
|
201
206
|
tunnelConfiguredForChannel: () => options.tunnelConfiguredForChannel?.('github') ?? false,
|
|
207
|
+
...(options.githubTokenBridge !== undefined ? { githubTokenBridge: options.githubTokenBridge } : {}),
|
|
202
208
|
})
|
|
203
209
|
}
|
|
204
210
|
if (name === 'telegram-bot') {
|
|
@@ -21,6 +21,15 @@ export type MembershipCount = {
|
|
|
21
21
|
bots: number
|
|
22
22
|
fetchedAt: number
|
|
23
23
|
truncated: boolean
|
|
24
|
+
// Identities of the human members, present ONLY when the adapter enumerated
|
|
25
|
+
// the COMPLETE current membership and classified every listed member in the
|
|
26
|
+
// same pass that produced `humans`. When set, `humanMemberIds.length` equals
|
|
27
|
+
// `humans` by construction, so a consumer can prove "every human in the room
|
|
28
|
+
// is X" by resolving each id — something the bare `humans` count cannot do.
|
|
29
|
+
// Left undefined by approximate/truncated/history-derived reads and by
|
|
30
|
+
// adapters that cannot enumerate members (Telegram, KakaoTalk); consumers
|
|
31
|
+
// that need a completeness proof must fail closed when it is absent.
|
|
32
|
+
humanMemberIds?: readonly string[]
|
|
24
33
|
}
|
|
25
34
|
|
|
26
35
|
export type MembershipResolverFailure = { kind: 'transient' } | { kind: 'permanent' }
|