typeclaw 0.3.1 → 0.4.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 (89) 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/session-meta.ts +1 -1
  6. package/src/agent/session-origin.ts +3 -2
  7. package/src/bundled-plugins/security/index.ts +3 -2
  8. package/src/channels/adapters/github/auth-app.ts +120 -0
  9. package/src/channels/adapters/github/auth-pat.ts +50 -0
  10. package/src/channels/adapters/github/auth.ts +33 -0
  11. package/src/channels/adapters/github/channel-resolver.ts +30 -0
  12. package/src/channels/adapters/github/dedup.ts +26 -0
  13. package/src/channels/adapters/github/event-allowlist.ts +8 -0
  14. package/src/channels/adapters/github/fetch-attachment.ts +5 -0
  15. package/src/channels/adapters/github/history.ts +63 -0
  16. package/src/channels/adapters/github/inbound.ts +286 -0
  17. package/src/channels/adapters/github/index.ts +286 -0
  18. package/src/channels/adapters/github/managed-path.ts +54 -0
  19. package/src/channels/adapters/github/membership.ts +35 -0
  20. package/src/channels/adapters/github/outbound.ts +145 -0
  21. package/src/channels/adapters/github/webhook-register.ts +349 -0
  22. package/src/channels/manager.ts +94 -9
  23. package/src/channels/schema.ts +31 -1
  24. package/src/channels/tunnel-bridge.ts +51 -0
  25. package/src/cli/builtins.ts +28 -0
  26. package/src/cli/channel.ts +511 -25
  27. package/src/cli/container-command-client.ts +244 -0
  28. package/src/cli/cron.ts +173 -0
  29. package/src/cli/host-command-runner.ts +150 -0
  30. package/src/cli/index.ts +42 -1
  31. package/src/cli/init.ts +256 -27
  32. package/src/cli/model.ts +4 -2
  33. package/src/cli/plugin-command-help.ts +49 -0
  34. package/src/cli/plugin-commands-dispatch.ts +112 -0
  35. package/src/cli/plugin-commands.ts +118 -0
  36. package/src/cli/tui.ts +10 -2
  37. package/src/cli/tunnel.ts +533 -0
  38. package/src/cli/ui.ts +8 -3
  39. package/src/config/config.ts +75 -0
  40. package/src/container/start.ts +30 -3
  41. package/src/cron/bridge.ts +136 -0
  42. package/src/cron/consumer.ts +45 -5
  43. package/src/cron/index.ts +19 -2
  44. package/src/cron/list.ts +105 -0
  45. package/src/cron/scheduler.ts +12 -3
  46. package/src/cron/schema.ts +11 -3
  47. package/src/doctor/checks.ts +0 -50
  48. package/src/init/dockerfile.ts +59 -13
  49. package/src/init/ensure-deps.ts +15 -4
  50. package/src/init/github-webhook-install.ts +109 -0
  51. package/src/init/index.ts +505 -9
  52. package/src/init/run-bun-install.ts +17 -3
  53. package/src/init/run-owner-claim.ts +11 -2
  54. package/src/permissions/builtins.ts +6 -1
  55. package/src/permissions/match-rule.ts +24 -2
  56. package/src/permissions/resolve.ts +1 -0
  57. package/src/plugin/define.ts +42 -1
  58. package/src/plugin/index.ts +18 -3
  59. package/src/plugin/manager.ts +2 -0
  60. package/src/plugin/registry.ts +85 -3
  61. package/src/plugin/types.ts +138 -1
  62. package/src/plugin/zod-introspect.ts +100 -0
  63. package/src/role-claim/match-rule.ts +2 -1
  64. package/src/run/index.ts +110 -3
  65. package/src/secrets/index.ts +1 -1
  66. package/src/secrets/schema.ts +21 -0
  67. package/src/server/command-runner.ts +476 -0
  68. package/src/server/index.ts +388 -5
  69. package/src/shared/index.ts +8 -0
  70. package/src/shared/protocol.ts +80 -1
  71. package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
  72. package/src/skills/typeclaw-config/SKILL.md +27 -26
  73. package/src/skills/typeclaw-cron/SKILL.md +234 -3
  74. package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
  75. package/src/skills/typeclaw-permissions/SKILL.md +5 -4
  76. package/src/skills/typeclaw-plugins/SKILL.md +251 -5
  77. package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
  78. package/src/test-helpers/wait-for.ts +50 -0
  79. package/src/tui/index.ts +35 -4
  80. package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
  81. package/src/tunnels/events.ts +14 -0
  82. package/src/tunnels/index.ts +12 -0
  83. package/src/tunnels/log-ring.ts +54 -0
  84. package/src/tunnels/manager.ts +139 -0
  85. package/src/tunnels/providers/cloudflare-quick.ts +189 -0
  86. package/src/tunnels/providers/external.ts +53 -0
  87. package/src/tunnels/quick-url-parser.ts +5 -0
  88. package/src/tunnels/types.ts +43 -0
  89. package/typeclaw.schema.json +254 -1
