typeclaw 0.37.4 → 0.37.6
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/package.json +1 -1
- package/src/agent/doctor.ts +6 -1
- package/src/agent/plugin-tools.ts +23 -1
- package/src/agent/subagents.ts +146 -14
- package/src/agent/todo/scope.ts +4 -2
- package/src/agent/tools/channel-reply.ts +7 -9
- package/src/bundled-plugins/doc-render/index.ts +10 -0
- package/src/bundled-plugins/doc-render/skills/typeclaw-render-pdf/SKILL.md +171 -165
- package/src/bundled-plugins/doc-render/templates/lib.typ +339 -0
- package/src/bundled-plugins/github-cli-auth/gh-command.ts +95 -11
- package/src/bundled-plugins/github-cli-auth/git-command.ts +11 -0
- package/src/bundled-plugins/github-cli-auth/index.ts +68 -7
- package/src/bundled-plugins/memory/index.ts +9 -6
- package/src/bundled-plugins/memory/load-memory.ts +16 -2
- package/src/bundled-plugins/memory/slug.ts +19 -0
- package/src/bundled-plugins/security/policies/private-surface-read.ts +4 -1
- package/src/channels/adapters/github/inbound.ts +68 -43
- package/src/channels/adapters/github/index.ts +57 -9
- package/src/channels/adapters/github/recover-failed-deliveries.ts +270 -0
- package/src/channels/adapters/kakaotalk.ts +5 -1
- package/src/channels/adapters/mention-hints.ts +17 -0
- package/src/channels/manager.ts +77 -1
- package/src/channels/router.ts +181 -12
- package/src/cli/compose.ts +11 -2
- package/src/cli/dreams.ts +2 -2
- package/src/cli/inspect.ts +2 -2
- package/src/cli/logs.ts +2 -2
- package/src/cli/mount.ts +5 -5
- package/src/cli/require-agent-dir.ts +31 -0
- package/src/cli/restart.ts +2 -1
- package/src/cli/shell.ts +2 -2
- package/src/cli/start.ts +2 -1
- package/src/cli/stop.ts +2 -2
- package/src/cli/tui.ts +20 -6
- package/src/cli/ui.ts +13 -0
- package/src/compose/restart.ts +1 -1
- package/src/compose/start.ts +4 -2
- package/src/config/config.ts +200 -9
- package/src/container/shared.ts +18 -0
- package/src/container/start.ts +1 -1
- package/src/cron/consumer.ts +3 -3
- package/src/hostd/client.ts +48 -52
- package/src/hostd/daemon.ts +82 -39
- package/src/hostd/paths.ts +22 -2
- package/src/hostd/spawn.ts +7 -0
- package/src/init/dockerfile.ts +11 -8
- package/src/init/kakaotalk-auth.ts +2 -2
- package/src/init/packagejson.ts +2 -2
- package/src/plugin/loader.ts +7 -4
- package/src/sandbox/session-tmp.ts +6 -1
- package/src/secrets/export-claude-credentials-file.ts +2 -2
|
@@ -7,7 +7,7 @@ import { createApproveIdempotencyGuard } from './approve-idempotency'
|
|
|
7
7
|
import { createGithubEffectiveApprovalResolver, createGithubHeadShaResolver } from './effective-approval'
|
|
8
8
|
import { analyzeGhCommand, effectiveGhTokensForAuthenticatedUserEndpoint } from './gh-command'
|
|
9
9
|
import { ensureGitAskPassHelper } from './git-askpass'
|
|
10
|
-
import { analyzeGitCommand, defaultGitResolvers } from './git-command'
|
|
10
|
+
import { analyzeGitCommand, defaultGitResolvers, resolveGhDefaultRepoFromCwd } from './git-command'
|
|
11
11
|
import { checkGraphqlAuthNudge } from './graphql-auth-nudge'
|
|
12
12
|
import { commitReviewIfSucceeded, noteReviewCommand } from './review-recorder'
|
|
13
13
|
import { classifyGhToken, shouldMintAppToken } from './token-class'
|
|
@@ -72,6 +72,35 @@ export default definePlugin({
|
|
|
72
72
|
|
|
73
73
|
type HookResult = void | { block: true; reason: string }
|
|
74
74
|
|
|
75
|
+
// A TRUSTED repo to fill in for a repo-less `gh` command, resolved from
|
|
76
|
+
// sources the command author cannot forge: (1) a GitHub channel session's
|
|
77
|
+
// own repo (origin.workspace comes from the signed webhook payload), then
|
|
78
|
+
// (2) the working tree's `origin` remote. NOT from any `-R`/path in the
|
|
79
|
+
// command (that is the attacker-controllable input the parser already
|
|
80
|
+
// handles). The slug is still gated by the repos[] allowlist at mint time.
|
|
81
|
+
const resolveTrustedFallbackRepo = async (origin: SessionOrigin | undefined): Promise<string | undefined> => {
|
|
82
|
+
if (origin?.kind === 'channel' && origin.adapter === 'github' && origin.workspace !== '') {
|
|
83
|
+
return origin.workspace
|
|
84
|
+
}
|
|
85
|
+
const fromCwd = await resolveGhDefaultRepoFromCwd(ctx.agentDir, defaultGitResolvers)
|
|
86
|
+
return fromCwd ?? undefined
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// When a repo-less `gh` is blocked but a trusted repo IS available, show the
|
|
90
|
+
// exact single-bare rewrite so the agent recovers in one step instead of
|
|
91
|
+
// guessing. Composition blocks get a split-the-script instruction. The
|
|
92
|
+
// returned text is appended to the block reason (synchronous, always seen).
|
|
93
|
+
const buildGhBlockGuidance = (code: string, fallbackRepo: string | undefined): string => {
|
|
94
|
+
const slug = fallbackRepo ?? 'owner/repo'
|
|
95
|
+
if (code === 'composition') {
|
|
96
|
+
return (
|
|
97
|
+
` Run each gh as its own single bare command, e.g. \`gh label edit <name> -R ${slug} --name ...\` —` +
|
|
98
|
+
' not inside a function, `if`/`then`, `&&`, `;`, or `$(...)`.'
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
return ` For example: \`gh <cmd> -R ${slug}\` as a single bare command.`
|
|
102
|
+
}
|
|
103
|
+
|
|
75
104
|
// 'fall-through' means "not a repo-targeting gh command" so the caller can
|
|
76
105
|
// try the git path on the same command (e.g. `git ... # gh` substrings).
|
|
77
106
|
const handleGhCommand = async (params: {
|
|
@@ -91,7 +120,26 @@ export default definePlugin({
|
|
|
91
120
|
}
|
|
92
121
|
if (review.dump !== null) return review.dump
|
|
93
122
|
|
|
94
|
-
|
|
123
|
+
// Analyze first WITHOUT the fallback: an explicit `-R`/path repo must win,
|
|
124
|
+
// and we only pay for fallback resolution (a git subprocess) when the
|
|
125
|
+
// command is otherwise repo-less. A trusted fallback is then applied ONLY to
|
|
126
|
+
// a `missing-repo` block (never to composition/non-literal/multi-owner/api),
|
|
127
|
+
// and re-analysis re-runs the SAME composition gate, so a compound command
|
|
128
|
+
// still blocks. `fallbackRepoUsed` marks an inject that came from the
|
|
129
|
+
// fallback so we also set GH_REPO (gh needs the repo, not just the token).
|
|
130
|
+
let decision = analyzeGhCommand(command)
|
|
131
|
+
let fallbackRepo: string | undefined
|
|
132
|
+
let fallbackRepoUsed = false
|
|
133
|
+
if (decision.kind === 'block' && decision.code === 'missing-repo') {
|
|
134
|
+
fallbackRepo = await resolveTrustedFallbackRepo(event.origin)
|
|
135
|
+
if (fallbackRepo !== undefined) {
|
|
136
|
+
const withFallback = analyzeGhCommand(command, fallbackRepo)
|
|
137
|
+
if (withFallback.kind === 'inject') {
|
|
138
|
+
decision = withFallback
|
|
139
|
+
fallbackRepoUsed = true
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
95
143
|
|
|
96
144
|
// `/user` classifies as pass-through (no repo to mint for), so this block
|
|
97
145
|
// must run BEFORE the pass-through return. Resolve the EFFECTIVE token per
|
|
@@ -140,7 +188,10 @@ export default definePlugin({
|
|
|
140
188
|
// NOT apply — a PAT needs no per-repo mint — so we never surface it here.
|
|
141
189
|
if (runsUnsandboxed(event.origin)) {
|
|
142
190
|
if (decision.kind === 'inject') {
|
|
143
|
-
event.args[TYPECLAW_INTERNAL_BASH_ENV] = {
|
|
191
|
+
event.args[TYPECLAW_INTERNAL_BASH_ENV] = {
|
|
192
|
+
GH_TOKEN: process.env.GH_TOKEN as string,
|
|
193
|
+
...(fallbackRepoUsed && fallbackRepo !== undefined ? { GH_REPO: fallbackRepo } : {}),
|
|
194
|
+
}
|
|
144
195
|
}
|
|
145
196
|
return
|
|
146
197
|
}
|
|
@@ -148,14 +199,18 @@ export default definePlugin({
|
|
|
148
199
|
// available (a PAT must NOT suppress it, or the original silent-failure
|
|
149
200
|
// bug returns); otherwise block with guidance rather than failing mute.
|
|
150
201
|
if (!shouldMintAppToken(undefined, hasAppTokenResolver())) {
|
|
151
|
-
if (decision.kind === 'block')
|
|
202
|
+
if (decision.kind === 'block') {
|
|
203
|
+
return { block: true, reason: decision.reason + buildGhBlockGuidance(decision.code, fallbackRepo) }
|
|
204
|
+
}
|
|
152
205
|
warnSandboxedPatWithheldOnce()
|
|
153
206
|
return { block: true, reason: sandboxedPatWithheldReason }
|
|
154
207
|
}
|
|
155
208
|
mintForSandboxedPat = true
|
|
156
209
|
}
|
|
157
210
|
|
|
158
|
-
if (decision.kind === 'block')
|
|
211
|
+
if (decision.kind === 'block') {
|
|
212
|
+
return { block: true, reason: decision.reason + buildGhBlockGuidance(decision.code, fallbackRepo) }
|
|
213
|
+
}
|
|
159
214
|
|
|
160
215
|
// No App auth (no App-class GH_TOKEN and no live minter): leave whatever
|
|
161
216
|
// is seeded so `gh` fails honestly rather than us guessing a token. The
|
|
@@ -166,8 +221,14 @@ export default definePlugin({
|
|
|
166
221
|
if (result.kind === 'unavailable') return { block: true, reason: result.reason }
|
|
167
222
|
// Inject via the internal env overlay (delivered to the spawn / bwrap
|
|
168
223
|
// --setenv by the bash wrapper) so the token never enters the command
|
|
169
|
-
// string, where it could leak through logs or later hooks.
|
|
170
|
-
|
|
224
|
+
// string, where it could leak through logs or later hooks. When the repo
|
|
225
|
+
// came from a trusted fallback (not an explicit -R), also set GH_REPO so
|
|
226
|
+
// `gh` actually targets it — a token alone leaves the repo unresolved.
|
|
227
|
+
// GH_REPO is non-secret; the token still scopes reach to that repo.
|
|
228
|
+
event.args[TYPECLAW_INTERNAL_BASH_ENV] = {
|
|
229
|
+
GH_TOKEN: result.token,
|
|
230
|
+
...(fallbackRepoUsed ? { GH_REPO: decision.repoSlug } : {}),
|
|
231
|
+
}
|
|
171
232
|
return
|
|
172
233
|
}
|
|
173
234
|
|
|
@@ -155,12 +155,17 @@ const VECTOR_TURN_TOP_K = 10
|
|
|
155
155
|
// without loading the ~279 MB model, or `hybridSearch` to fake retrieval while
|
|
156
156
|
// testing hook orchestration — without leaking state across other tests in the
|
|
157
157
|
// same worker. Production uses the real `embed` and `hybridSearch`.
|
|
158
|
-
type MemoryPluginDeps = {
|
|
158
|
+
export type MemoryPluginDeps = {
|
|
159
159
|
hybridSearch: typeof hybridSearch
|
|
160
160
|
queryEmbedFn: EmbedFn
|
|
161
|
+
openAppendVectorStore: (agentDir: string) => VectorStore
|
|
161
162
|
}
|
|
162
163
|
|
|
163
|
-
const defaultDeps: MemoryPluginDeps = {
|
|
164
|
+
const defaultDeps: MemoryPluginDeps = {
|
|
165
|
+
hybridSearch,
|
|
166
|
+
queryEmbedFn: embed,
|
|
167
|
+
openAppendVectorStore: (agentDir) => VectorStore.open(join(agentDir, 'memory', '.vectors', 'index.db')),
|
|
168
|
+
}
|
|
164
169
|
|
|
165
170
|
// Builds the per-turn user-prompt memory block for a vector agent. Non-channel
|
|
166
171
|
// turns always use top-K hybrid search, regardless of total shard size. Repeated
|
|
@@ -231,7 +236,7 @@ async function renderVectorTurnMemory(
|
|
|
231
236
|
}
|
|
232
237
|
}
|
|
233
238
|
|
|
234
|
-
function createMemoryPlugin(deps: MemoryPluginDeps = defaultDeps) {
|
|
239
|
+
export function createMemoryPlugin(deps: MemoryPluginDeps = defaultDeps) {
|
|
235
240
|
return definePlugin({
|
|
236
241
|
configSchema: memoryConfigSchema,
|
|
237
242
|
plugin: async (ctx) => {
|
|
@@ -401,9 +406,7 @@ function createMemoryPlugin(deps: MemoryPluginDeps = defaultDeps) {
|
|
|
401
406
|
}
|
|
402
407
|
|
|
403
408
|
// Open a long-lived VectorStore for append-time indexing when vector is enabled.
|
|
404
|
-
const appendVectorStore = ctx.config.vector.enabled
|
|
405
|
-
? VectorStore.open(join(ctx.agentDir, 'memory', '.vectors', 'index.db'))
|
|
406
|
-
: undefined
|
|
409
|
+
const appendVectorStore = ctx.config.vector.enabled ? deps.openAppendVectorStore(ctx.agentDir) : undefined
|
|
407
410
|
|
|
408
411
|
return {
|
|
409
412
|
subagents: {
|
|
@@ -6,6 +6,7 @@ import type { SessionOrigin } from '@/agent/session-origin'
|
|
|
6
6
|
import { buildInjectionPlan, DEFAULT_INJECTION_BUDGET_BYTES, type InjectionPlan } from './injection-plan'
|
|
7
7
|
import { loadAllShards, type TopicShard } from './load-shards'
|
|
8
8
|
import { topicsDir } from './paths'
|
|
9
|
+
import { slugIsHeadingEcho } from './slug'
|
|
9
10
|
import type { DedupedRetrievedItem } from './turn-dedup'
|
|
10
11
|
|
|
11
12
|
const MAX_FILE_BYTES = 12 * 1024
|
|
@@ -130,7 +131,7 @@ export function renderRetrievedMemorySection(
|
|
|
130
131
|
lines.push(`## ${item.heading}`)
|
|
131
132
|
lines.push(item.excerpt.trimEnd(), '')
|
|
132
133
|
} else if (item.source === 'topic' || item.source === 'reference') {
|
|
133
|
-
lines.push(
|
|
134
|
+
lines.push(topicIndexEntry(item.heading, item.key))
|
|
134
135
|
} else {
|
|
135
136
|
lines.push(`- ${item.heading} _(recent observation)_`)
|
|
136
137
|
}
|
|
@@ -150,11 +151,24 @@ export function renderTopicIndexMemorySection(
|
|
|
150
151
|
if (options.origin?.kind === 'channel') lines.push(...CHANNEL_MEMORY_BOUNDARY, '')
|
|
151
152
|
lines.push(topicIndexDirective(options), '')
|
|
152
153
|
for (const shard of shards) {
|
|
153
|
-
lines.push(
|
|
154
|
+
lines.push(topicIndexEntry(shard.frontmatter.heading, shard.slug))
|
|
154
155
|
}
|
|
155
156
|
return lines.join('\n').trimEnd()
|
|
156
157
|
}
|
|
157
158
|
|
|
159
|
+
// A topic-index line names a topic so the model can decide whether to open it
|
|
160
|
+
// (the slug is the `memory_search({ topic })` key). When the slug is just a kebab
|
|
161
|
+
// echo of the heading the heading adds no signal, so render the slug alone; keep
|
|
162
|
+
// both when they diverge (e.g. `gh-api-labels-array-syntax` vs "GitHub API label
|
|
163
|
+
// management in the agent environment") or when the heading has no ASCII form
|
|
164
|
+
// (e.g. CJK), where `slugIsHeadingEcho` returns false and the readable name stays.
|
|
165
|
+
function topicIndexEntry(heading: string, slug: string): string {
|
|
166
|
+
if (slugIsHeadingEcho(heading, slug)) {
|
|
167
|
+
return `- \`${slug}\``
|
|
168
|
+
}
|
|
169
|
+
return `- ${heading} \`${slug}\``
|
|
170
|
+
}
|
|
171
|
+
|
|
158
172
|
function topicIndexDirective(options: Pick<LoadMemoryOptions, 'origin'>): string {
|
|
159
173
|
if (options.origin?.kind === 'channel') {
|
|
160
174
|
return 'Memory shown as headings only in channels. Call `memory_search({ topic: "<slug>" })` with a slug below to read a full body.'
|
|
@@ -20,6 +20,25 @@ export function headingToSlug(heading: string, existingSlugs: Set<string>): stri
|
|
|
20
20
|
return slug
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// True only when `slug` is a clean kebab echo of `heading` (the readable form
|
|
24
|
+
// adds nothing the slug doesn't). `headingToSlug` maps every non-ASCII letter,
|
|
25
|
+
// ideograph, or symbol to `-` (or to an `untitled-<hash>` when nothing survives),
|
|
26
|
+
// so a heading like `한글 memo` slugifies to `memo` and an all-CJK/emoji heading to
|
|
27
|
+
// the fallback — collapsing either would drop the only human-readable name. Guard
|
|
28
|
+
// by requiring the diacritic-folded heading to consist solely of ASCII
|
|
29
|
+
// alphanumerics and separators/punctuation; any surviving CJK/emoji/symbol means
|
|
30
|
+
// normalization discarded content, so it is never an echo. (Diacritics are
|
|
31
|
+
// transliterated, not dropped — `café` → `cafe` stays a legitimate echo.)
|
|
32
|
+
const ECHO_SAFE_HEADING = /^[A-Za-z0-9\s\p{P}]*$/u
|
|
33
|
+
|
|
34
|
+
export function slugIsHeadingEcho(heading: string, slug: string): boolean {
|
|
35
|
+
const folded = heading.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
|
36
|
+
if (!ECHO_SAFE_HEADING.test(folded)) {
|
|
37
|
+
return false
|
|
38
|
+
}
|
|
39
|
+
return headingToSlug(heading, new Set<string>()) === slug
|
|
40
|
+
}
|
|
41
|
+
|
|
23
42
|
function normalizeHeading(heading: string): string {
|
|
24
43
|
let normalized = heading.normalize('NFD').replace(/[\u0300-\u036f]/g, '')
|
|
25
44
|
|
|
@@ -178,7 +178,10 @@ function matchHidden(
|
|
|
178
178
|
}
|
|
179
179
|
for (const dir of deniedDirs) {
|
|
180
180
|
const realDir = realpathRealIntendedPath(dir)
|
|
181
|
-
|
|
181
|
+
// realpathRealIntendedPath joins with the platform separator, so the
|
|
182
|
+
// under-dir test must use path.sep too — a hardcoded "/" never matches the
|
|
183
|
+
// "\"-joined paths a win32 test runner produces.
|
|
184
|
+
if (resolved === realDir || resolved.startsWith(`${realDir}${path.sep}`)) return dir
|
|
182
185
|
}
|
|
183
186
|
return undefined
|
|
184
187
|
}
|
|
@@ -61,56 +61,81 @@ export function createGithubWebhookHandler(options: GithubWebhookHandlerOptions)
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
const delivery = req.headers.get('x-github-delivery') ?? ''
|
|
64
|
-
if (delivery !== '' && options.dedup.has(delivery)) {
|
|
65
|
-
options.logger.info(`[github] duplicate delivery ignored id=${delivery}`)
|
|
66
|
-
return ok()
|
|
67
|
-
}
|
|
68
|
-
|
|
69
64
|
const event = req.headers.get('x-github-event') ?? ''
|
|
70
65
|
const payload = parseJson(body)
|
|
71
66
|
if (payload === null) return ok()
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const author = readAuthor(event, payload)
|
|
78
|
-
if (author !== null && isSelfAuthor(author, selfId, selfLogin)) {
|
|
79
|
-
maybeScheduleDecoyReviewerDrop({ event, action, payload, selfLogin, options })
|
|
80
|
-
options.logger.info(
|
|
81
|
-
`[github] dropped self-authored ${event}${action !== null ? `.${action}` : ''} from @${author.login}`,
|
|
82
|
-
)
|
|
83
|
-
return ok()
|
|
84
|
-
}
|
|
67
|
+
// The HTTP request is only transport; event handling is the shared core.
|
|
68
|
+
await processVerifiedGithubDelivery(options, { event, delivery, payload })
|
|
69
|
+
return ok()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
85
72
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
73
|
+
// Post-verification core shared by the live HTTP handler AND the missed-delivery
|
|
74
|
+
// recovery sweep (recover-failed-deliveries.ts), which pulls lost payloads from
|
|
75
|
+
// GitHub's authenticated deliveries API and re-injects them with no HTTP request
|
|
76
|
+
// or signature. Routing both through this one function is the load-bearing
|
|
77
|
+
// invariant: recovery must never become a second, divergent event pipeline.
|
|
78
|
+
// `delivery` is the `X-GitHub-Delivery` GUID (stable across redeliveries, equal
|
|
79
|
+
// to a delivery's `guid`) used as the dedup key, so a recovered event and its
|
|
80
|
+
// later/duplicate live delivery collapse to one route. HMAC is the caller's job:
|
|
81
|
+
// the live handler verifies the body; the sweep trusts the authenticated API.
|
|
82
|
+
export async function processVerifiedGithubDelivery(
|
|
83
|
+
options: GithubWebhookHandlerOptions,
|
|
84
|
+
input: { event: string; delivery: string; payload: Record<string, unknown> },
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
const { event, delivery, payload } = input
|
|
87
|
+
if (delivery !== '') {
|
|
88
|
+
if (options.dedup.has(delivery)) {
|
|
89
|
+
options.logger.info(`[github] duplicate delivery ignored id=${delivery}`)
|
|
90
|
+
return
|
|
98
91
|
}
|
|
92
|
+
// Reserve the delivery id synchronously, BEFORE the awaits below, so a live
|
|
93
|
+
// webhook and the recovery sweep can never both clear the dedup gate for the
|
|
94
|
+
// same event and route it twice. JS is single-threaded: nothing else runs
|
|
95
|
+
// between this has-check and add, so the reservation is atomic. The awaits
|
|
96
|
+
// and classify that follow are all throw-safe, so reserving early cannot
|
|
97
|
+
// strand a routable event.
|
|
98
|
+
options.dedup.add(delivery)
|
|
99
|
+
}
|
|
99
100
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
return ok()
|
|
101
|
+
const action = readString(payload, 'action')
|
|
102
|
+
if (!isGithubEventAllowed(options.allowlist(), event, action)) return
|
|
103
|
+
|
|
104
|
+
const selfId = options.selfId()
|
|
105
|
+
const selfLogin = options.selfLogin()
|
|
106
|
+
const author = readAuthor(event, payload)
|
|
107
|
+
if (author !== null && isSelfAuthor(author, selfId, selfLogin)) {
|
|
108
|
+
maybeScheduleDecoyReviewerDrop({ event, action, payload, selfLogin, options })
|
|
109
|
+
options.logger.info(
|
|
110
|
+
`[github] dropped self-authored ${event}${action !== null ? `.${action}` : ''} from @${author.login}`,
|
|
111
|
+
)
|
|
112
|
+
return
|
|
113
113
|
}
|
|
114
|
+
|
|
115
|
+
// A push to an open PR (`synchronize`) is not a message to react to — it is
|
|
116
|
+
// a trigger to re-evaluate the bot's own outstanding review obligations on
|
|
117
|
+
// this PR: unresolved review threads it authored AND a sticky
|
|
118
|
+
// CHANGES_REQUESTED block (which leaves no threads when filed as a top-level
|
|
119
|
+
// verdict — the black hole this path closes). Both need an API round-trip,
|
|
120
|
+
// so it runs OFF the ACK path (like the decoy-reviewer drop) and only wakes a
|
|
121
|
+
// session when an obligation is outstanding. Returning here also keeps
|
|
122
|
+
// synchronize out of the generic awareness-only fallthrough below.
|
|
123
|
+
if (event === 'pull_request' && action === 'synchronize') {
|
|
124
|
+
scheduleReviewFollowup({ payload, selfLogin, options })
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const teamIsBotMember = await resolveTeamMembership(event, payload, options)
|
|
129
|
+
const reviewCommentParent = await resolveReviewCommentParent(event, payload, selfId, selfLogin, options)
|
|
130
|
+
const classified = classifyGithubInbound(event, payload, selfLogin, {
|
|
131
|
+
teamIsBotMember,
|
|
132
|
+
authType: options.authType?.() ?? 'pat',
|
|
133
|
+
reviewOn: options.reviewOn?.() ?? 'review_requested',
|
|
134
|
+
...(reviewCommentParent !== null ? { reviewCommentParent } : {}),
|
|
135
|
+
})
|
|
136
|
+
if (classified === null) return
|
|
137
|
+
|
|
138
|
+
options.route(withApprovalPolicy(classified, options.allowApprove?.() ?? true))
|
|
114
139
|
}
|
|
115
140
|
|
|
116
141
|
export const PR_APPROVAL_DISABLED_NOTE =
|
|
@@ -11,7 +11,7 @@ import { createDeliveryDedup } from './dedup'
|
|
|
11
11
|
import { findPermissionGaps } from './event-permissions'
|
|
12
12
|
import { createGithubFetchAttachmentCallback } from './fetch-attachment'
|
|
13
13
|
import { createGithubHistoryCallback } from './history'
|
|
14
|
-
import { createGithubWebhookHandler } from './inbound'
|
|
14
|
+
import { createGithubWebhookHandler, processVerifiedGithubDelivery, type GithubWebhookHandlerOptions } from './inbound'
|
|
15
15
|
import { applyManagedPath, buildManagedPath, resolveAgentId } from './managed-path'
|
|
16
16
|
import { createGithubMembershipResolver } from './membership'
|
|
17
17
|
import { createGithubOutboundCallback } from './outbound'
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
} from './permission-guidance'
|
|
23
23
|
import { createGithubReactionCallback, createGithubRemoveReactionCallback } from './reactions'
|
|
24
24
|
import { reconcileOpenPrs } from './reconcile-open-prs'
|
|
25
|
+
import { createRecoveredGuidLog, recoverFailedGithubDeliveries } from './recover-failed-deliveries'
|
|
25
26
|
import { createGithubReviewStateResolver } from './review-state'
|
|
26
27
|
import { createGithubReviewThreadResolver } from './review-thread-resolver'
|
|
27
28
|
import { createTeamMembershipChecker } from './team-membership'
|
|
@@ -67,6 +68,10 @@ export type GithubAdapterOptions = {
|
|
|
67
68
|
// Test-only: replaces `setInterval` so tests can control when the
|
|
68
69
|
// background refresh fires without waiting on real wall-clock time.
|
|
69
70
|
setInterval?: (handler: () => void, ms: number) => { clear: () => void }
|
|
71
|
+
// How often to sweep each managed hook's GitHub delivery log for events whose
|
|
72
|
+
// inbound delivery failed (and that GitHub never redelivered), re-injecting
|
|
73
|
+
// them through the live event path. Zero disables the sweep. Default: 5 min.
|
|
74
|
+
deliveryRecoveryIntervalMs?: number
|
|
70
75
|
// Write-side of the GithubTokenBridge. On App-auth start the adapter
|
|
71
76
|
// registers a per-repo minter here so plugin hooks can resolve a token for
|
|
72
77
|
// ad-hoc `gh` commands; it unregisters on stop and on start rollback. PAT
|
|
@@ -89,6 +94,12 @@ const consoleLogger: GithubAdapterLogger = {
|
|
|
89
94
|
|
|
90
95
|
const DEFAULT_WEBHOOK_REGISTRATION_DELAY_MS = 2_000
|
|
91
96
|
const DEFAULT_TOKEN_REFRESH_INTERVAL_MS = 30 * 60 * 1000
|
|
97
|
+
const DEFAULT_DELIVERY_RECOVERY_INTERVAL_MS = 5 * 60 * 1000
|
|
98
|
+
// GitHub retains the delivery log for 3 days; sweep a little under that so a
|
|
99
|
+
// failed delivery is always still listable on the next interval.
|
|
100
|
+
const DELIVERY_RECOVERY_LOOKBACK_MS = 70 * 60 * 60 * 1000
|
|
101
|
+
// Bounds an LLM-session storm if a bad tunnel window drops a large burst.
|
|
102
|
+
const MAX_RECOVERED_PER_SWEEP = 50
|
|
92
103
|
|
|
93
104
|
export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapter {
|
|
94
105
|
const logger = options.logger ?? consoleLogger
|
|
@@ -105,8 +116,15 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
105
116
|
let started = false
|
|
106
117
|
let managedHooks: ReadonlyArray<{ repo: string; hookId: number }> = []
|
|
107
118
|
let tokenRefreshTimer: { clear: () => void } | null = null
|
|
119
|
+
let deliveryRecoveryTimer: { clear: () => void } | null = null
|
|
108
120
|
let unregisterTokenBridge: (() => void) | null = null
|
|
109
121
|
const workspaceByChat = new Map<string, string>()
|
|
122
|
+
const setIntervalFn =
|
|
123
|
+
options.setInterval ??
|
|
124
|
+
((handler: () => void, ms: number) => {
|
|
125
|
+
const timer = setInterval(handler, ms)
|
|
126
|
+
return { clear: () => clearInterval(timer) }
|
|
127
|
+
})
|
|
110
128
|
|
|
111
129
|
const rememberWorkspace = (workspace: string, chat: string): void => {
|
|
112
130
|
workspaceByChat.set(chat, workspace)
|
|
@@ -174,7 +192,7 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
174
192
|
logger.error(`[github] route failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
175
193
|
})
|
|
176
194
|
}
|
|
177
|
-
const
|
|
195
|
+
const handlerOptions: GithubWebhookHandlerOptions = {
|
|
178
196
|
webhookSecret,
|
|
179
197
|
dedup,
|
|
180
198
|
allowlist: () => options.configRef().eventAllowlist,
|
|
@@ -188,7 +206,8 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
188
206
|
fetchImpl,
|
|
189
207
|
logger,
|
|
190
208
|
route: routeInbound,
|
|
191
|
-
}
|
|
209
|
+
}
|
|
210
|
+
const handler = createGithubWebhookHandler(handlerOptions)
|
|
192
211
|
|
|
193
212
|
return {
|
|
194
213
|
async start(): Promise<void> {
|
|
@@ -251,12 +270,6 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
251
270
|
)
|
|
252
271
|
})
|
|
253
272
|
}
|
|
254
|
-
const setIntervalFn =
|
|
255
|
-
options.setInterval ??
|
|
256
|
-
((handler: () => void, ms: number) => {
|
|
257
|
-
const timer = setInterval(handler, ms)
|
|
258
|
-
return { clear: () => clearInterval(timer) }
|
|
259
|
-
})
|
|
260
273
|
tokenRefreshTimer = setIntervalFn(refresh, tokenRefreshIntervalMs)
|
|
261
274
|
}
|
|
262
275
|
} else {
|
|
@@ -355,10 +368,45 @@ export function createGithubAdapter(options: GithubAdapterOptions): GithubAdapte
|
|
|
355
368
|
logger.warn(`[github] reconcile pass failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
356
369
|
})
|
|
357
370
|
}
|
|
371
|
+
// Periodically recover inbound deliveries that failed at the tunnel and
|
|
372
|
+
// were never redelivered (the cloudflare-quick 502 loss). Registered only
|
|
373
|
+
// when we manage hooks to query, and driven by the same injectable timer
|
|
374
|
+
// as the token refresh. The first sweep fires after one interval — NOT
|
|
375
|
+
// inside start() — so start() stays free of surprise API traffic; the
|
|
376
|
+
// reconcile pass above already covers the review-needed case immediately.
|
|
377
|
+
const deliveryRecoveryIntervalMs = options.deliveryRecoveryIntervalMs ?? DEFAULT_DELIVERY_RECOVERY_INTERVAL_MS
|
|
378
|
+
if (managedHooks.length > 0 && deliveryRecoveryIntervalMs > 0) {
|
|
379
|
+
// Created once and captured by `sweep`, so recovery idempotency persists
|
|
380
|
+
// across ticks even when the shared live dedup evicts the guid.
|
|
381
|
+
const recoveredLog = createRecoveredGuidLog(DELIVERY_RECOVERY_LOOKBACK_MS)
|
|
382
|
+
const sweep = () => {
|
|
383
|
+
recoverFailedGithubDeliveries({
|
|
384
|
+
hooks: managedHooks,
|
|
385
|
+
token: (repoSlug: string) => auth.token({ repoSlug }),
|
|
386
|
+
process: (input) => processVerifiedGithubDelivery(handlerOptions, input),
|
|
387
|
+
alreadySeen: (guid: string) => dedup.has(guid),
|
|
388
|
+
recoveredLog,
|
|
389
|
+
lookbackMs: DELIVERY_RECOVERY_LOOKBACK_MS,
|
|
390
|
+
maxPerSweep: MAX_RECOVERED_PER_SWEEP,
|
|
391
|
+
logger,
|
|
392
|
+
fetchImpl,
|
|
393
|
+
}).catch((err: unknown) => {
|
|
394
|
+
logger.warn(`[github] delivery recovery sweep failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
395
|
+
})
|
|
396
|
+
}
|
|
397
|
+
deliveryRecoveryTimer = setIntervalFn(sweep, deliveryRecoveryIntervalMs)
|
|
398
|
+
}
|
|
358
399
|
},
|
|
359
400
|
async stop(): Promise<void> {
|
|
360
401
|
if (!started) return
|
|
361
402
|
started = false
|
|
403
|
+
// Stop the recovery sweep first: its async work outlives the synchronous
|
|
404
|
+
// unregister calls below, and a tick landing mid-teardown would query a
|
|
405
|
+
// hook we're about to deregister and could route during shutdown.
|
|
406
|
+
if (deliveryRecoveryTimer !== null) {
|
|
407
|
+
deliveryRecoveryTimer.clear()
|
|
408
|
+
deliveryRecoveryTimer = null
|
|
409
|
+
}
|
|
362
410
|
options.router.unregisterOutbound('github', outbound)
|
|
363
411
|
options.router.unregisterReaction('github', reaction)
|
|
364
412
|
options.router.unregisterRemoveReaction('github', removeReaction)
|