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.
- package/README.md +20 -15
- package/auth.schema.json +113 -0
- package/package.json +1 -1
- package/secrets.schema.json +113 -0
- package/src/agent/session-meta.ts +1 -1
- package/src/agent/session-origin.ts +3 -2
- package/src/bundled-plugins/security/index.ts +3 -2
- package/src/channels/adapters/github/auth-app.ts +120 -0
- package/src/channels/adapters/github/auth-pat.ts +50 -0
- package/src/channels/adapters/github/auth.ts +33 -0
- package/src/channels/adapters/github/channel-resolver.ts +30 -0
- package/src/channels/adapters/github/dedup.ts +26 -0
- package/src/channels/adapters/github/event-allowlist.ts +8 -0
- package/src/channels/adapters/github/fetch-attachment.ts +5 -0
- package/src/channels/adapters/github/history.ts +63 -0
- package/src/channels/adapters/github/inbound.ts +286 -0
- package/src/channels/adapters/github/index.ts +286 -0
- package/src/channels/adapters/github/managed-path.ts +54 -0
- package/src/channels/adapters/github/membership.ts +35 -0
- package/src/channels/adapters/github/outbound.ts +145 -0
- package/src/channels/adapters/github/webhook-register.ts +349 -0
- package/src/channels/manager.ts +94 -9
- package/src/channels/schema.ts +31 -1
- package/src/channels/tunnel-bridge.ts +51 -0
- package/src/cli/builtins.ts +28 -0
- package/src/cli/channel.ts +511 -25
- package/src/cli/container-command-client.ts +244 -0
- package/src/cli/cron.ts +173 -0
- package/src/cli/host-command-runner.ts +150 -0
- package/src/cli/index.ts +42 -1
- package/src/cli/init.ts +256 -27
- package/src/cli/model.ts +4 -2
- package/src/cli/plugin-command-help.ts +49 -0
- package/src/cli/plugin-commands-dispatch.ts +112 -0
- package/src/cli/plugin-commands.ts +118 -0
- package/src/cli/tui.ts +10 -2
- package/src/cli/tunnel.ts +533 -0
- package/src/cli/ui.ts +8 -3
- package/src/config/config.ts +75 -0
- package/src/container/start.ts +30 -3
- package/src/cron/bridge.ts +136 -0
- package/src/cron/consumer.ts +45 -5
- package/src/cron/index.ts +19 -2
- package/src/cron/list.ts +105 -0
- package/src/cron/scheduler.ts +12 -3
- package/src/cron/schema.ts +11 -3
- package/src/doctor/checks.ts +0 -50
- package/src/init/dockerfile.ts +59 -13
- package/src/init/ensure-deps.ts +15 -4
- package/src/init/github-webhook-install.ts +109 -0
- package/src/init/index.ts +505 -9
- package/src/init/run-bun-install.ts +17 -3
- package/src/init/run-owner-claim.ts +11 -2
- package/src/permissions/builtins.ts +6 -1
- package/src/permissions/match-rule.ts +24 -2
- package/src/permissions/resolve.ts +1 -0
- package/src/plugin/define.ts +42 -1
- package/src/plugin/index.ts +18 -3
- package/src/plugin/manager.ts +2 -0
- package/src/plugin/registry.ts +85 -3
- package/src/plugin/types.ts +138 -1
- package/src/plugin/zod-introspect.ts +100 -0
- package/src/role-claim/match-rule.ts +2 -1
- package/src/run/index.ts +110 -3
- package/src/secrets/index.ts +1 -1
- package/src/secrets/schema.ts +21 -0
- package/src/server/command-runner.ts +476 -0
- package/src/server/index.ts +388 -5
- package/src/shared/index.ts +8 -0
- package/src/shared/protocol.ts +80 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
- package/src/skills/typeclaw-config/SKILL.md +27 -26
- package/src/skills/typeclaw-cron/SKILL.md +234 -3
- package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
- package/src/skills/typeclaw-permissions/SKILL.md +5 -4
- package/src/skills/typeclaw-plugins/SKILL.md +251 -5
- package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
- package/src/test-helpers/wait-for.ts +50 -0
- package/src/tui/index.ts +35 -4
- package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
- package/src/tunnels/events.ts +14 -0
- package/src/tunnels/index.ts +12 -0
- package/src/tunnels/log-ring.ts +54 -0
- package/src/tunnels/manager.ts +139 -0
- package/src/tunnels/providers/cloudflare-quick.ts +189 -0
- package/src/tunnels/providers/external.ts +53 -0
- package/src/tunnels/quick-url-parser.ts +5 -0
- package/src/tunnels/types.ts +43 -0
- 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
|
|
84
|
-
| `typeclaw channel
|
|
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
|
-
- `
|
|
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
package/secrets.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": {
|
|
@@ -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]: '
|
|
29
|
-
[SECURITY_PERMISSIONS.bypassGitRemoteTainted]:
|
|
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
|
+
}
|