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
@@ -2,33 +2,43 @@ import { createPrivateKey } from 'node:crypto'
2
2
 
3
3
  import { resolveSecret, type Secret } from '@/secrets/resolve'
4
4
 
5
- import type { GithubAuthStrategy, GithubInstallationGrants, GithubSelfUser } from './auth'
5
+ import type { GithubAuthContext, GithubAuthStrategy, GithubInstallationGrants, GithubSelfUser } from './auth'
6
6
  import { GITHUB_API_BASE, githubJsonHeaders, githubPublicHeaders } from './auth-pat'
7
7
 
8
+ type TokenCacheEntry = { value: string; expiresAt: number }
9
+
8
10
  export class AppAuthStrategy implements GithubAuthStrategy {
9
11
  private readonly appId: number
10
12
  private readonly privateKeyPem: string
11
- private readonly installationId: number | null
12
13
  private readonly fetchImpl: typeof fetch
13
- private cachedToken: { value: string; expiresAt: number } | null = null
14
- private resolvedInstallationId: number | null = null
14
+ // Keyed by installation id: a single App may span multiple owners, each a
15
+ // separate installation with its own short-lived token.
16
+ private readonly tokenCache = new Map<number, TokenCacheEntry>()
17
+ private readonly repoInstallationCache = new Map<string, number>()
18
+ private soleInstallationId: number | null = null
15
19
  private _selfUser: GithubSelfUser | null = null
16
20
 
17
- constructor(options: { appId: number; privateKey: Secret; installationId?: number; fetchImpl?: typeof fetch }) {
21
+ constructor(options: { appId: number; privateKey: Secret; fetchImpl?: typeof fetch }) {
18
22
  const privateKeyPem = resolveSecret(options.privateKey, undefined, process.env)
19
23
  if (privateKeyPem === undefined || privateKeyPem.trim() === '') throw new Error('GitHub App private key is missing')
20
24
  this.appId = options.appId
21
25
  this.privateKeyPem = privateKeyPem
22
- this.installationId = options.installationId ?? null
23
26
  this.fetchImpl = options.fetchImpl ?? fetch
24
27
  }
25
28
 
26
- async token(): Promise<string> {
27
- if (this.cachedToken && Date.now() < this.cachedToken.expiresAt - 5 * 60 * 1000) {
28
- return this.cachedToken.value
29
- }
29
+ async token(context?: GithubAuthContext): Promise<string> {
30
30
  const jwt = await this.mintJwt()
31
- const installId = await this.resolveInstallationId(jwt)
31
+ const installId = await this.resolveInstallationId(jwt, context)
32
+ return this.installationToken(jwt, installId)
33
+ }
34
+
35
+ async authHeaders(context?: GithubAuthContext): Promise<HeadersInit> {
36
+ return githubJsonHeaders(await this.token(context))
37
+ }
38
+
39
+ private async installationToken(jwt: string, installId: number): Promise<string> {
40
+ const cached = this.tokenCache.get(installId)
41
+ if (cached && Date.now() < cached.expiresAt - 5 * 60 * 1000) return cached.value
32
42
  const response = await this.fetchImpl(`${GITHUB_API_BASE}/app/installations/${installId}/access_tokens`, {
33
43
  method: 'POST',
34
44
  headers: githubJsonHeaders(jwt),
@@ -37,14 +47,10 @@ export class AppAuthStrategy implements GithubAuthStrategy {
37
47
  const raw = (await response.json()) as { token?: unknown; expires_at?: unknown }
38
48
  if (typeof raw.token !== 'string') throw new Error('GitHub App token response missing token')
39
49
  const expiresAt = typeof raw.expires_at === 'string' ? Date.parse(raw.expires_at) : Date.now() + 60 * 60 * 1000
40
- this.cachedToken = { value: raw.token, expiresAt }
50
+ this.tokenCache.set(installId, { value: raw.token, expiresAt })
41
51
  return raw.token
42
52
  }
43
53
 
44
- async authHeaders(): Promise<HeadersInit> {
45
- return githubJsonHeaders(await this.token())
46
- }
47
-
48
54
  async getSelf(): Promise<GithubSelfUser> {
49
55
  if (this._selfUser) return this._selfUser
50
56
  const jwt = await this.mintJwt()
@@ -69,9 +75,9 @@ export class AppAuthStrategy implements GithubAuthStrategy {
69
75
  return this._selfUser
70
76
  }
71
77
 
72
- async getInstallationGrants(): Promise<GithubInstallationGrants> {
78
+ async getInstallationGrants(context?: GithubAuthContext): Promise<GithubInstallationGrants> {
73
79
  const jwt = await this.mintJwt()
74
- const installId = await this.resolveInstallationId(jwt)
80
+ const installId = await this.resolveInstallationId(jwt, context)
75
81
  const response = await this.fetchImpl(`${GITHUB_API_BASE}/app/installations/${installId}`, {
76
82
  headers: githubJsonHeaders(jwt),
77
83
  })
@@ -88,7 +94,8 @@ export class AppAuthStrategy implements GithubAuthStrategy {
88
94
  }
89
95
 
90
96
  async dispose(): Promise<void> {
91
- this.cachedToken = null
97
+ this.tokenCache.clear()
98
+ this.repoInstallationCache.clear()
92
99
  }
93
100
 
94
101
  private async mintJwt(): Promise<string> {
@@ -107,25 +114,41 @@ export class AppAuthStrategy implements GithubAuthStrategy {
107
114
  return `${signingInput}.${base64url(Buffer.from(signature))}`
108
115
  }
109
116
 
110
- private async resolveInstallationId(jwt: string): Promise<number> {
111
- if (this.resolvedInstallationId !== null) return this.resolvedInstallationId
112
- if (this.installationId !== null) {
113
- this.resolvedInstallationId = this.installationId
114
- return this.installationId
117
+ private async resolveInstallationId(jwt: string, context?: GithubAuthContext): Promise<number> {
118
+ if (context?.repoSlug !== undefined && context.repoSlug !== '') {
119
+ return this.resolveInstallationByEndpoint(jwt, `repos/${context.repoSlug}/installation`, context.repoSlug)
120
+ }
121
+ if (context?.owner !== undefined && context.owner !== '') {
122
+ return this.resolveInstallationByEndpoint(jwt, `orgs/${context.owner}/installation`, context.owner)
115
123
  }
124
+ if (this.soleInstallationId !== null) return this.soleInstallationId
116
125
  const response = await this.fetchImpl(`${GITHUB_API_BASE}/app/installations`, { headers: githubJsonHeaders(jwt) })
117
126
  if (!response.ok) throw new Error(`GitHub App installations fetch failed: ${response.status}`)
118
127
  const list = (await response.json()) as Array<{ id?: unknown }>
119
128
  if (list.length === 0) throw new Error('GitHub App has no installations')
120
129
  if (list.length > 1) {
121
130
  const ids = list.map((installation) => installation.id).join(', ')
122
- throw new Error(`GitHub App has multiple installations (${ids}); set installationId in secrets.json`)
131
+ throw new Error(`GitHub App has multiple installations (${ids}); a repo must be specified to select one`)
123
132
  }
124
133
  const id = list[0]?.id
125
134
  if (typeof id !== 'number') throw new Error('GitHub App installation missing id')
126
- this.resolvedInstallationId = id
135
+ this.soleInstallationId = id
127
136
  return id
128
137
  }
138
+
139
+ private async resolveInstallationByEndpoint(jwt: string, path: string, target: string): Promise<number> {
140
+ const cached = this.repoInstallationCache.get(target)
141
+ if (cached !== undefined) return cached
142
+ const response = await this.fetchImpl(`${GITHUB_API_BASE}/${path}`, { headers: githubJsonHeaders(jwt) })
143
+ if (response.status === 404) {
144
+ throw new Error(`GitHub App is not installed for ${target} or lacks access to that repository`)
145
+ }
146
+ if (!response.ok) throw new Error(`GitHub App installation lookup for ${target} failed: ${response.status}`)
147
+ const raw = (await response.json()) as { id?: unknown }
148
+ if (typeof raw.id !== 'number') throw new Error(`GitHub App installation for ${target} missing id`)
149
+ this.repoInstallationCache.set(target, raw.id)
150
+ return raw.id
151
+ }
129
152
  }
130
153
 
131
154
  function base64url(input: string | Buffer): string {
@@ -1,6 +1,6 @@
1
1
  import { resolveSecret, type Secret } from '@/secrets/resolve'
2
2
 
3
- import type { GithubAuthStrategy, GithubSelfUser } from './auth'
3
+ import type { GithubAuthContext, GithubAuthStrategy, GithubSelfUser } from './auth'
4
4
 
5
5
  export const GITHUB_API_BASE = 'https://api.github.com'
6
6
 
@@ -15,11 +15,11 @@ export class PatAuthStrategy implements GithubAuthStrategy {
15
15
  this.fetchImpl = options.fetchImpl ?? fetch
16
16
  }
17
17
 
18
- async token(): Promise<string> {
18
+ async token(_context?: GithubAuthContext): Promise<string> {
19
19
  return this._token
20
20
  }
21
21
 
22
- async authHeaders(): Promise<HeadersInit> {
22
+ async authHeaders(_context?: GithubAuthContext): Promise<HeadersInit> {
23
23
  return githubJsonHeaders(this._token)
24
24
  }
25
25
 
@@ -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
- getInstallationGrants?: () => Promise<GithubInstallationGrants>
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}` }
@@ -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. Only 'app' promotes an opened PR to a
17
- // review request; see classifyOpenedAsReview for why.
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 itself requested (or un-requested) the
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 = requestedUser !== null && requestedUser.login === selfLogin
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 {