typeclaw 0.3.1 → 0.5.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 (125) hide show
  1. package/README.md +20 -15
  2. package/auth.schema.json +113 -0
  3. package/package.json +1 -1
  4. package/secrets.schema.json +113 -0
  5. package/src/agent/auth.ts +4 -2
  6. package/src/agent/index.ts +16 -28
  7. package/src/agent/model-fallback.ts +127 -0
  8. package/src/agent/session-meta.ts +1 -1
  9. package/src/agent/session-origin.ts +3 -2
  10. package/src/agent/tools/curl-impersonate.ts +300 -0
  11. package/src/agent/tools/ddg.ts +13 -88
  12. package/src/agent/tools/webfetch/fetch.ts +105 -2
  13. package/src/agent/tools/webfetch/tool.ts +4 -0
  14. package/src/bundled-plugins/agent-browser/shim.ts +47 -0
  15. package/src/bundled-plugins/backup/subagents.ts +2 -0
  16. package/src/bundled-plugins/memory/README.md +49 -12
  17. package/src/bundled-plugins/memory/citation-superset.ts +63 -0
  18. package/src/bundled-plugins/memory/dreaming.ts +105 -17
  19. package/src/bundled-plugins/memory/index.ts +2 -2
  20. package/src/bundled-plugins/memory/memory-logger.ts +45 -26
  21. package/src/bundled-plugins/memory/strength.ts +127 -0
  22. package/src/bundled-plugins/memory/topics.ts +75 -0
  23. package/src/bundled-plugins/security/index.ts +88 -43
  24. package/src/bundled-plugins/security/permissions.ts +36 -0
  25. package/src/bundled-plugins/security/policies/git-exfil.ts +20 -0
  26. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +12 -0
  27. package/src/bundled-plugins/security/policies/prompt-injection.ts +23 -3
  28. package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +7 -0
  29. package/src/bundled-plugins/security/policies/secret-exfil-read.ts +6 -0
  30. package/src/bundled-plugins/security/policies/session-search-secrets.ts +9 -0
  31. package/src/bundled-plugins/security/policies/ssrf.ts +6 -0
  32. package/src/bundled-plugins/security/policies/system-prompt-leak.ts +7 -0
  33. package/src/channels/adapters/github/auth-app.ts +120 -0
  34. package/src/channels/adapters/github/auth-pat.ts +50 -0
  35. package/src/channels/adapters/github/auth.ts +33 -0
  36. package/src/channels/adapters/github/channel-resolver.ts +30 -0
  37. package/src/channels/adapters/github/dedup.ts +26 -0
  38. package/src/channels/adapters/github/event-allowlist.ts +8 -0
  39. package/src/channels/adapters/github/fetch-attachment.ts +5 -0
  40. package/src/channels/adapters/github/history.ts +63 -0
  41. package/src/channels/adapters/github/inbound.ts +286 -0
  42. package/src/channels/adapters/github/index.ts +370 -0
  43. package/src/channels/adapters/github/managed-path.ts +54 -0
  44. package/src/channels/adapters/github/membership.ts +35 -0
  45. package/src/channels/adapters/github/outbound.ts +145 -0
  46. package/src/channels/adapters/github/webhook-register.ts +349 -0
  47. package/src/channels/manager.ts +94 -9
  48. package/src/channels/router.ts +194 -28
  49. package/src/channels/schema.ts +31 -1
  50. package/src/channels/tunnel-bridge.ts +51 -0
  51. package/src/channels/types.ts +3 -1
  52. package/src/cli/builtins.ts +28 -0
  53. package/src/cli/channel.ts +511 -25
  54. package/src/cli/container-command-client.ts +244 -0
  55. package/src/cli/cron.ts +173 -0
  56. package/src/cli/host-command-runner.ts +150 -0
  57. package/src/cli/index.ts +42 -1
  58. package/src/cli/init.ts +400 -67
  59. package/src/cli/model.ts +14 -4
  60. package/src/cli/oauth-callbacks.ts +49 -0
  61. package/src/cli/plugin-command-help.ts +49 -0
  62. package/src/cli/plugin-commands-dispatch.ts +112 -0
  63. package/src/cli/plugin-commands.ts +118 -0
  64. package/src/cli/provider.ts +3 -20
  65. package/src/cli/tui.ts +10 -2
  66. package/src/cli/tunnel.ts +533 -0
  67. package/src/cli/ui.ts +8 -3
  68. package/src/config/config.ts +134 -24
  69. package/src/config/models-mutation.ts +42 -8
  70. package/src/config/providers-mutation.ts +12 -8
  71. package/src/container/start.ts +48 -4
  72. package/src/cron/bridge.ts +136 -0
  73. package/src/cron/consumer.ts +174 -48
  74. package/src/cron/index.ts +19 -2
  75. package/src/cron/list.ts +105 -0
  76. package/src/cron/scheduler.ts +12 -3
  77. package/src/cron/schema.ts +11 -3
  78. package/src/doctor/checks.ts +0 -50
  79. package/src/init/dockerfile.ts +165 -13
  80. package/src/init/ensure-deps.ts +15 -4
  81. package/src/init/github-webhook-install.ts +109 -0
  82. package/src/init/hatching.ts +2 -2
  83. package/src/init/index.ts +519 -12
  84. package/src/init/oauth-login.ts +17 -3
  85. package/src/init/run-bun-install.ts +17 -3
  86. package/src/init/run-owner-claim.ts +11 -2
  87. package/src/permissions/builtins.ts +29 -2
  88. package/src/permissions/match-rule.ts +24 -2
  89. package/src/permissions/permissions.ts +24 -7
  90. package/src/permissions/resolve.ts +1 -0
  91. package/src/plugin/define.ts +44 -1
  92. package/src/plugin/index.ts +18 -3
  93. package/src/plugin/manager.ts +16 -0
  94. package/src/plugin/registry.ts +85 -3
  95. package/src/plugin/types.ts +144 -1
  96. package/src/plugin/zod-introspect.ts +100 -0
  97. package/src/role-claim/match-rule.ts +2 -1
  98. package/src/run/index.ts +112 -4
  99. package/src/secrets/index.ts +1 -1
  100. package/src/secrets/schema.ts +21 -0
  101. package/src/server/command-runner.ts +476 -0
  102. package/src/server/index.ts +388 -5
  103. package/src/shared/index.ts +8 -0
  104. package/src/shared/protocol.ts +80 -1
  105. package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
  106. package/src/skills/typeclaw-config/SKILL.md +27 -26
  107. package/src/skills/typeclaw-cron/SKILL.md +234 -3
  108. package/src/skills/typeclaw-memory/SKILL.md +25 -15
  109. package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
  110. package/src/skills/typeclaw-permissions/SKILL.md +35 -16
  111. package/src/skills/typeclaw-plugins/SKILL.md +251 -5
  112. package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
  113. package/src/test-helpers/wait-for.ts +50 -0
  114. package/src/tui/index.ts +70 -7
  115. package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
  116. package/src/tunnels/events.ts +14 -0
  117. package/src/tunnels/index.ts +12 -0
  118. package/src/tunnels/log-ring.ts +54 -0
  119. package/src/tunnels/manager.ts +139 -0
  120. package/src/tunnels/providers/cloudflare-quick.ts +189 -0
  121. package/src/tunnels/providers/external.ts +53 -0
  122. package/src/tunnels/quick-url-parser.ts +5 -0
  123. package/src/tunnels/types.ts +43 -0
  124. package/src/usage/report.ts +15 -12
  125. package/typeclaw.schema.json +311 -26