package/README.md CHANGED
@@ -35,6 +35,7 @@ TypeClaw is the agent I wanted to use:
35
35
  - 🔄 **Hot reload** — change `typeclaw.json`, `typeclaw reload` — no restart for most fields
36
36
  - 🔁 **Self-restart** — the agent can bounce its own container when it updates itself
37
37
  - 🌐 **Auto port-forward** — dev servers inside the container appear on `localhost`, even loopback-only ones
38
+ - 🌍 **Public tunnels** — Cloudflare Quick (zero signup) or bring-your-own external URL; the agent self-registers GitHub webhooks at the resulting public URL
38
39
  - 🎼 **Compose** — orchestrate multiple agents across multiple folders
39
40
 
40
41
  ### 🌱 Self-improving, in detail
@@ -68,20 +69,23 @@ That's it. The agent is now alive, listening on a websocket, ready to receive pr
68
69
 
69
70
  ## CLI
70
71
 
71
- | Command | Purpose |
72
- | ----------------------------------- | ---------------------------------------------------------------------------------- |
73
- | `typeclaw init` | Scaffold a new agent folder |
74
- | `typeclaw start` | Build and run the container |
75
- | `typeclaw stop` | Stop the container |
76
- | `typeclaw restart` | `stop` then `start` |
77
- | `typeclaw status` | Show container + daemon registration state |
78
- | `typeclaw logs` | Stream container stdout/stderr with local timestamps; `-f` to follow |
79
- | `typeclaw tui` | Attach a terminal UI over the agent's websocket |
80
- | `typeclaw shell` | Open a shell inside the running container |
81
- | `typeclaw reload` | Push a live config reload to the running agent |
82
- | `typeclaw compose` | Orchestrate multiple agents |
83
- | `typeclaw channel add <kind>` | Wire a new channel adapter (Slack, Discord, Telegram, KakaoTalk) |
84
- | `typeclaw channel reauth kakaotalk` | Re-authenticate KakaoTalk after a stale-token 401 or to rotate the stored password |
72
+ | Command | Purpose |
73
+ | ----------------------------------- | ----------------------------------------------------------------------------------- |
74
+ | `typeclaw init` | Scaffold a new agent folder |
75
+ | `typeclaw start` | Build and run the container |
76
+ | `typeclaw stop` | Stop the container |
77
+ | `typeclaw restart` | `stop` then `start` |
78
+ | `typeclaw status` | Show container + daemon registration state |
79
+ | `typeclaw logs` | Stream container stdout/stderr with local timestamps; `-f` to follow |
80
+ | `typeclaw tui` | Attach a terminal UI over the agent's websocket |
81
+ | `typeclaw shell` | Open a shell inside the running container |
82
+ | `typeclaw reload` | Push a live config reload to the running agent |
83
+ | `typeclaw compose` | Orchestrate multiple agents |
84
+ | `typeclaw cron list` | List every cron job registered in the running agent (user `cron.json` + plugins) |
85
+ | `typeclaw channel add <kind>` | Wire a new channel adapter (Slack, Discord, Telegram, KakaoTalk, GitHub) |
86
+ | `typeclaw channel set <kind>` | Rotate the credentials of an already-configured channel (bot/app tokens, PAT, etc.) |
87
+ | `typeclaw channel reauth kakaotalk` | Re-authenticate KakaoTalk after a stale-token 401 or to rotate the stored password |
88
+ | `typeclaw tunnel ...` | Add/list/status/remove public tunnels and inspect tunnel logs |
85
89
 
