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.
- 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/auth.ts +4 -2
- package/src/agent/index.ts +16 -28
- package/src/agent/model-fallback.ts +127 -0
- package/src/agent/session-meta.ts +1 -1
- package/src/agent/session-origin.ts +3 -2
- package/src/agent/tools/curl-impersonate.ts +300 -0
- package/src/agent/tools/ddg.ts +13 -88
- package/src/agent/tools/webfetch/fetch.ts +105 -2
- package/src/agent/tools/webfetch/tool.ts +4 -0
- package/src/bundled-plugins/agent-browser/shim.ts +47 -0
- package/src/bundled-plugins/backup/subagents.ts +2 -0
- package/src/bundled-plugins/memory/README.md +49 -12
- package/src/bundled-plugins/memory/citation-superset.ts +63 -0
- package/src/bundled-plugins/memory/dreaming.ts +105 -17
- package/src/bundled-plugins/memory/index.ts +2 -2
- package/src/bundled-plugins/memory/memory-logger.ts +45 -26
- package/src/bundled-plugins/memory/strength.ts +127 -0
- package/src/bundled-plugins/memory/topics.ts +75 -0
- package/src/bundled-plugins/security/index.ts +88 -43
- package/src/bundled-plugins/security/permissions.ts +36 -0
- package/src/bundled-plugins/security/policies/git-exfil.ts +20 -0
- package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +12 -0
- package/src/bundled-plugins/security/policies/prompt-injection.ts +23 -3
- package/src/bundled-plugins/security/policies/secret-exfil-bash.ts +7 -0
- package/src/bundled-plugins/security/policies/secret-exfil-read.ts +6 -0
- package/src/bundled-plugins/security/policies/session-search-secrets.ts +9 -0
- package/src/bundled-plugins/security/policies/ssrf.ts +6 -0
- package/src/bundled-plugins/security/policies/system-prompt-leak.ts +7 -0
- 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 +370 -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/router.ts +194 -28
- package/src/channels/schema.ts +31 -1
- package/src/channels/tunnel-bridge.ts +51 -0
- package/src/channels/types.ts +3 -1
- 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 +400 -67
- package/src/cli/model.ts +14 -4
- package/src/cli/oauth-callbacks.ts +49 -0
- 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/provider.ts +3 -20
- 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 +134 -24
- package/src/config/models-mutation.ts +42 -8
- package/src/config/providers-mutation.ts +12 -8
- package/src/container/start.ts +48 -4
- package/src/cron/bridge.ts +136 -0
- package/src/cron/consumer.ts +174 -48
- 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 +165 -13
- package/src/init/ensure-deps.ts +15 -4
- package/src/init/github-webhook-install.ts +109 -0
- package/src/init/hatching.ts +2 -2
- package/src/init/index.ts +519 -12
- package/src/init/oauth-login.ts +17 -3
- package/src/init/run-bun-install.ts +17 -3
- package/src/init/run-owner-claim.ts +11 -2
- package/src/permissions/builtins.ts +29 -2
- package/src/permissions/match-rule.ts +24 -2
- package/src/permissions/permissions.ts +24 -7
- package/src/permissions/resolve.ts +1 -0
- package/src/plugin/define.ts +44 -1
- package/src/plugin/index.ts +18 -3
- package/src/plugin/manager.ts +16 -0
- package/src/plugin/registry.ts +85 -3
- package/src/plugin/types.ts +144 -1
- package/src/plugin/zod-introspect.ts +100 -0
- package/src/role-claim/match-rule.ts +2 -1
- package/src/run/index.ts +112 -4
- 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-memory/SKILL.md +25 -15
- package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
- package/src/skills/typeclaw-permissions/SKILL.md +35 -16
- 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 +70 -7
- 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/src/usage/report.ts +15 -12
- package/typeclaw.schema.json +311 -26
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { GITHUB_API_BASE, githubJsonHeaders } from './auth-pat'
|
|
2
|
+
|
|
3
|
+
export type RegisterGithubWebhooksOptions = {
|
|
4
|
+
token: () => Promise<string>
|
|
5
|
+
webhookUrl: string
|
|
6
|
+
webhookSecret: string
|
|
7
|
+
repos: readonly string[]
|
|
8
|
+
events: readonly string[]
|
|
9
|
+
// Stable, hostname-agnostic marker embedded in the webhook URL's path so
|
|
10
|
+
// hooks created by this agent in past runs can be recognized as ours even
|
|
11
|
+
// after the host part of the URL has rotated (e.g. cloudflare-quick tunnels
|
|
12
|
+
// mint a fresh `*.trycloudflare.com` on every container restart).
|
|
13
|
+
//
|
|
14
|
+
// When set, any hook whose `config.url` URL.pathname ends with this exact
|
|
15
|
+
// string is considered owned by this agent: at register time we PATCH the
|
|
16
|
+
// first such hook to the current URL and delete the rest as stale orphans.
|
|
17
|
+
//
|
|
18
|
+
// Convention: `/typeclaw/v1/github/<containerName>` — see
|
|
19
|
+
// `buildManagedPath` in `./managed-path.ts`. The path is appended onto
|
|
20
|
+
// tunnel-derived URLs by the adapter; user-set `webhookUrl` is kept
|
|
21
|
+
// verbatim (the operator is in control of their own URL — we trust them
|
|
22
|
+
// not to point two agents at the same URL).
|
|
23
|
+
//
|
|
24
|
+
// Omitted means the legacy URL-equality path is used (no orphan cleanup).
|
|
25
|
+
// The adapter always passes it in production; the option stays optional so
|
|
26
|
+
// direct unit-test calls can opt out of the cleanup logic.
|
|
27
|
+
managedPath?: string
|
|
28
|
+
// Opt-in legacy-orphan cleanup for hooks created before the marker existed.
|
|
29
|
+
// When set (e.g. `.trycloudflare.com`), the lister ALSO claims any hook
|
|
30
|
+
// whose URL host endsWith this suffix AND whose pathname is empty or `/`
|
|
31
|
+
// (unmarked = necessarily pre-fix). The adapter passes this only when the
|
|
32
|
+
// CURRENT effective URL itself lives on the same provider domain, so an
|
|
33
|
+
// agent on an external/self-hosted tunnel can never claim a colleague's
|
|
34
|
+
// cloudflare-quick hook. Hooks with a non-trivial path are still skipped
|
|
35
|
+
// unconditionally so a foreign service that happens to also use
|
|
36
|
+
// *.trycloudflare.com with its own path stays safe.
|
|
37
|
+
legacyProviderHostSuffix?: string
|
|
38
|
+
fetchImpl?: typeof fetch
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type WebhookRepoResult =
|
|
42
|
+
| { repo: string; action: 'created'; hookId: number }
|
|
43
|
+
| { repo: string; action: 'updated'; hookId: number; stalePruned: number }
|
|
44
|
+
| { repo: string; action: 'failed'; error: string }
|
|
45
|
+
|
|
46
|
+
export type WebhookRegistrationResult = {
|
|
47
|
+
repos: WebhookRepoResult[]
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function registerGithubWebhooks(
|
|
51
|
+
options: RegisterGithubWebhooksOptions,
|
|
52
|
+
): Promise<WebhookRegistrationResult> {
|
|
53
|
+
const fetchImpl = options.fetchImpl ?? fetch
|
|
54
|
+
let token: string
|
|
55
|
+
try {
|
|
56
|
+
token = await options.token()
|
|
57
|
+
} catch (err) {
|
|
58
|
+
const error = describe(err)
|
|
59
|
+
return { repos: options.repos.map((repo) => ({ repo, action: 'failed' as const, error })) }
|
|
60
|
+
}
|
|
61
|
+
const repos: WebhookRepoResult[] = []
|
|
62
|
+
for (const repo of options.repos) {
|
|
63
|
+
repos.push(await registerOne(fetchImpl, token, repo, options))
|
|
64
|
+
}
|
|
65
|
+
return { repos }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type DeregisterGithubWebhooksOptions = {
|
|
69
|
+
token: () => Promise<string>
|
|
70
|
+
hooks: ReadonlyArray<{ repo: string; hookId: number }>
|
|
71
|
+
fetchImpl?: typeof fetch
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type WebhookDeregistrationResult = {
|
|
75
|
+
hooks: Array<{ repo: string; hookId: number; action: 'deleted' | 'missing' | 'failed'; error?: string }>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function deregisterGithubWebhooks(
|
|
79
|
+
options: DeregisterGithubWebhooksOptions,
|
|
80
|
+
): Promise<WebhookDeregistrationResult> {
|
|
81
|
+
const fetchImpl = options.fetchImpl ?? fetch
|
|
82
|
+
let token: string
|
|
83
|
+
try {
|
|
84
|
+
token = await options.token()
|
|
85
|
+
} catch (err) {
|
|
86
|
+
const error = describe(err)
|
|
87
|
+
return { hooks: options.hooks.map((h) => ({ ...h, action: 'failed', error })) }
|
|
88
|
+
}
|
|
89
|
+
const hooks: WebhookDeregistrationResult['hooks'] = []
|
|
90
|
+
for (const hook of options.hooks) {
|
|
91
|
+
hooks.push(await deleteOne(fetchImpl, token, hook))
|
|
92
|
+
}
|
|
93
|
+
return { hooks }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function registerOne(
|
|
97
|
+
fetchImpl: typeof fetch,
|
|
98
|
+
token: string,
|
|
99
|
+
repo: string,
|
|
100
|
+
options: RegisterGithubWebhooksOptions,
|
|
101
|
+
): Promise<WebhookRepoResult> {
|
|
102
|
+
const parsed = parseRepoSlug(repo)
|
|
103
|
+
if (parsed === null) {
|
|
104
|
+
return { repo, action: 'failed', error: `invalid repo slug: "${repo}" (expected owner/name)` }
|
|
105
|
+
}
|
|
106
|
+
try {
|
|
107
|
+
const owned = await findManagedHooks(
|
|
108
|
+
fetchImpl,
|
|
109
|
+
token,
|
|
110
|
+
parsed,
|
|
111
|
+
options.webhookUrl,
|
|
112
|
+
options.managedPath,
|
|
113
|
+
options.legacyProviderHostSuffix,
|
|
114
|
+
)
|
|
115
|
+
if (owned.length === 0) {
|
|
116
|
+
const hookId = await createHook(fetchImpl, token, parsed, options)
|
|
117
|
+
return { repo, action: 'created', hookId }
|
|
118
|
+
}
|
|
119
|
+
// Sort by id ascending so the canonical kept hook is deterministic
|
|
120
|
+
// (oldest = lowest id wins). This makes successive runs converge on the
|
|
121
|
+
// same hookId for the same repo, which is friendlier to anyone
|
|
122
|
+
// inspecting the repo's webhook list.
|
|
123
|
+
const [keep, ...stale] = owned.slice().sort((a, b) => a - b)
|
|
124
|
+
await updateHook(fetchImpl, token, parsed, keep!, options)
|
|
125
|
+
let stalePruned = 0
|
|
126
|
+
for (const id of stale) {
|
|
127
|
+
const ok = await tryDeleteHook(fetchImpl, token, parsed, id)
|
|
128
|
+
if (ok) stalePruned++
|
|
129
|
+
}
|
|
130
|
+
return { repo, action: 'updated', hookId: keep!, stalePruned }
|
|
131
|
+
} catch (err) {
|
|
132
|
+
return { repo, action: 'failed', error: describe(err) }
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function deleteOne(
|
|
137
|
+
fetchImpl: typeof fetch,
|
|
138
|
+
token: string,
|
|
139
|
+
hook: { repo: string; hookId: number },
|
|
140
|
+
): Promise<WebhookDeregistrationResult['hooks'][number]> {
|
|
141
|
+
const parsed = parseRepoSlug(hook.repo)
|
|
142
|
+
if (parsed === null) {
|
|
143
|
+
return { ...hook, action: 'failed', error: `invalid repo slug: "${hook.repo}"` }
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
const response = await fetchImpl(`${GITHUB_API_BASE}/repos/${parsed.owner}/${parsed.name}/hooks/${hook.hookId}`, {
|
|
147
|
+
method: 'DELETE',
|
|
148
|
+
headers: githubJsonHeaders(token),
|
|
149
|
+
})
|
|
150
|
+
if (response.status === 404) return { ...hook, action: 'missing' }
|
|
151
|
+
if (!response.ok) {
|
|
152
|
+
const body = await response.text().catch(() => '')
|
|
153
|
+
return {
|
|
154
|
+
...hook,
|
|
155
|
+
action: 'failed',
|
|
156
|
+
error: `delete hook failed: ${response.status}${body !== '' ? ` ${body}` : ''}`,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return { ...hook, action: 'deleted' }
|
|
160
|
+
} catch (err) {
|
|
161
|
+
return { ...hook, action: 'failed', error: describe(err) }
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Best-effort stale-hook prune. We don't surface 404/403/etc. as a register
|
|
166
|
+
// failure because the primary keep-hook is already updated; an inability to
|
|
167
|
+
// delete a stale orphan is a soft warning at most. Caller counts successful
|
|
168
|
+
// prunes for the log line.
|
|
169
|
+
async function tryDeleteHook(fetchImpl: typeof fetch, token: string, repo: RepoSlug, hookId: number): Promise<boolean> {
|
|
170
|
+
try {
|
|
171
|
+
const response = await fetchImpl(`${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/hooks/${hookId}`, {
|
|
172
|
+
method: 'DELETE',
|
|
173
|
+
headers: githubJsonHeaders(token),
|
|
174
|
+
})
|
|
175
|
+
// 404 = already gone; treat as a successful prune for log-summary purposes
|
|
176
|
+
// (the orphan is no longer on the repo, which is what we wanted).
|
|
177
|
+
return response.ok || response.status === 404
|
|
178
|
+
} catch {
|
|
179
|
+
return false
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
type RepoSlug = { owner: string; name: string }
|
|
184
|
+
|
|
185
|
+
function parseRepoSlug(slug: string): RepoSlug | null {
|
|
186
|
+
const parts = slug.split('/')
|
|
187
|
+
if (parts.length !== 2) return null
|
|
188
|
+
const [owner, name] = parts
|
|
189
|
+
if (!owner || !name) return null
|
|
190
|
+
if (!REPO_SEGMENT.test(owner) || !REPO_SEGMENT.test(name)) return null
|
|
191
|
+
return { owner, name }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const REPO_SEGMENT = /^[A-Za-z0-9._-]+$/
|
|
195
|
+
|
|
196
|
+
// Returns the hookIds of every hook owned by this agent on `repo`, in the
|
|
197
|
+
// order GitHub returned them. Ownership is the union of three rules:
|
|
198
|
+
//
|
|
199
|
+
// 1. `config.url === webhookUrl` — the live URL match. Covers the
|
|
200
|
+
// common case (user-set webhookUrl, or a tunnel URL that hasn't
|
|
201
|
+
// rotated since the last register).
|
|
202
|
+
//
|
|
203
|
+
// 2. `URL(config.url).pathname` ends with `managedPath` — the
|
|
204
|
+
// hostname-agnostic path-marker match. Covers hooks that THIS agent
|
|
205
|
+
// created in a previous run whose tunnel host has since rotated.
|
|
206
|
+
// Skipped when `managedPath` is omitted (legacy callers).
|
|
207
|
+
//
|
|
208
|
+
// 3. (Opt-in via `legacyProviderHostSuffix`) `URL(config.url).host` ends
|
|
209
|
+
// with the supplied suffix AND pathname is empty or `/`. Covers the
|
|
210
|
+
// pre-marker orphans the user reported in the bug. Tightly bounded:
|
|
211
|
+
// same provider domain only, unmarked hooks only.
|
|
212
|
+
//
|
|
213
|
+
// Hooks whose `config.url` isn't a parseable URL are ignored. Hooks
|
|
214
|
+
// without an `id` are ignored.
|
|
215
|
+
async function findManagedHooks(
|
|
216
|
+
fetchImpl: typeof fetch,
|
|
217
|
+
token: string,
|
|
218
|
+
repo: RepoSlug,
|
|
219
|
+
webhookUrl: string,
|
|
220
|
+
managedPath: string | undefined,
|
|
221
|
+
legacyProviderHostSuffix: string | undefined,
|
|
222
|
+
): Promise<number[]> {
|
|
223
|
+
const response = await fetchImpl(`${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/hooks?per_page=100`, {
|
|
224
|
+
method: 'GET',
|
|
225
|
+
headers: githubJsonHeaders(token),
|
|
226
|
+
})
|
|
227
|
+
if (!response.ok) {
|
|
228
|
+
const body = await response.text().catch(() => '')
|
|
229
|
+
throw new Error(`list hooks failed: ${response.status}${body !== '' ? ` ${body}` : ''}`)
|
|
230
|
+
}
|
|
231
|
+
const hooks = (await response.json()) as Array<{ id?: unknown; config?: { url?: unknown } }>
|
|
232
|
+
const owned: number[] = []
|
|
233
|
+
for (const hook of hooks) {
|
|
234
|
+
if (typeof hook.id !== 'number') continue
|
|
235
|
+
const url = hook.config?.url
|
|
236
|
+
if (typeof url !== 'string') continue
|
|
237
|
+
if (url === webhookUrl) {
|
|
238
|
+
owned.push(hook.id)
|
|
239
|
+
continue
|
|
240
|
+
}
|
|
241
|
+
if (managedPath !== undefined && hookPathMatchesMarker(url, managedPath)) {
|
|
242
|
+
owned.push(hook.id)
|
|
243
|
+
continue
|
|
244
|
+
}
|
|
245
|
+
if (legacyProviderHostSuffix !== undefined && hookIsUnmarkedOnProvider(url, legacyProviderHostSuffix)) {
|
|
246
|
+
owned.push(hook.id)
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return owned
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function hookPathMatchesMarker(rawUrl: string, marker: string): boolean {
|
|
253
|
+
let parsed: URL
|
|
254
|
+
try {
|
|
255
|
+
parsed = new URL(rawUrl)
|
|
256
|
+
} catch {
|
|
257
|
+
return false
|
|
258
|
+
}
|
|
259
|
+
// Suffix match on pathname only (not the full URL). Rotating Cloudflare
|
|
260
|
+
// hostnames change `parsed.host`; the marker survives in `parsed.pathname`.
|
|
261
|
+
// Suffix (not equality) so a future reverse-proxy that prepends a path
|
|
262
|
+
// prefix doesn't break recognition.
|
|
263
|
+
return parsed.pathname === marker || parsed.pathname.endsWith(marker)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function hookIsUnmarkedOnProvider(rawUrl: string, hostSuffix: string): boolean {
|
|
267
|
+
let parsed: URL
|
|
268
|
+
try {
|
|
269
|
+
parsed = new URL(rawUrl)
|
|
270
|
+
} catch {
|
|
271
|
+
return false
|
|
272
|
+
}
|
|
273
|
+
// Empty pathname (rare, depends on URL parser) or root only. Anything
|
|
274
|
+
// with a real path is treated as user-controlled and left alone.
|
|
275
|
+
const unmarked = parsed.pathname === '' || parsed.pathname === '/'
|
|
276
|
+
// hostSuffix must start with a dot OR be the full host — guards against
|
|
277
|
+
// `foo.com` accidentally matching `evilfoo.com`.
|
|
278
|
+
const onProvider = parsed.host === hostSuffix || (hostSuffix.startsWith('.') && parsed.host.endsWith(hostSuffix))
|
|
279
|
+
return unmarked && onProvider
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function createHook(
|
|
283
|
+
fetchImpl: typeof fetch,
|
|
284
|
+
token: string,
|
|
285
|
+
repo: RepoSlug,
|
|
286
|
+
options: RegisterGithubWebhooksOptions,
|
|
287
|
+
): Promise<number> {
|
|
288
|
+
const response = await fetchImpl(`${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/hooks`, {
|
|
289
|
+
method: 'POST',
|
|
290
|
+
headers: githubJsonHeaders(token),
|
|
291
|
+
body: JSON.stringify(buildHookPayload(options, { includeName: true })),
|
|
292
|
+
})
|
|
293
|
+
if (!response.ok) {
|
|
294
|
+
const body = await response.text().catch(() => '')
|
|
295
|
+
throw new Error(`create hook failed: ${response.status}${body !== '' ? ` ${body}` : ''}`)
|
|
296
|
+
}
|
|
297
|
+
const raw = (await response.json()) as { id?: unknown }
|
|
298
|
+
if (typeof raw.id !== 'number') throw new Error('create hook response missing id')
|
|
299
|
+
return raw.id
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function updateHook(
|
|
303
|
+
fetchImpl: typeof fetch,
|
|
304
|
+
token: string,
|
|
305
|
+
repo: RepoSlug,
|
|
306
|
+
hookId: number,
|
|
307
|
+
options: RegisterGithubWebhooksOptions,
|
|
308
|
+
): Promise<void> {
|
|
309
|
+
const response = await fetchImpl(`${GITHUB_API_BASE}/repos/${repo.owner}/${repo.name}/hooks/${hookId}`, {
|
|
310
|
+
method: 'PATCH',
|
|
311
|
+
headers: githubJsonHeaders(token),
|
|
312
|
+
body: JSON.stringify(buildHookPayload(options, { includeName: false })),
|
|
313
|
+
})
|
|
314
|
+
if (!response.ok) {
|
|
315
|
+
const body = await response.text().catch(() => '')
|
|
316
|
+
throw new Error(`update hook failed: ${response.status}${body !== '' ? ` ${body}` : ''}`)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function buildHookPayload(
|
|
321
|
+
options: RegisterGithubWebhooksOptions,
|
|
322
|
+
{ includeName }: { includeName: boolean },
|
|
323
|
+
): Record<string, unknown> {
|
|
324
|
+
const payload: Record<string, unknown> = {
|
|
325
|
+
active: true,
|
|
326
|
+
events: toCoarseEvents(options.events),
|
|
327
|
+
config: {
|
|
328
|
+
url: options.webhookUrl,
|
|
329
|
+
content_type: 'json',
|
|
330
|
+
secret: options.webhookSecret,
|
|
331
|
+
insecure_ssl: '0',
|
|
332
|
+
},
|
|
333
|
+
}
|
|
334
|
+
if (includeName) payload.name = 'web'
|
|
335
|
+
return payload
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function toCoarseEvents(events: readonly string[]): string[] {
|
|
339
|
+
const seen = new Set<string>()
|
|
340
|
+
for (const e of events) {
|
|
341
|
+
const coarse = e.split('.')[0]
|
|
342
|
+
if (coarse && coarse.length > 0) seen.add(coarse)
|
|
343
|
+
}
|
|
344
|
+
return [...seen]
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function describe(err: unknown): string {
|
|
348
|
+
return err instanceof Error ? err.message : String(err)
|
|
349
|
+
}
|
package/src/channels/manager.ts
CHANGED
|
@@ -2,15 +2,23 @@ import { createHash } from 'node:crypto'
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
|
|
4
4
|
import type { PermissionService } from '@/permissions'
|
|
5
|
+
import type { GithubSecretsBlock } from '@/secrets'
|
|
5
6
|
import { SecretsKakaoCredentialStore } from '@/secrets/kakao-store'
|
|
6
7
|
import { SecretsBackend } from '@/secrets/storage'
|
|
7
8
|
|
|
8
9
|
import { createDiscordBotAdapter, type DiscordBotAdapter } from './adapters/discord-bot'
|
|
10
|
+
import { createGithubAdapter, type GithubAdapter } from './adapters/github'
|
|
9
11
|
import { createKakaotalkAdapter, type KakaotalkAdapter } from './adapters/kakaotalk'
|
|
10
12
|
import { createSlackBotAdapter, type SlackBotAdapter } from './adapters/slack-bot'
|
|
11
13
|
import { createTelegramBotAdapter, type TelegramBotAdapter } from './adapters/telegram-bot'
|
|
12
14
|
import { createChannelRouter, type ChannelRouter, type ClaimHandler, type CreateSessionForChannel } from './router'
|
|
13
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
ADAPTER_IDS,
|
|
17
|
+
type AdapterId,
|
|
18
|
+
type ChannelAdapterConfig,
|
|
19
|
+
type ChannelsConfig,
|
|
20
|
+
type GithubAdapterConfig,
|
|
21
|
+
} from './schema'
|
|
14
22
|
|
|
15
23
|
export type ChannelManagerLogger = {
|
|
16
24
|
info: (msg: string) => void
|
|
@@ -48,6 +56,7 @@ export type ChannelManagerOptions = {
|
|
|
48
56
|
createSessionForChannel?: CreateSessionForChannel
|
|
49
57
|
// Test seams: let fake adapters replace the real adapter wiring per id.
|
|
50
58
|
createDiscordAdapter?: typeof createDiscordBotAdapter
|
|
59
|
+
createGithubAdapter?: typeof createGithubAdapter
|
|
51
60
|
createKakaotalkAdapter?: typeof createKakaotalkAdapter
|
|
52
61
|
createSlackAdapter?: typeof createSlackBotAdapter
|
|
53
62
|
createTelegramAdapter?: typeof createTelegramBotAdapter
|
|
@@ -62,16 +71,24 @@ export type ChannelManagerOptions = {
|
|
|
62
71
|
// code. Production wiring sets this from the role-claim subsystem (see
|
|
63
72
|
// src/run/index.ts). Tests typically omit it.
|
|
64
73
|
claimHandler?: ClaimHandler
|
|
74
|
+
tunnelUrlForChannel?: (channelName: string) => string | null
|
|
75
|
+
// Whether the user declared a `tunnels[]` entry bound to this channel.
|
|
76
|
+
// Lets channel-bound adapters distinguish "operator opted out of public
|
|
77
|
+
// webhook delivery" from "operator opted in but the tunnel never produced
|
|
78
|
+
// a URL" so error logs can be precise. Same shape as
|
|
79
|
+
// `tunnelUrlForChannel` for consistency. Optional for tests.
|
|
80
|
+
tunnelConfiguredForChannel?: (channelName: string) => boolean
|
|
65
81
|
}
|
|
66
82
|
|
|
67
83
|
export type ChannelManager = {
|
|
68
84
|
router: ChannelRouter
|
|
69
85
|
start: () => Promise<void>
|
|
70
86
|
stop: () => Promise<void>
|
|
87
|
+
restartAdapter: (name: AdapterId) => Promise<void>
|
|
71
88
|
reload: () => Promise<{ started: string[]; stopped: string[]; restartRequired: string[] }>
|
|
72
89
|
}
|
|
73
90
|
|
|
74
|
-
type AnyAdapter = DiscordBotAdapter | KakaotalkAdapter | SlackBotAdapter | TelegramBotAdapter
|
|
91
|
+
type AnyAdapter = DiscordBotAdapter | GithubAdapter | KakaotalkAdapter | SlackBotAdapter | TelegramBotAdapter
|
|
75
92
|
|
|
76
93
|
// Credential signature is the comparison key for credential-rotation
|
|
77
94
|
// detection on reload. Discord and Telegram each use a single bot token;
|
|
@@ -98,14 +115,27 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
|
|
|
98
115
|
...(options.claimHandler ? { claimHandler: options.claimHandler } : {}),
|
|
99
116
|
})
|
|
100
117
|
const createDiscordAdapter = options.createDiscordAdapter ?? createDiscordBotAdapter
|
|
118
|
+
const createGithub = options.createGithubAdapter ?? createGithubAdapter
|
|
101
119
|
const createKakaotalk = options.createKakaotalkAdapter ?? createKakaotalkAdapter
|
|
102
120
|
const createSlackAdapter = options.createSlackAdapter ?? createSlackBotAdapter
|
|
103
121
|
const createTelegramAdapter = options.createTelegramAdapter ?? createTelegramBotAdapter
|
|
104
122
|
|
|
105
123
|
const live = new Map<AdapterId, AdapterEntry>()
|
|
124
|
+
const perAdapterSerial = new Map<AdapterId, Promise<unknown>>()
|
|
125
|
+
|
|
126
|
+
const runSerially = <T>(name: AdapterId, op: () => Promise<T>): Promise<T> => {
|
|
127
|
+
const prev = perAdapterSerial.get(name) ?? Promise.resolve()
|
|
128
|
+
const next = prev.then(op, op)
|
|
129
|
+
perAdapterSerial.set(
|
|
130
|
+
name,
|
|
131
|
+
next.catch(() => {}),
|
|
132
|
+
)
|
|
133
|
+
return next
|
|
134
|
+
}
|
|
106
135
|
|
|
107
136
|
const buildCredentialSignature = (name: AdapterId): { signature: string; missing: string[] } => {
|
|
108
137
|
if (name === 'kakaotalk') return buildKakaotalkSignature(options.agentDir)
|
|
138
|
+
if (name === 'github') return buildGithubSignature(options.agentDir)
|
|
109
139
|
const requiredEnvs = TOKEN_ENV[name]
|
|
110
140
|
const parts: string[] = []
|
|
111
141
|
const missing: string[] = []
|
|
@@ -151,6 +181,19 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
|
|
|
151
181
|
credentialsStore: createContainerKakaoCredentialStore(options.agentDir, env),
|
|
152
182
|
})
|
|
153
183
|
}
|
|
184
|
+
if (name === 'github') {
|
|
185
|
+
const secrets = readGithubSecrets(options.agentDir)
|
|
186
|
+
if (secrets === null) return null
|
|
187
|
+
return createGithub({
|
|
188
|
+
router,
|
|
189
|
+
configRef: () => (options.channelsConfigRef()[name] ?? cfg) as ChannelAdapterConfig & GithubAdapterConfig,
|
|
190
|
+
secrets,
|
|
191
|
+
agentDir: options.agentDir,
|
|
192
|
+
logger,
|
|
193
|
+
tunnelUrl: () => options.tunnelUrlForChannel?.('github') ?? null,
|
|
194
|
+
tunnelConfiguredForChannel: () => options.tunnelConfiguredForChannel?.('github') ?? false,
|
|
195
|
+
})
|
|
196
|
+
}
|
|
154
197
|
if (name === 'telegram-bot') {
|
|
155
198
|
const token = env.TELEGRAM_BOT_TOKEN
|
|
156
199
|
if (token === undefined || token.trim() === '') return null
|
|
@@ -193,9 +236,9 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
|
|
|
193
236
|
const stopAdapter = async (name: AdapterId): Promise<void> => {
|
|
194
237
|
const entry = live.get(name)
|
|
195
238
|
if (!entry) return
|
|
196
|
-
live.delete(name)
|
|
197
239
|
try {
|
|
198
240
|
await entry.adapter.stop()
|
|
241
|
+
live.delete(name)
|
|
199
242
|
logger.info(`[channels] adapter "${name}" stopped`)
|
|
200
243
|
} catch (err) {
|
|
201
244
|
logger.error(`[channels] adapter "${name}" failed to stop: ${describe(err)}`)
|
|
@@ -209,15 +252,31 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
|
|
|
209
252
|
const cfg = options.channelsConfigRef()
|
|
210
253
|
for (const name of ADAPTER_IDS) {
|
|
211
254
|
const adapterCfg = cfg[name]
|
|
212
|
-
if (adapterCfg !== undefined) await startAdapter(name, adapterCfg)
|
|
255
|
+
if (adapterCfg !== undefined) await runSerially(name, () => startAdapter(name, adapterCfg))
|
|
213
256
|
}
|
|
214
257
|
},
|
|
215
258
|
|
|
216
259
|
async stop(): Promise<void> {
|
|
217
|
-
for (const name of Array.from(live.keys())) await stopAdapter(name)
|
|
260
|
+
for (const name of Array.from(live.keys())) await runSerially(name, () => stopAdapter(name))
|
|
218
261
|
await router.stop()
|
|
219
262
|
},
|
|
220
263
|
|
|
264
|
+
async restartAdapter(name: AdapterId): Promise<void> {
|
|
265
|
+
await runSerially(name, async () => {
|
|
266
|
+
if (!live.has(name)) {
|
|
267
|
+
logger.info(`[channels] restartAdapter('${name}'): adapter not live, skipping`)
|
|
268
|
+
return
|
|
269
|
+
}
|
|
270
|
+
const currentCfg = options.channelsConfigRef()[name]
|
|
271
|
+
if (currentCfg === undefined) {
|
|
272
|
+
logger.info(`[channels] restartAdapter('${name}'): adapter config missing, skipping`)
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
await stopAdapter(name)
|
|
276
|
+
await startAdapter(name, currentCfg)
|
|
277
|
+
})
|
|
278
|
+
},
|
|
279
|
+
|
|
221
280
|
async reload(): Promise<{ started: string[]; stopped: string[]; restartRequired: string[] }> {
|
|
222
281
|
const cfg = options.channelsConfigRef()
|
|
223
282
|
const started: string[] = []
|
|
@@ -229,11 +288,11 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
|
|
|
229
288
|
const current = live.get(name)
|
|
230
289
|
if (desired === undefined || desired.enabled === false) {
|
|
231
290
|
if (current) {
|
|
232
|
-
await stopAdapter(name)
|
|
291
|
+
await runSerially(name, () => stopAdapter(name))
|
|
233
292
|
stopped.push(name)
|
|
234
293
|
}
|
|
235
294
|
} else if (!current) {
|
|
236
|
-
const ok = await startAdapter(name, desired)
|
|
295
|
+
const ok = await runSerially(name, () => startAdapter(name, desired))
|
|
237
296
|
if (ok) started.push(name)
|
|
238
297
|
} else {
|
|
239
298
|
const { signature, missing } = buildCredentialSignature(name)
|
|
@@ -246,7 +305,7 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
|
|
|
246
305
|
logger.warn(
|
|
247
306
|
`[channels] adapter "${name}" missing credentials after reload (${missing.join(', ')}); stopping`,
|
|
248
307
|
)
|
|
249
|
-
await stopAdapter(name)
|
|
308
|
+
await runSerially(name, () => stopAdapter(name))
|
|
250
309
|
stopped.push(name)
|
|
251
310
|
} else if (signature !== current.credentialSignature) {
|
|
252
311
|
const reason = name === 'kakaotalk' ? 'credential rotation' : 'token rotation'
|
|
@@ -263,7 +322,7 @@ export function createChannelManager(options: ChannelManagerOptions): ChannelMan
|
|
|
263
322
|
// Token-based adapters only. KakaoTalk's credentials live in
|
|
264
323
|
// secrets.json#channels.kakaotalk, not in env, so it goes through
|
|
265
324
|
// buildKakaotalkSignature instead.
|
|
266
|
-
const TOKEN_ENV: Record<Exclude<AdapterId, 'kakaotalk'>, readonly string[]> = {
|
|
325
|
+
const TOKEN_ENV: Record<Exclude<AdapterId, 'kakaotalk' | 'github'>, readonly string[]> = {
|
|
267
326
|
'discord-bot': ['DISCORD_BOT_TOKEN'],
|
|
268
327
|
'slack-bot': ['SLACK_BOT_TOKEN', 'SLACK_APP_TOKEN'],
|
|
269
328
|
'telegram-bot': ['TELEGRAM_BOT_TOKEN'],
|
|
@@ -301,6 +360,32 @@ function buildKakaotalkSignature(agentDir: string): { signature: string; missing
|
|
|
301
360
|
}
|
|
302
361
|
}
|
|
303
362
|
|
|
363
|
+
function buildGithubSignature(agentDir: string): { signature: string; missing: string[] } {
|
|
364
|
+
const block = readGithubSecrets(agentDir)
|
|
365
|
+
if (block === null) return { signature: '', missing: ['secrets.json#channels.github'] }
|
|
366
|
+
const digest = createHash('sha256').update(JSON.stringify(block)).digest('hex')
|
|
367
|
+
return { signature: `secrets.json#channels.github@sha256:${digest}`, missing: [] }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function readGithubSecrets(agentDir: string): GithubSecretsBlock | null {
|
|
371
|
+
const path = join(agentDir, 'secrets.json')
|
|
372
|
+
try {
|
|
373
|
+
const block = new SecretsBackend(path).tryReadChannelsSync()?.github
|
|
374
|
+
return isGithubSecretsBlock(block) ? block : null
|
|
375
|
+
} catch {
|
|
376
|
+
return null
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function isGithubSecretsBlock(value: unknown): value is GithubSecretsBlock {
|
|
381
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
|
382
|
+
const record = value as Record<string, unknown>
|
|
383
|
+
const auth = record.auth
|
|
384
|
+
if (typeof auth !== 'object' || auth === null || Array.isArray(auth)) return false
|
|
385
|
+
const authType = (auth as Record<string, unknown>).type
|
|
386
|
+
return authType === 'pat' || authType === 'app'
|
|
387
|
+
}
|
|
388
|
+
|
|
304
389
|
function isKakaoCredentialBlock(value: unknown): value is { accounts: Record<string, unknown> } {
|
|
305
390
|
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
|
|
306
391
|
if (!('accounts' in value)) return false
|