@@ -0,0 +1,370 @@
1
+ import type { ChannelRouter } from '@/channels/router'
2
+ import type { ChannelAdapterConfig, GithubAdapterConfig } from '@/channels/schema'
3
+ import { resolveSecret } from '@/secrets/resolve'
4
+ import type { GithubSecretsBlock } from '@/secrets/schema'
5
+
6
+ import { buildAuthStrategy } from './auth'
7
+ import { createGithubChannelNameResolver } from './channel-resolver'
8
+ import { createDeliveryDedup } from './dedup'
9
+ import { createGithubFetchAttachmentCallback } from './fetch-attachment'
10
+ import { createGithubHistoryCallback } from './history'
11
+ import { createGithubWebhookHandler } from './inbound'
12
+ import { applyManagedPath, buildManagedPath, resolveAgentId } from './managed-path'
13
+ import { createGithubMembershipResolver } from './membership'
14
+ import { createGithubOutboundCallback } from './outbound'
15
+ import { deregisterGithubWebhooks, registerGithubWebhooks, type WebhookRegistrationResult } from './webhook-register'
16
+
17
+ export type GithubAdapterLogger = {
18
+ info: (m: string) => void
19
+ warn: (m: string) => void
20
+ error: (m: string) => void
21
+ }
22
+
23
+ export type GithubAdapterOptions = {
24
+ router: ChannelRouter
25
+ configRef: () => ChannelAdapterConfig & GithubAdapterConfig
26
+ secrets: GithubSecretsBlock
27
+ agentDir: string
28
+ logger?: GithubAdapterLogger
29
+ fetchImpl?: typeof fetch
30
+ httpListenImpl?: (port: number, handler: (req: Request) => Promise<Response>) => { stop: () => Promise<void> }
31
+ tunnelUrl?: () => string | null
32
+ // Whether a channel-bound tunnel exists in typeclaw.json#tunnels[] for the
33
+ // github channel. Used to distinguish "no tunnel configured (operator opted
34
+ // out)" from "tunnel configured but not producing a URL (something is
35
+ // wrong)" so the skip-registration log can be precise and actionable.
36
+ // Optional so tests that don't exercise the tunnel-status path can omit it.
37
+ tunnelConfiguredForChannel?: () => boolean
38
+ }
39
+
40
+ export type GithubAdapter = {
41
+ start: () => Promise<void>
42
+ stop: () => Promise<void>
43
+ isConnected: () => boolean
44
+ }
45
+
46
+ const consoleLogger: GithubAdapterLogger = {
47
+ info: (m) => console.log(m),
48
+ warn: (m) => console.warn(m),
49
+ error: (m) => console.error(m),
50
+ }
51
+
52
+ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapter {
53
+ const logger = options.logger ?? consoleLogger
54
+ const fetchImpl = options.fetchImpl ?? fetch
55
+ const auth = buildAuthStrategy({ auth: options.secrets.auth, fetchImpl })
56
+ const webhookSecret = resolveSecret(options.secrets.webhookSecret, undefined, process.env)
57
+ if (webhookSecret === undefined || webhookSecret.trim() === '') throw new Error('GitHub webhookSecret is missing')
58
+
59
+ let server: { stop: () => Promise<void> } | null = null
60
+ let selfId: string | null = null
61
+ let selfLogin: string | null = null
62
+ let started = false
63
+ let managedHooks: ReadonlyArray<{ repo: string; hookId: number }> = []
64
+ const workspaceByChat = new Map<string, string>()
65
+
66
+ const rememberWorkspace = (workspace: string, chat: string): void => {
67
+ workspaceByChat.set(chat, workspace)
68
+ }
69
+
70
+ const tokenFn = async () => {
71
+ const t = await auth.token()
72
+ process.env.GH_TOKEN = t
73
+ return t
74
+ }
75
+ const outbound = createGithubOutboundCallback({ token: tokenFn, logger, fetchImpl })
76
+ const history = createGithubHistoryCallback({
77
+ token: tokenFn,
78
+ fetchImpl,
79
+ workspaceForChat: (chat) => workspaceByChat.get(chat) ?? null,
80
+ })
81
+ const membership = createGithubMembershipResolver({ token: tokenFn, fetchImpl })
82
+ const channelNameResolver = createGithubChannelNameResolver({ token: tokenFn, fetchImpl })
83
+ const fetchAttachment = createGithubFetchAttachmentCallback()
84
+ // No-op typing callback: GitHub has no typing indicator API.
85
+ const typing = async (): Promise<void> => {}
86
+ const dedup = createDeliveryDedup()
87
+ const handler = createGithubWebhookHandler({
88
+ webhookSecret,
89
+ dedup,
90
+ allowlist: () => options.configRef().eventAllowlist,
91
+ selfId: () => selfId,
92
+ selfLogin: () => selfLogin,
93
+ logger,
94
+ route: (message) => {
95
+ rememberWorkspace(message.workspace, message.chat)
96
+ // Ack-first: wrap in Promise.resolve so a synchronous throw inside
97
+ // router.route() cannot prevent the 200 response from being returned.
98
+ void Promise.resolve()
99
+ .then(() => options.router.route(message))
100
+ .catch((err: unknown) => {
101
+ logger.error(`[github] route failed: ${err instanceof Error ? err.message : String(err)}`)
102
+ })
103
+ },
104
+ })
105
+
106
+ return {
107
+ async start(): Promise<void> {
108
+ if (started) return
109
+ const self = await auth.getSelf()
110
+ selfId = String(self.id)
111
+ selfLogin = self.login
112
+ // Register all callbacks before binding the HTTP listener so the router
113
+ // is fully wired before any webhook can arrive.
114
+ options.router.registerOutbound('github', outbound)
115
+ options.router.registerTyping('github', typing)
116
+ options.router.registerHistory('github', history)
117
+ options.router.registerMembership('github', membership)
118
+ options.router.registerChannelNameResolver('github', channelNameResolver)
119
+ options.router.registerFetchAttachment('github', fetchAttachment)
120
+ try {
121
+ server = (options.httpListenImpl ?? listenWithBun)(options.configRef().webhookPort, handler)
122
+ } catch (err) {
123
+ // Listener failed — roll back all registrations so stop() is a no-op
124
+ // and the manager can report the failure cleanly.
125
+ options.router.unregisterOutbound('github', outbound)
126
+ options.router.unregisterTyping('github', typing)
127
+ options.router.unregisterHistory('github', history)
128
+ options.router.unregisterMembership('github', membership)
129
+ options.router.unregisterChannelNameResolver('github', channelNameResolver)
130
+ options.router.unregisterFetchAttachment('github', fetchAttachment)
131
+ await auth.dispose()
132
+ delete process.env.GH_TOKEN
133
+ selfId = null
134
+ selfLogin = null
135
+ throw err
136
+ }
137
+ // Seed GH_TOKEN so `gh` CLI calls in the container are pre-authenticated.
138
+ // tokenFn keeps it current on every adapter API call; App tokens refresh
139
+ // automatically when within 5 minutes of expiry.
140
+ process.env.GH_TOKEN = await auth.token()
141
+ started = true
142
+ logger.info(`[github] webhook listening on port ${options.configRef().webhookPort} as @${self.login}`)
143
+ // Repository webhook registration is best-effort: failures are logged
144
+ // per-repo, the adapter stays up. A misconfigured PAT or App that
145
+ // can't manage hooks must not prevent the adapter from accepting
146
+ // events for repos whose hooks are already registered.
147
+ const cfg = options.configRef()
148
+ const repos = cfg.repos ?? []
149
+ const tunnelUrl = options.tunnelUrl?.() ?? null
150
+ if (cfg.webhookUrl !== undefined && tunnelUrl !== null) {
151
+ logger.warn('[github] webhookUrl configured; ignoring tunnel URL for webhook registration')
152
+ }
153
+ const rawUrl = cfg.webhookUrl ?? tunnelUrl
154
+ const managedPath = buildManagedPath(
155
+ resolveAgentId({ containerName: process.env.TYPECLAW_CONTAINER_NAME, agentDir: options.agentDir }),
156
+ )
157
+ const effectiveUrl = rawUrl === null ? null : applyManagedPath(rawUrl, managedPath)
158
+ if (effectiveUrl === null) {
159
+ logSkippedRegistration(logger, {
160
+ tunnelConfigured: options.tunnelConfiguredForChannel?.() ?? false,
161
+ reposCount: repos.length,
162
+ })
163
+ } else if (repos.length > 0) {
164
+ const legacyProviderHostSuffix = detectLegacyProviderHostSuffix(effectiveUrl)
165
+ const registration = await registerGithubWebhooks({
166
+ token: tokenFn,
167
+ webhookUrl: effectiveUrl,
168
+ webhookSecret,
169
+ repos,
170
+ events: cfg.eventAllowlist,
171
+ managedPath,
172
+ ...(legacyProviderHostSuffix !== undefined ? { legacyProviderHostSuffix } : {}),
173
+ fetchImpl,
174
+ })
175
+ managedHooks = registration.repos.flatMap((r) =>
176
+ r.action === 'created' || r.action === 'updated' ? [{ repo: r.repo, hookId: r.hookId }] : [],
177
+ )
178
+ logRegistrationOutcome(logger, registration, options.secrets.auth.type)
179
+ }
180
+ },
181
+ async stop(): Promise<void> {
182
+ if (!started) return
183
+ started = false
184
+ options.router.unregisterOutbound('github', outbound)
185
+ options.router.unregisterTyping('github', typing)
186
+ options.router.unregisterHistory('github', history)
187
+ options.router.unregisterMembership('github', membership)
188
+ options.router.unregisterChannelNameResolver('github', channelNameResolver)
189
+ options.router.unregisterFetchAttachment('github', fetchAttachment)
190
+ await server?.stop()
191
+ // Detach hooks AFTER closing the listener so any in-flight deliveries
192
+ // from GitHub no longer hit a live receiver while we're tearing down.
193
+ // The token call uses the still-live `auth` strategy; dispose() runs
194
+ // last to clear the cached App-installation token.
195
+ if (managedHooks.length > 0) {
196
+ const deregistration = await deregisterGithubWebhooks({
197
+ token: tokenFn,
198
+ hooks: managedHooks,
199
+ fetchImpl,
200
+ })
201
+ logDeregistrationOutcome(logger, deregistration)
202
+ managedHooks = []
203
+ }
204
+ await auth.dispose()
205
+ delete process.env.GH_TOKEN
206
+ server = null
207
+ selfId = null
208
+ selfLogin = null
209
+ },
210
+ isConnected(): boolean {
211
+ return started && selfLogin !== null
212
+ },
213
+ }
214
+ }
215
+
216
+ function listenWithBun(port: number, handler: (req: Request) => Promise<Response>): { stop: () => Promise<void> } {
217
+ const server = Bun.serve({ port, fetch: handler })
218
+ return { stop: async () => server.stop() }
219
+ }
220
+
221
+ function logSkippedRegistration(
222
+ logger: GithubAdapterLogger,
223
+ context: { tunnelConfigured: boolean; reposCount: number },
224
+ ): void {
225
+ if (context.reposCount === 0) {
226
+ logger.info('[github] no repos[] configured; webhook registration skipped')
227
+ return
228
+ }
229
+ if (context.tunnelConfigured) {
230
+ logger.warn(
231
+ '[github] webhook registration SKIPPED: a tunnel is configured for this channel but produced no URL yet. ' +
232
+ "Check `typeclaw tunnel status` for the tunnel's health (cloudflared binary missing, " +
233
+ 'auth failure, network issue). Webhook delivery will not work until the tunnel produces a public URL.',
234
+ )
235
+ return
236
+ }
237
+ logger.warn(
238
+ '[github] webhook registration SKIPPED: no `channels.github.webhookUrl` set and no `tunnels[]` entry ' +
239
+ 'binds a public URL to this channel. Add an entry to `tunnels[]` (e.g. `provider: "cloudflare-quick"`) ' +
240
+ 'or set `channels.github.webhookUrl` to a public URL to enable webhook delivery.',
241
+ )
242
+ }
243
+
244
+ // Known tunnel-provider host suffixes whose hostnames rotate per container.
245
+ // A pre-marker hook on one of these is unambiguously a typeclaw orphan from
246
+ // this agent's prior runs (cloudflare-quick is per-container, the host
247
+ // changes every restart, so a stale unmarked *.trycloudflare.com hook
248
+ // pointing at a now-dead host cannot belong to any live service).
249
+ // Extending: add the host suffix here AND verify that hooks on the new
250
+ // provider always look unmarked (no operator-supplied path) before the
251
+ // marker was introduced.
252
+ const LEGACY_TUNNEL_PROVIDER_HOSTS: readonly string[] = ['.trycloudflare.com']
253
+
254
+ function detectLegacyProviderHostSuffix(url: string): string | undefined {
255
+ let parsed: URL
256
+ try {
257
+ parsed = new URL(url)
258
+ } catch {
259
+ return undefined
260
+ }
261
+ for (const suffix of LEGACY_TUNNEL_PROVIDER_HOSTS) {
262
+ if (parsed.host.endsWith(suffix)) return suffix
263
+ }
264
+ return undefined
265
+ }
266
+
267
+ function logRegistrationOutcome(
268
+ logger: GithubAdapterLogger,
269
+ result: WebhookRegistrationResult,
270
+ authType: 'pat' | 'app',
271
+ ): void {
272
+ const permissionFailures: Array<{ repo: string; status: number }> = []
273
+ for (const r of result.repos) {
274
+ if (r.action === 'created') logger.info(`[github] registered webhook ${r.hookId} on ${r.repo}`)
275
+ else if (r.action === 'updated') {
276
+ const tail = r.stalePruned > 0 ? ` (pruned ${r.stalePruned} stale)` : ''
277
+ logger.info(`[github] updated webhook ${r.hookId} on ${r.repo}${tail}`)
278
+ } else {
279
+ logger.warn(`[github] webhook register failed for ${r.repo}: ${r.error}`)
280
+ const status = parseListHooksPermissionStatus(r.error)
281
+ if (status !== null) permissionFailures.push({ repo: r.repo, status })
282
+ }
283
+ }
284
+ // One guidance block per start() (not per repo) so a 10-repo permission
285
+ // failure doesn't paste the same paragraph 10 times. The names below MUST
286
+ // match the current github.com UI labels — see comment in
287
+ // buildPermissionGuidance.
288
+ if (permissionFailures.length > 0) {
289
+ logger.warn(buildPermissionGuidance(authType, permissionFailures))
290
+ }
291
+ }
292
+
293
+ // Parses webhook-register errors of the shape `list hooks failed: <status> <body>`.
294
+ // Returns the status code when it matches the two shapes GitHub emits for
295
+ // missing access on the list-hooks endpoint:
296
+ // - 404 Not Found: the token cannot see the repo at all (private repo
297
+ // gated behind missing repository access — GitHub returns 404 instead of
298
+ // 403 to avoid leaking the existence of private repos).
299
+ // - 403 Forbidden: the token sees the repo but lacks webhook-management
300
+ // permission, OR is blocked by an org SSO/SAML authorization gate.
301
+ // Returns null for any other error (network, malformed slug, create-hook
302
+ // failures, etc.) so the guidance only fires on the actual symptom.
303
+ export function parseListHooksPermissionStatus(error: string): number | null {
304
+ const match = error.match(/^list hooks failed: (404|403)\b/)
305
+ if (match === null) return null
306
+ return Number(match[1])
307
+ }
308
+
309
+ // The labels below intentionally mirror github.com's current UI verbatim so a
310
+ // user can grep their settings page for the exact string. If GitHub renames
311
+ // any of these in a future redesign, update both here and the
312
+ // `permissionGuidance` tests in lifecycle.test.ts.
313
+ //
314
+ // Fine-grained PAT:
315
+ // Settings → Developer settings → Personal access tokens → Fine-grained tokens
316
+ // "Resource owner", "Repository access", "Repository permissions" → "Webhooks" → "Read and write", "Metadata" → "Read-only"
317
+ // GitHub App:
318
+ // Settings → Developer settings → GitHub Apps → <app> → Permissions & events
319
+ // "Repository permissions" → "Webhooks" → "Read and write"
320
+ // Install/configure on the org: <app settings> → Install App / Configure → "Repository access"
321
+ // Classic PAT (legacy, still supported by GitHub but we don't surface it in
322
+ // channel-add prompts):
323
+ // Settings → Developer settings → Personal access tokens (classic)
324
+ // Scope: "admin:repo_hook" (or full "repo" for private repositories)
325
+ export function buildPermissionGuidance(
326
+ authType: 'pat' | 'app',
327
+ failures: ReadonlyArray<{ repo: string; status: number }>,
328
+ ): string {
329
+ const repoList = failures.map((f) => `${f.repo} (${f.status})`).join(', ')
330
+ const lines: string[] = [
331
+ `[github] webhook setup needs more access for: ${repoList}.`,
332
+ ' - 404 from GitHub means the token cannot see the repo (GitHub hides private repos behind 404 instead of 403).',
333
+ ' - 403 means the token sees the repo but lacks webhook permission, or is blocked by org SAML/SSO.',
334
+ '',
335
+ ]
336
+ if (authType === 'pat') {
337
+ lines.push(
338
+ ' Fix (fine-grained personal access token):',
339
+ ' 1. Open https://github.com/settings/personal-access-tokens and edit the token TypeClaw is using.',
340
+ ' 2. Under "Resource owner", select the org that owns the failing repos (e.g. the org in the slug above).',
341
+ ' 3. Under "Repository access", choose "Only select repositories" and add every failing repo (or pick "All repositories").',
342
+ ' 4. Under "Repository permissions", set "Webhooks" to "Read and write" and "Metadata" to "Read-only".',
343
+ ' 5. Save. If the org enforces SAML SSO, click "Configure SSO" next to the token and authorize the org.',
344
+ '',
345
+ ' Or (classic personal access token): grant the "admin:repo_hook" scope (or "repo" for private repos),',
346
+ ' and on a SAML-protected org click "Authorize" next to the token.',
347
+ )
348
+ } else {
349
+ lines.push(
350
+ ' Fix (GitHub App):',
351
+ ' 1. Open https://github.com/settings/apps and edit the app TypeClaw is using.',
352
+ ' 2. Under "Permissions & events" → "Repository permissions", set "Webhooks" to "Read and write". Save.',
353
+ ' 3. From the app page, click "Install App" (or "Configure" if already installed) and select the org that owns the failing repos.',
354
+ ' 4. Under "Repository access", choose "Only select repositories" and add every failing repo (or pick "All repositories").',
355
+ ' 5. If the app permissions changed in step 2, install owners must accept the updated permissions from the install page before the new access takes effect.',
356
+ )
357
+ }
358
+ return lines.join('\n')
359
+ }
360
+
361
+ function logDeregistrationOutcome(
362
+ logger: GithubAdapterLogger,
363
+ result: Awaited<ReturnType<typeof deregisterGithubWebhooks>>,
364
+ ): void {
365
+ for (const h of result.hooks) {
366
+ if (h.action === 'deleted') logger.info(`[github] detached webhook ${h.hookId} from ${h.repo}`)
367
+ else if (h.action === 'missing') logger.info(`[github] webhook ${h.hookId} on ${h.repo} already gone`)
368
+ else logger.warn(`[github] webhook detach failed for ${h.repo}#${h.hookId}: ${h.error ?? 'unknown error'}`)
369
+ }
370
+ }
@@ -0,0 +1,54 @@
1
+ import { basename, resolve } from 'node:path'
2
+
3
+ // `v1` is a schema version for the marker layout. Bumping it lets a future
4
+ // change (e.g. embedding a per-repo nonce, switching to a different ownership
5
+ // scheme) coexist with hooks created under earlier versions instead of
6
+ // stranding them. `findManagedHooks` only treats the current version as ours;
7
+ // a v2 rollout would need a one-shot pass that adopts v1 hooks before
8
+ // retiring them.
9
+ const MARKER_PREFIX = '/typeclaw/v1/github/'
10
+
11
+ export function buildManagedPath(agentId: string): string {
12
+ const safe = sanitizeAgentId(agentId)
13
+ return `${MARKER_PREFIX}${safe}`
14
+ }
15
+
16
+ // `containerName` (TYPECLAW_CONTAINER_NAME) is the load-bearing identifier
17
+ // inside the container; falls back to the agent folder basename for host-side
18
+ // callers (e.g. eager webhook install at `typeclaw channel add github` time)
19
+ // that don't have the env var set yet. Both resolve to the same string in
20
+ // practice — see `containerNameFromCwd` in src/container/shared.ts.
21
+ export function resolveAgentId(options: { containerName?: string; agentDir: string }): string {
22
+ const fromEnv = options.containerName?.trim()
23
+ if (fromEnv && fromEnv.length > 0) return fromEnv
24
+ return basename(resolve(options.agentDir))
25
+ }
26
+
27
+ // Append the marker path to a URL that's missing one. The cloudflare-quick
28
+ // tunnel hands us `https://<random>.trycloudflare.com` with no path; we want
29
+ // the marker visible in the resulting webhook URL so a future run of THIS
30
+ // agent can recognize the hook as ours after the hostname rotates.
31
+ //
32
+ // If the URL already has a non-trivial path (user-set webhookUrl), it's
33
+ // returned verbatim. We treat that as "operator owns this URL" — appending
34
+ // our marker would silently change a user-configured webhook URL.
35
+ export function applyManagedPath(rawUrl: string, managedPath: string): string {
36
+ let parsed: URL
37
+ try {
38
+ parsed = new URL(rawUrl)
39
+ } catch {
40
+ return rawUrl
41
+ }
42
+ if (parsed.pathname !== '' && parsed.pathname !== '/') return rawUrl
43
+ parsed.pathname = managedPath
44
+ return parsed.toString()
45
+ }
46
+
47
+ // `containerNameFromCwd` (src/container/shared.ts) clamps to [a-z0-9_.-];
48
+ // applying the same conservative shape here keeps URL paths well-formed even
49
+ // if a caller passes us an unsanitized identifier from somewhere else.
50
+ function sanitizeAgentId(raw: string): string {
51
+ const trimmed = raw.trim().toLowerCase()
52
+ const cleaned = trimmed.replace(/[^a-z0-9_.-]+/g, '-').replace(/^-+|-+$/g, '')
53
+ return cleaned === '' ? 'agent' : cleaned
54
+ }
@@ -0,0 +1,35 @@
1
+ import type { MembershipResolver, MembershipResolverResult } from '@/channels/membership'
2
+
3
+ import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
4
+ import { parseRepo } from './outbound'
5
+
6
+ export function createGithubMembershipResolver(options: {
7
+ token: () => Promise<string>
8
+ fetchImpl?: typeof fetch
9
+ }): MembershipResolver {
10
+ const fetchImpl = options.fetchImpl ?? fetch
11
+ return async (key): Promise<MembershipResolverResult> => {
12
+ if (key.adapter !== 'github') return { kind: 'permanent' }
13
+ const repo = parseRepo(key.workspace)
14
+ if (repo === null) return { kind: 'permanent' }
15
+ try {
16
+ const response = await fetchImpl(
17
+ `${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/collaborators?per_page=100`,
18
+ {
19
+ headers: githubJsonHeaders(await options.token()),
20
+ },
21
+ )
22
+ if (!response.ok) return response.status >= 500 ? { kind: 'transient' } : { kind: 'permanent' }
23
+ const users = (await response.json()) as Array<{ type?: string }>
24
+ let bots = 0
25
+ let humans = 0
26
+ for (const user of users) {
27
+ if (user.type === 'Bot') bots++
28
+ else humans++
29
+ }
30
+ return { humans, bots, fetchedAt: Date.now(), truncated: users.length >= 100 }
31
+ } catch {
32
+ return { kind: 'transient' }
33
+ }
34
+ }
35
+ }
@@ -0,0 +1,145 @@
1
+ import type { OutboundCallback, OutboundMessage, SendResult } from '@/channels/types'
2
+
3
+ import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
4
+
5
+ export type GithubOutboundLogger = { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void }
6
+
7
+ export function createGithubOutboundCallback(deps: {
8
+ token: () => Promise<string>
9
+ logger: GithubOutboundLogger
10
+ fetchImpl?: typeof fetch
11
+ }): OutboundCallback {
12
+ const fetchImpl = deps.fetchImpl ?? fetch
13
+ return async (msg: OutboundMessage): Promise<SendResult> => {
14
+ if (msg.adapter !== 'github') return { ok: false, error: `unknown adapter: ${msg.adapter}` }
15
+ if ((msg.attachments ?? []).length > 0) return { ok: false, error: 'github-bot-does-not-support-attachments' }
16
+ const body = msg.text ?? ''
17
+ if (body === '') return { ok: false, error: 'message has neither text nor attachments' }
18
+
19
+ const repo = parseRepo(msg.workspace)
20
+ if (repo === null) return { ok: false, error: `invalid GitHub workspace: ${msg.workspace}` }
21
+ const target = parseChat(msg.chat)
22
+ if (target === null) return { ok: false, error: `invalid GitHub chat: ${msg.chat}` }
23
+
24
+ if (target.kind === 'discussion') {
25
+ return await postDiscussionComment({ ...deps, fetchImpl, repo, discussionNumber: target.number, body })
26
+ }
27
+
28
+ const endpoint =
29
+ target.kind === 'pr' && msg.thread !== null && msg.thread !== undefined && msg.thread !== ''
30
+ ? `${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/pulls/${target.number}/comments/${encodeURIComponent(msg.thread)}/replies`
31
+ : `${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/issues/${target.number}/comments`
32
+ return await postJson(fetchImpl, await deps.token(), endpoint, { body })
33
+ }
34
+ }
35
+
36
+ async function postDiscussionComment(options: {
37
+ token: () => Promise<string>
38
+ fetchImpl: typeof fetch
39
+ repo: RepoRef
40
+ discussionNumber: number
41
+ body: string
42
+ }): Promise<SendResult> {
43
+ const discussionId = await fetchDiscussionId(options)
44
+ if (!discussionId.ok) return discussionId
45
+ const mutation = `mutation($discussionId:ID!,$body:String!){addDiscussionComment(input:{discussionId:$discussionId,body:$body}){comment{id}}}`
46
+ return await postGraphql(options.fetchImpl, await options.token(), mutation, {
47
+ discussionId: discussionId.id,
48
+ body: options.body,
49
+ })
50
+ }
51
+
52
+ async function fetchDiscussionId(options: {
53
+ token: () => Promise<string>
54
+ fetchImpl: typeof fetch
55
+ repo: RepoRef
56
+ discussionNumber: number
57
+ }): Promise<{ ok: true; id: string } | { ok: false; error: string }> {
58
+ const query = `query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){discussion(number:$number){id}}}`
59
+ const result = await graphql<{ repository?: { discussion?: { id?: string } | null } }>(
60
+ options.fetchImpl,
61
+ await options.token(),
62
+ query,
63
+ {
64
+ owner: options.repo.owner,
65
+ name: options.repo.name,
66
+ number: options.discussionNumber,
67
+ },
68
+ )
69
+ if (!result.ok) return result
70
+ const id = result.data.repository?.discussion?.id
71
+ return typeof id === 'string' && id !== '' ? { ok: true, id } : { ok: false, error: 'discussion not found' }
72
+ }
73
+
74
+ async function postGraphql(
75
+ fetchImpl: typeof fetch,
76
+ token: string,
77
+ query: string,
78
+ variables: Record<string, unknown>,
79
+ ): Promise<SendResult> {
80
+ const result = await graphql(fetchImpl, token, query, variables)
81
+ return result.ok ? { ok: true } : { ok: false, error: result.error }
82
+ }
83
+
84
+ async function graphql<T>(
85
+ fetchImpl: typeof fetch,
86
+ token: string,
87
+ query: string,
88
+ variables: Record<string, unknown>,
89
+ ): Promise<{ ok: true; data: T } | { ok: false; error: string }> {
90
+ try {
91
+ const response = await fetchImpl(`${GITHUB_API_BASE}/graphql`, {
92
+ method: 'POST',
93
+ headers: githubJsonHeaders(token),
94
+ body: JSON.stringify({ query, variables }),
95
+ })
96
+ const raw = (await response.json()) as { data?: T; errors?: Array<{ message?: string }> }
97
+ if (!response.ok || raw.errors !== undefined) {
98
+ return {
99
+ ok: false,
100
+ error: raw.errors?.map((e) => e.message ?? 'unknown').join('; ') ?? `HTTP ${response.status}`,
101
+ }
102
+ }
103
+ if (raw.data === undefined) return { ok: false, error: 'GraphQL response missing data' }
104
+ return { ok: true, data: raw.data }
105
+ } catch (err) {
106
+ return { ok: false, error: describe(err) }
107
+ }
108
+ }
109
+
110
+ async function postJson(fetchImpl: typeof fetch, token: string, url: string, payload: unknown): Promise<SendResult> {
111
+ try {
112
+ const response = await fetchImpl(url, {
113
+ method: 'POST',
114
+ headers: githubJsonHeaders(token),
115
+ body: JSON.stringify(payload),
116
+ })
117
+ if (response.ok) return { ok: true }
118
+ const text = await response.text().catch(() => '')
119
+ return { ok: false, error: `GitHub API ${response.status}${text !== '' ? `: ${text}` : ''}` }
120
+ } catch (err) {
121
+ return { ok: false, error: describe(err) }
122
+ }
123
+ }
124
+
125
+ type RepoRef = { owner: string; name: string }
126
+ type ChatRef = { kind: 'issue' | 'pr' | 'discussion'; number: number }
127
+
128
+ export function parseRepo(workspace: string): RepoRef | null {
129
+ const [owner, name, extra] = workspace.split('/')
130
+ if (!owner || !name || extra !== undefined) return null
131
+ return { owner, name }
132
+ }
133
+
134
+ export function parseChat(chat: string): ChatRef | null {
135
+ const [kind, rawNumber] = chat.split(':')
136
+ const number = Number(rawNumber)
137
+ if ((kind !== 'issue' && kind !== 'pr' && kind !== 'discussion') || !Number.isInteger(number) || number <= 0) {
138
+ return null
139
+ }
140
+ return { kind, number }
141
+ }
142
+
143
+ function describe(err: unknown): string {
144
+ return err instanceof Error ? err.message : String(err)
145
+ }