86
90
  ## Configuration
87
91
 
@@ -107,7 +111,8 @@ my-agent/
107
111
  - `plugins` — list of plugin module specifiers
108
112
  - `channels` — `slack-bot` / `discord-bot` config
109
113
  - `portForward` — allow/deny list for auto port forwarding (default: `*`)
110
- - `dockerfile` — toggles for `gh`, `python`, `tmux`, `ffmpeg`, plus `append` lines
114
+ - `tunnels` — declare public URLs for inbound webhooks and ad-hoc exposure (`cloudflare-quick` or `external`)
115
+ - `dockerfile` — toggles for `gh`, `python`, `tmux`, `ffmpeg`, `cjkFonts`, plus `append` lines
111
116
  - `memory` — idle window and dreaming schedule for the memory plugin
112
117
 
113
118
  `Dockerfile` and `.gitignore` are owned by TypeClaw and rewritten on every `start` — edit `src/init/dockerfile.ts` and re-run `start --build` to ship template changes.
package/auth.schema.json CHANGED
@@ -142,6 +142,119 @@
142
142
  }
143
143
  }
144
144
  },
145
+ "github": {
146
+ "type": "object",
147
+ "properties": {
148
+ "auth": {
149
+ "oneOf": [
150
+ {
151
+ "type": "object",
152
+ "properties": {
153
+ "type": {
154
+ "type": "string",
155
+ "const": "pat"
156
+ },
157
+ "token": {
158
+ "anyOf": [
159
+ {
160
+ "type": "string",
161
+ "minLength": 1
162
+ },
163
+ {
164
+ "type": "object",
165
+ "properties": {
166
+ "value": {
167
+ "type": "string",
168
+ "minLength": 1
169
+ },
170
+ "env": {
171
+ "type": "string",
172
+ "minLength": 1
173
+ }
174
+ }
175
+ }
176
+ ]
177
+ }
178
+ },
179
+ "required": [
180
+ "type",
181
+ "token"
182
+ ]
183
+ },
184
+ {
185
+ "type": "object",
186
+ "properties": {
187
+ "type": {
188
+ "type": "string",
189
+ "const": "app"
190
+ },
191
+ "appId": {
192
+ "type": "integer",
193
+ "exclusiveMinimum": 0,
194
+ "maximum": 9007199254740991
195
+ },
196
+ "privateKey": {
197
+ "anyOf": [
198
+ {
199
+ "type": "string",
200
+ "minLength": 1
201
+ },
202
+ {
203
+ "type": "object",
204
+ "properties": {
205
+ "value": {
206
+ "type": "string",
207
+ "minLength": 1
208
+ },
209
+ "env": {
210
+ "type": "string",
211
+ "minLength": 1
212
+ }
213
+ }
214
+ }
215
+ ]
216
+ },
217
+ "installationId": {
218
+ "type": "integer",
219
+ "exclusiveMinimum": 0,
220
+ "maximum": 9007199254740991
221
+ }
222
+ },
223
+ "required": [
224
+ "type",
225
+ "appId",
226
+ "privateKey"
227
+ ]
228
+ }
229
+ ]
230
+ },
231
+ "webhookSecret": {
232
+ "anyOf": [
233
+ {
234
+ "type": "string",
235
+ "minLength": 1
236
+ },
237
+ {
238
+ "type": "object",
239
+ "properties": {
240
+ "value": {
241
+ "type": "string",
242
+ "minLength": 1
243
+ },
244
+ "env": {
245
+ "type": "string",
246
+ "minLength": 1
247
+ }
248
+ }
249
+ }
250
+ ]
251
+ }
252
+ },
253
+ "required": [
254
+ "auth",
255
+ "webhookSecret"
256
+ ]
257
+ },
145
258
  "telegram-bot": {
146
259
  "type": "object",
147
260
  "properties": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typeclaw",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "homepage": "https://github.com/typeclaw/typeclaw#readme",
5
5
  "bugs": {
6
6
  "url": "https://github.com/typeclaw/typeclaw/issues"
@@ -142,6 +142,119 @@
142
142
  }
143
143
  }
144
144
  },
145
+ "github": {
146
+ "type": "object",
147
+ "properties": {
148
+ "auth": {
149
+ "oneOf": [
150
+ {
151
+ "type": "object",
152
+ "properties": {
153
+ "type": {
154
+ "type": "string",
155
+ "const": "pat"
156
+ },
157
+ "token": {
158
+ "anyOf": [
159
+ {
160
+ "type": "string",
161
+ "minLength": 1
162
+ },
163
+ {
164
+ "type": "object",
165
+ "properties": {
166
+ "value": {
167
+ "type": "string",
168
+ "minLength": 1
169
+ },
170
+ "env": {
171
+ "type": "string",
172
+ "minLength": 1
173
+ }
174
+ }
175
+ }
176
+ ]
177
+ }
178
+ },
179
+ "required": [
180
+ "type",
181
+ "token"
182
+ ]
183
+ },
184
+ {
185
+ "type": "object",
186
+ "properties": {
187
+ "type": {
188
+ "type": "string",
189
+ "const": "app"
190
+ },
191
+ "appId": {
192
+ "type": "integer",
193
+ "exclusiveMinimum": 0,
194
+ "maximum": 9007199254740991
195
+ },
196
+ "privateKey": {
197
+ "anyOf": [
198
+ {
199
+ "type": "string",
200
+ "minLength": 1
201
+ },
202
+ {
203
+ "type": "object",
204
+ "properties": {
205
+ "value": {
206
+ "type": "string",
207
+ "minLength": 1
208
+ },
209
+ "env": {
210
+ "type": "string",
211
+ "minLength": 1
212
+ }
213
+ }
214
+ }
215
+ ]
216
+ },
217
+ "installationId": {
218
+ "type": "integer",
219
+ "exclusiveMinimum": 0,
220
+ "maximum": 9007199254740991
221
+ }
222
+ },
223
+ "required": [
224
+ "type",
225
+ "appId",
226
+ "privateKey"
227
+ ]
228
+ }
229
+ ]
230
+ },
231
+ "webhookSecret": {
232
+ "anyOf": [
233
+ {
234
+ "type": "string",
235
+ "minLength": 1
236
+ },
237
+ {
238
+ "type": "object",
239
+ "properties": {
240
+ "value": {
241
+ "type": "string",
242
+ "minLength": 1
243
+ },
244
+ "env": {
245
+ "type": "string",
246
+ "minLength": 1
247
+ }
248
+ }
249
+ }
250
+ ]
251
+ }
252
+ },
253
+ "required": [
254
+ "auth",
255
+ "webhookSecret"
256
+ ]
257
+ },
145
258
  "telegram-bot": {
146
259
  "type": "object",
147
260
  "properties": {
@@ -8,7 +8,7 @@ export type SessionMetaPayload = {
8
8
 
9
9
  export type MinimalSessionOrigin =
10
10
  | { kind: 'tui' }
11
- | { kind: 'cron'; jobId: string; jobKind: 'prompt' | 'exec' | 'subagent' }
11
+ | { kind: 'cron'; jobId: string; jobKind: 'prompt' | 'exec' | 'subagent' | 'handler' }
12
12
  | { kind: 'channel'; adapter: string; workspace: string; chat: string; thread: string | null }
13
13
  | { kind: 'subagent'; subagent: string; parentSessionId: string }
14
14
 
@@ -25,7 +25,7 @@ export type SessionOrigin =
25
25
  | {
26
26
  kind: 'cron'
27
27
  jobId: string
28
- jobKind: 'prompt' | 'exec' | 'subagent'
28
+ jobKind: 'prompt' | 'exec' | 'subagent' | 'handler'
29
29
  scheduledByRole?: string
30
30
  scheduledByOrigin?: SessionOrigin | { kind: 'config-file' }
31
31
  }
@@ -78,6 +78,7 @@ type PlatformInfo = {
78
78
  const PLATFORM_INFO: Record<AdapterId, PlatformInfo> = {
79
79
  'slack-bot': { displayName: 'Slack', mentionMode: 'angle-id' },
80
80
  'discord-bot': { displayName: 'Discord', mentionMode: 'angle-id' },
81
+ github: { displayName: 'GitHub', mentionMode: 'at-username' },
81
82
  'telegram-bot': { displayName: 'Telegram', mentionMode: 'at-username' },
82
83
  kakaotalk: { displayName: 'KakaoTalk', mentionMode: 'alias' },
83
84
  }
@@ -150,7 +151,7 @@ function renderTuiOrigin(): string {
150
151
  ].join('\n')
151
152
  }
152
153
 
153
- function renderCronOrigin(origin: { jobId: string; jobKind: 'prompt' | 'exec' | 'subagent' }): string {
154
+ function renderCronOrigin(origin: { jobId: string; jobKind: 'prompt' | 'exec' | 'subagent' | 'handler' }): string {
154
155
  return [
155
156
  '## Session origin',
156
157
  '',
@@ -25,8 +25,9 @@ export { SECURITY_PERMISSIONS, type SecurityPermission } from './permissions'
25
25
  // it's the only carrier.
26
26
  const BYPASS_ROLE_HINT = {
27
27
  [SECURITY_PERMISSIONS.bypassSecretExfilBash]: 'owner and trusted have it by default',
28
- [SECURITY_PERMISSIONS.bypassGitExfil]: 'only owner has it by default',
29
- [SECURITY_PERMISSIONS.bypassGitRemoteTainted]: 'only owner has it by default',
28
+ [SECURITY_PERMISSIONS.bypassGitExfil]: 'owner and trusted have it by default',
29
+ [SECURITY_PERMISSIONS.bypassGitRemoteTainted]:
30
+ 'only owner has it by default (trusted intentionally does not, so the two-step taint defense still fires)',
30
31
  [SECURITY_PERMISSIONS.bypassSecretExfilRead]: 'only owner has it by default',
31
32
  [SECURITY_PERMISSIONS.bypassSsrf]: 'only owner has it by default',
32
33
  [SECURITY_PERMISSIONS.bypassSessionSearchSecrets]: 'only owner has it by default',
@@ -0,0 +1,120 @@
1
+ import { resolveSecret, type Secret } from '@/secrets/resolve'
2
+
3
+ import type { GithubAuthStrategy, GithubSelfUser } from './auth'
4
+ import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
5
+
6
+ export class AppAuthStrategy implements GithubAuthStrategy {
7
+ private readonly appId: number
8
+ private readonly privateKeyPem: string
9
+ private readonly installationId: number | null
10
+ private readonly fetchImpl: typeof fetch
11
+ private cachedToken: { value: string; expiresAt: number } | null = null
12
+ private resolvedInstallationId: number | null = null
13
+ private _selfUser: GithubSelfUser | null = null
14
+
15
+ constructor(options: { appId: number; privateKey: Secret; installationId?: number; fetchImpl?: typeof fetch }) {
16
+ const privateKeyPem = resolveSecret(options.privateKey, undefined, process.env)
17
+ if (privateKeyPem === undefined || privateKeyPem.trim() === '') throw new Error('GitHub App private key is missing')
18
+ this.appId = options.appId
19
+ this.privateKeyPem = privateKeyPem
20
+ this.installationId = options.installationId ?? null
21
+ this.fetchImpl = options.fetchImpl ?? fetch
22
+ }
23
+
24
+ async token(): Promise<string> {
25
+ if (this.cachedToken && Date.now() < this.cachedToken.expiresAt - 5 * 60 * 1000) {
26
+ return this.cachedToken.value
27
+ }
28
+ const jwt = await this.mintJwt()
29
+ const installId = await this.resolveInstallationId(jwt)
30
+ const response = await this.fetchImpl(`${GITHUB_API_BASE}/app/installations/${installId}/access_tokens`, {
31
+ method: 'POST',
32
+ headers: githubJsonHeaders(jwt),
33
+ })
34
+ if (!response.ok) throw new Error(`GitHub App token mint failed: ${response.status}`)
35
+ const raw = (await response.json()) as { token?: unknown; expires_at?: unknown }
36
+ if (typeof raw.token !== 'string') throw new Error('GitHub App token response missing token')
37
+ const expiresAt = typeof raw.expires_at === 'string' ? Date.parse(raw.expires_at) : Date.now() + 60 * 60 * 1000
38
+ this.cachedToken = { value: raw.token, expiresAt }
39
+ return raw.token
40
+ }
41
+
42
+ async authHeaders(): Promise<HeadersInit> {
43
+ return githubJsonHeaders(await this.token())
44
+ }
45
+
46
+ async getSelf(): Promise<GithubSelfUser> {
47
+ if (this._selfUser) return this._selfUser
48
+ const jwt = await this.mintJwt()
49
+ const appResponse = await this.fetchImpl(`${GITHUB_API_BASE}/app`, { headers: githubJsonHeaders(jwt) })
50
+ if (!appResponse.ok) throw new Error(`GitHub App preflight failed: ${appResponse.status}`)
51
+ const app = (await appResponse.json()) as { slug?: unknown }
52
+ if (typeof app.slug !== 'string') throw new Error('GitHub /app response missing slug')
53
+
54
+ const botLogin = `${app.slug}[bot]`
55
+ const userResponse = await this.fetchImpl(`${GITHUB_API_BASE}/users/${encodeURIComponent(botLogin)}`, {
56
+ headers: githubJsonHeaders(jwt),
57
+ })
58
+ if (!userResponse.ok) throw new Error(`GitHub bot user lookup failed: ${userResponse.status}`)
59
+ const user = (await userResponse.json()) as { id?: unknown; login?: unknown }
60
+ if (typeof user.id !== 'number' || typeof user.login !== 'string') {
61
+ throw new Error('GitHub bot user response missing id/login')
62
+ }
63
+ this._selfUser = { id: user.id, login: user.login }
64
+ return this._selfUser
65
+ }
66
+
67
+ async dispose(): Promise<void> {
68
+ this.cachedToken = null
69
+ }
70
+
71
+ private async mintJwt(): Promise<string> {
72
+ const now = Math.floor(Date.now() / 1000)
73
+ const iat = now - 60
74
+ const exp = iat + 600
75
+ const header = base64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))
76
+ const payload = base64url(JSON.stringify({ iat, exp, iss: this.appId }))
77
+ const signingInput = `${header}.${payload}`
78
+ const key = await importRsaPrivateKey(this.privateKeyPem)
79
+ const signature = await crypto.subtle.sign(
80
+ { name: 'RSASSA-PKCS1-v1_5' },
81
+ key,
82
+ new TextEncoder().encode(signingInput),
83
+ )
84
+ return `${signingInput}.${base64url(Buffer.from(signature))}`
85
+ }
86
+
87
+ private async resolveInstallationId(jwt: string): Promise<number> {
88
+ if (this.resolvedInstallationId !== null) return this.resolvedInstallationId
89
+ if (this.installationId !== null) {
90
+ this.resolvedInstallationId = this.installationId
91
+ return this.installationId
92
+ }
93
+ const response = await this.fetchImpl(`${GITHUB_API_BASE}/app/installations`, { headers: githubJsonHeaders(jwt) })
94
+ if (!response.ok) throw new Error(`GitHub App installations fetch failed: ${response.status}`)
95
+ const list = (await response.json()) as Array<{ id?: unknown }>
96
+ if (list.length === 0) throw new Error('GitHub App has no installations')
97
+ if (list.length > 1) {
98
+ const ids = list.map((installation) => installation.id).join(', ')
99
+ throw new Error(`GitHub App has multiple installations (${ids}); set installationId in secrets.json`)
100
+ }
101
+ const id = list[0]?.id
102
+ if (typeof id !== 'number') throw new Error('GitHub App installation missing id')
103
+ this.resolvedInstallationId = id
104
+ return id
105
+ }
106
+ }
107
+
108
+ function base64url(input: string | Buffer): string {
109
+ const buf = typeof input === 'string' ? Buffer.from(input) : input
110
+ return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '')
111
+ }
112
+
113
+ async function importRsaPrivateKey(pem: string): Promise<CryptoKey> {
114
+ const b64 = pem
115
+ .replace(/-----BEGIN [^-]+-----/, '')
116
+ .replace(/-----END [^-]+-----/, '')
117
+ .replace(/\s/g, '')
118
+ const der = Buffer.from(b64, 'base64')
119
+ return await crypto.subtle.importKey('pkcs8', der, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['sign'])
120
+ }
@@ -0,0 +1,50 @@
1
+ import { resolveSecret, type Secret } from '@/secrets/resolve'
2
+
3
+ import type { GithubAuthStrategy, GithubSelfUser } from './auth'
4
+
5
+ export const GITHUB_API_BASE = 'https://api.github.com'
6
+
7
+ export class PatAuthStrategy implements GithubAuthStrategy {
8
+ private readonly _token: string
9
+ private readonly fetchImpl: typeof fetch
10
+
11
+ constructor(options: { token: Secret; fetchImpl?: typeof fetch }) {
12
+ const token = resolveSecret(options.token, undefined, process.env)
13
+ if (token === undefined || token.trim() === '') throw new Error('GitHub PAT token is missing')
14
+ this._token = token
15
+ this.fetchImpl = options.fetchImpl ?? fetch
16
+ }
17
+
18
+ async token(): Promise<string> {
19
+ return this._token
20
+ }
21
+
22
+ async authHeaders(): Promise<HeadersInit> {
23
+ return githubJsonHeaders(this._token)
24
+ }
25
+
26
+ async getSelf(): Promise<GithubSelfUser> {
27
+ const response = await this.fetchImpl(`${GITHUB_API_BASE}/user`, { headers: await this.authHeaders() })
28
+ if (!response.ok) {
29
+ const body = await response.text().catch(() => '')
30
+ throw new Error(`GitHub PAT authentication failed: ${response.status}${body !== '' ? ` ${body}` : ''}`)
31
+ }
32
+ const raw = (await response.json()) as { login?: unknown; id?: unknown }
33
+ if (typeof raw.login !== 'string' || typeof raw.id !== 'number') {
34
+ throw new Error('GitHub /user response did not include login/id')
35
+ }
36
+ return { login: raw.login, id: raw.id }
37
+ }
38
+
39
+ async dispose(): Promise<void> {}
40
+ }
41
+
42
+ export function githubJsonHeaders(token: string): HeadersInit {
43
+ return {
44
+ Authorization: `Bearer ${token}`,
45
+ Accept: 'application/vnd.github+json',
46
+ 'Content-Type': 'application/json',
47
+ 'X-GitHub-Api-Version': '2022-11-28',
48
+ 'User-Agent': 'typeclaw-github-channel',
49
+ }
50
+ }
@@ -0,0 +1,33 @@
1
+ import type { GithubAppAuthBlock, GithubPatAuthBlock } from '@/secrets/schema'
2
+
3
+ import { AppAuthStrategy } from './auth-app'
4
+ import { PatAuthStrategy } from './auth-pat'
5
+
6
+ export type GithubAuthStrategy = {
7
+ token: () => Promise<string>
8
+ authHeaders: () => Promise<HeadersInit>
9
+ getSelf: () => Promise<GithubSelfUser>
10
+ dispose: () => Promise<void>
11
+ }
12
+
13
+ export type GithubSelfUser = {
14
+ login: string
15
+ id: number
16
+ }
17
+
18
+ export function buildAuthStrategy(options: {
19
+ auth: GithubPatAuthBlock | GithubAppAuthBlock
20
+ fetchImpl?: typeof fetch
21
+ }): GithubAuthStrategy {
22
+ switch (options.auth.type) {
23
+ case 'pat':
24
+ return new PatAuthStrategy({ token: options.auth.token, fetchImpl: options.fetchImpl })
25
+ case 'app':
26
+ return new AppAuthStrategy({
27
+ appId: options.auth.appId,
28
+ privateKey: options.auth.privateKey,
29
+ installationId: options.auth.installationId,
30
+ fetchImpl: options.fetchImpl,
31
+ })
32
+ }
33
+ }
@@ -0,0 +1,30 @@
1
+ import type { ChannelNameResolver, ResolvedChannelNames } from '@/channels/types'
2
+
3
+ import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
4
+ import { parseChat, parseRepo } from './outbound'
5
+
6
+ export function createGithubChannelNameResolver(options: {
7
+ token: () => Promise<string>
8
+ fetchImpl?: typeof fetch
9
+ }): ChannelNameResolver {
10
+ const fetchImpl = options.fetchImpl ?? fetch
11
+ return async (key): Promise<ResolvedChannelNames> => {
12
+ if (key.adapter !== 'github') return {}
13
+ const repo = parseRepo(key.workspace)
14
+ const chat = parseChat(key.chat)
15
+ if (repo === null || chat === null) return {}
16
+ const names: ResolvedChannelNames = { workspaceName: key.workspace }
17
+ if (chat.kind === 'discussion') return names
18
+ const path = chat.kind === 'issue' ? `issues/${chat.number}` : `pulls/${chat.number}`
19
+ try {
20
+ const response = await fetchImpl(`${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/${path}`, {
21
+ headers: githubJsonHeaders(await options.token()),
22
+ })
23
+ if (!response.ok) return names
24
+ const raw = (await response.json()) as { title?: string }
25
+ return raw.title !== undefined ? { ...names, chatName: raw.title } : names
26
+ } catch {
27
+ return names
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,26 @@
1
+ export type DeliveryDedup = {
2
+ has: (deliveryId: string) => boolean
3
+ add: (deliveryId: string) => void
4
+ size: () => number
5
+ }
6
+
7
+ export function createDeliveryDedup(limit = 1000): DeliveryDedup {
8
+ const seen = new Map<string, true>()
9
+ return {
10
+ has(deliveryId: string): boolean {
11
+ return seen.has(deliveryId)
12
+ },
13
+ add(deliveryId: string): void {
14
+ if (seen.has(deliveryId)) seen.delete(deliveryId)
15
+ seen.set(deliveryId, true)
16
+ while (seen.size > limit) {
17
+ const oldest = seen.keys().next().value
18
+ if (oldest === undefined) break
19
+ seen.delete(oldest)
20
+ }
21
+ },
22
+ size(): number {
23
+ return seen.size
24
+ },
25
+ }
26
+ }
@@ -0,0 +1,8 @@
1
+ export function githubEventKey(event: string, action: unknown): string {
2
+ return typeof action === 'string' && action.length > 0 ? `${event}.${action}` : event
3
+ }
4
+
5
+ export function isGithubEventAllowed(allowlist: readonly string[], event: string, action: unknown): boolean {
6
+ const key = githubEventKey(event, action)
7
+ return allowlist.includes(key) || allowlist.includes(event)
8
+ }
@@ -0,0 +1,5 @@
1
+ import type { FetchAttachmentCallback } from '@/channels/types'
2
+
3
+ export function createGithubFetchAttachmentCallback(): FetchAttachmentCallback {
4
+ return async () => ({ ok: false, error: 'github-bot-does-not-support-attachments' })
5
+ }