typeclaw 0.30.0 → 0.31.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/package.json +1 -1
- package/scripts/verify-realproc-sandbox.sh +58 -0
- package/src/agent/plugin-tools.ts +13 -0
- package/src/agent/system-prompt.ts +1 -1
- package/src/bundled-plugins/github-cli-auth/approve-idempotency.ts +113 -52
- package/src/bundled-plugins/github-cli-auth/effective-approval.ts +14 -9
- package/src/bundled-plugins/github-cli-auth/index.ts +3 -3
- package/src/channels/adapters/discord-bot-format.ts +191 -0
- package/src/channels/adapters/discord-bot.ts +2 -1
- package/src/channels/adapters/github/inbound.ts +88 -30
- package/src/channels/adapters/github/review-state.ts +27 -0
- package/src/channels/outbound-flood-filter.ts +70 -3
- package/src/channels/router.ts +78 -24
- package/src/compose/discover.ts +5 -1
- package/src/config/config.ts +38 -0
- package/src/container/start.ts +14 -0
- package/src/sandbox/build.ts +41 -9
- package/src/sandbox/policy.ts +9 -1
- package/src/skills/typeclaw-markdown-pdf/SKILL.md +327 -0
- package/typeclaw.schema.json +24 -0
package/package.json
CHANGED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Manual acceptance check for the sandbox.realProc strategy (src/sandbox/build.ts).
|
|
3
|
+
# Not a unit test: it needs a Linux container with CAP_SYS_ADMIN, which the macOS
|
|
4
|
+
# dev host and standard CI runners cannot provide, so it lives here as an
|
|
5
|
+
# operator-runnable script instead of a skipIf-everywhere test.
|
|
6
|
+
#
|
|
7
|
+
# Proves two properties of the two-phase `unshare --mount-proc -- bwrap` sandbox:
|
|
8
|
+
# 1. An external package runner (bunx) runs to completion (no Bun "NotDir").
|
|
9
|
+
# 2. A secret in a sibling process's environment NEVER appears in any
|
|
10
|
+
# /proc/*/environ the sandbox can read (PID-namespace scoping holds).
|
|
11
|
+
#
|
|
12
|
+
# Usage: scripts/verify-realproc-sandbox.sh [image]
|
|
13
|
+
# image defaults to ghcr.io/typeclaw/typeclaw-base:<version-from-package.json>
|
|
14
|
+
set -euo pipefail
|
|
15
|
+
|
|
16
|
+
IMAGE="${1:-}"
|
|
17
|
+
if [ -z "$IMAGE" ]; then
|
|
18
|
+
version="$(node -p "require('./package.json').version" 2>/dev/null || echo latest)"
|
|
19
|
+
IMAGE="ghcr.io/typeclaw/typeclaw-base:${version}"
|
|
20
|
+
fi
|
|
21
|
+
|
|
22
|
+
secret="TYPECLAW_REALPROC_LEAK_CANARY_$$"
|
|
23
|
+
|
|
24
|
+
inner='
|
|
25
|
+
echo "=== bunx via real-proc sandbox ==="
|
|
26
|
+
bunx cowsay "real-proc ok" 2>&1 | tail -6
|
|
27
|
+
echo "bunx exit=$?"
|
|
28
|
+
echo "=== visible pids (sandbox should NOT see the canary holder) ==="
|
|
29
|
+
ls /proc | grep -E "^[0-9]+$" | tr "\n" " "; echo
|
|
30
|
+
echo "=== leak scan ==="
|
|
31
|
+
found=0
|
|
32
|
+
for f in /proc/[0-9]*/environ; do
|
|
33
|
+
if tr "\0" "\n" < "$f" 2>/dev/null | grep -q "CANARY_TOKEN"; then
|
|
34
|
+
echo "LEAK:$f"; found=1
|
|
35
|
+
fi
|
|
36
|
+
done
|
|
37
|
+
if [ $found -eq 0 ]; then echo "NO_LEAK_CONFIRMED"; else echo "LEAK_DETECTED"; exit 1; fi
|
|
38
|
+
'
|
|
39
|
+
inner="${inner//CANARY_TOKEN/$secret}"
|
|
40
|
+
|
|
41
|
+
# The real-proc argv shape mirrors buildArgv() in src/sandbox/build.ts. Keep in
|
|
42
|
+
# sync if that helper changes.
|
|
43
|
+
runner="
|
|
44
|
+
${secret}_holder() { :; }
|
|
45
|
+
env CANARY=${secret} sleep 120 &
|
|
46
|
+
unshare --pid --fork --mount --mount-proc -- \
|
|
47
|
+
bwrap --unshare-user --unshare-ipc --unshare-uts --unshare-cgroup \
|
|
48
|
+
--new-session --die-with-parent --clearenv \
|
|
49
|
+
--setenv PATH /usr/local/bin:/usr/bin:/bin --setenv HOME /tmp --setenv LANG C.UTF-8 \
|
|
50
|
+
--ro-bind /usr /usr --ro-bind /etc /etc --dev /dev --tmpfs /tmp \
|
|
51
|
+
--ro-bind-try /bin /bin --ro-bind-try /sbin /sbin --ro-bind-try /lib /lib --ro-bind-try /lib64 /lib64 \
|
|
52
|
+
--ro-bind /proc /proc \
|
|
53
|
+
bash -c '$inner'
|
|
54
|
+
"
|
|
55
|
+
|
|
56
|
+
echo "Image: $IMAGE"
|
|
57
|
+
docker run --rm --security-opt seccomp=unconfined --cap-add SYS_ADMIN \
|
|
58
|
+
-e "CANARY=${secret}" "$IMAGE" bash -c "$runner"
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
checkNonWorkspaceWriteGuard,
|
|
24
24
|
checkSkillAuthoringGuard,
|
|
25
25
|
} from '@/bundled-plugins/guard/policy'
|
|
26
|
+
import { config } from '@/config/config'
|
|
26
27
|
import type { PermissionService } from '@/permissions/permissions'
|
|
27
28
|
import type {
|
|
28
29
|
BuiltinToolRef,
|
|
@@ -582,6 +583,17 @@ async function applyBashSandbox(
|
|
|
582
583
|
// bwrap does --clearenv, so the overlay must be re-introduced via env.set or
|
|
583
584
|
// it would never reach the sandboxed process (the non-sandboxed spawnHook
|
|
584
585
|
// path does not run when the command is rewritten to a bwrap invocation).
|
|
586
|
+
// 'real-proc' gives a sandboxed JS package runner a working /proc/self/{fd,
|
|
587
|
+
// maps} so `bunx`/`bun add`/`bun run <pkg>` stop aborting with Bun's NotDir.
|
|
588
|
+
// Opt-in (default 'tmpfs') because it makes start.ts grant the container
|
|
589
|
+
// CAP_SYS_ADMIN at boot. Read from the boot-time `config` snapshot, NOT live
|
|
590
|
+
// getConfig(): sandbox.realProc is restart-required, and the strategy MUST
|
|
591
|
+
// track the boot-time capability. A `typeclaw reload` that flips realProc to
|
|
592
|
+
// true would otherwise make this emit `unshare --mount-proc` in a container
|
|
593
|
+
// booted WITHOUT CAP_SYS_ADMIN, so the mount fails instead of the old tmpfs
|
|
594
|
+
// strategy holding until restart. `config` never changes on reload.
|
|
595
|
+
// procSelfExe is only consumed by the 'tmpfs' branch.
|
|
596
|
+
const realProc = config.sandbox.realProc
|
|
585
597
|
const { commandString } = buildSandboxedCommand(command, {
|
|
586
598
|
mounts: [
|
|
587
599
|
{ type: 'ro-bind', source: agentDir, dest: agentDir },
|
|
@@ -592,6 +604,7 @@ async function applyBashSandbox(
|
|
|
592
604
|
protected: protectedZones,
|
|
593
605
|
network: 'inherit',
|
|
594
606
|
cwd: agentDir,
|
|
607
|
+
proc: realProc ? 'real-proc' : 'tmpfs',
|
|
595
608
|
procSelfExe: resolveProcSelfExe(),
|
|
596
609
|
...(envOverlay !== undefined ? { env: { set: envOverlay } } : {}),
|
|
597
610
|
})
|
|
@@ -93,7 +93,7 @@ Delegate focused work to subagents via \`spawn_subagent\`, \`subagent_output\`,
|
|
|
93
93
|
|
|
94
94
|
There are three delegation modes. Pick deliberately.
|
|
95
95
|
|
|
96
|
-
**Mode A — Research fan-out.** Need information and the search is broad? Fire 2-5 subagents (usually \`explorer\`/\`scout\`) in parallel with \`run_in_background: true\`, then end your response. A \`<system-reminder>\` lands per completion; call \`subagent_output\` once per task_id to collect (it never blocks) and answer. Match the worker to the depth: a fast or narrow web lookup goes to \`scout\`; a fuzzy question that needs decomposition, many sources, cross-validation, and a synthesized verdict goes to \`researcher\` (don't do that grind inline with \`web_search\` yourself).
|
|
96
|
+
**Mode A — Research fan-out.** Need information and the search is broad? Fire 2-5 subagents (usually \`explorer\`/\`scout\`) in parallel with \`run_in_background: true\`, then end your response. A \`<system-reminder>\` lands per completion; call \`subagent_output\` once per task_id to collect (it never blocks) and answer. Match the worker to the depth: a fast or narrow web lookup goes to \`scout\`; a fuzzy question that needs decomposition, many sources, cross-validation, and a synthesized verdict goes to \`researcher\` (don't do that grind inline with \`web_search\` yourself). When the user *explicitly* says "research"/"investigate" (or equivalent), you MUST spawn \`researcher\` — answering from training memory or a single inline \`web_search\` does not satisfy the request, even if you think you know the answer. (Fanning out \`scout\`/\`explorer\` underneath is fine, but it does not replace \`researcher\`.)
|
|
97
97
|
|
|
98
98
|
**Mode B — Delegate-and-converse.** Asked to DO something long-running (>~30s: installs, builds, \`docker\`, scrapes, long test suites, multi-host loops, any noisy "fetch N and synthesize" chain)? Don't run it inline — blocking your own \`bash\` freezes the conversation and stalls the channel typing heartbeat (\`MAX_TYPING_HEARTBEAT_MS\`). Spawn one subagent (\`operator\` for side effects, \`scout\` for a quick web lookup, \`researcher\` for a deep multi-source "fetch N and synthesize" investigation, \`planner\` when a multi-step goal needs a sequenced, risk-aware plan before anyone acts) with \`run_in_background: true\`, acknowledge, and KEEP TALKING. Single fast calls (\`git status\`, one known-endpoint \`curl\`) stay inline. When the completion reminder lands, weave the result in; in a channel session, the completion \`<system-reminder>\` is NOT a user message but plain text is still invisible — Surface the result via \`channel_reply\` (or \`channel_send\`). If you already posted the substantive answer in the spawn turn, prefer \`skip_response({ reason: "result confirms prior reply" })\` over going silent.
|
|
99
99
|
|
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import type { ReviewVerdict } from '@/channels/github-review-turn-ledger'
|
|
2
2
|
|
|
3
|
+
// `NONE` covers "never reviewed" and "last decisive review was DISMISSED" — both
|
|
4
|
+
// mean a fresh verdict is legitimate (not a duplicate).
|
|
5
|
+
export type EffectiveVerdict = 'APPROVED' | 'CHANGES_REQUESTED' | 'NONE'
|
|
6
|
+
|
|
3
7
|
export type EffectiveApprovalResolver = (target: {
|
|
4
8
|
workspace: string
|
|
5
9
|
prNumber: number
|
|
6
|
-
}) => Promise<{ ok: true;
|
|
10
|
+
}) => Promise<{ ok: true; effective: EffectiveVerdict } | { ok: false }>
|
|
7
11
|
|
|
8
12
|
export type ApproveBlock = { block: true; reason: string }
|
|
9
13
|
|
|
10
|
-
export type
|
|
14
|
+
export type ReviewVerdictGuard = {
|
|
11
15
|
guard: (args: {
|
|
12
16
|
callId: string
|
|
13
17
|
workspace: string
|
|
@@ -17,78 +21,135 @@ export type ApproveIdempotencyGuard = {
|
|
|
17
21
|
release: (args: { callId: string; succeeded: boolean }) => void
|
|
18
22
|
}
|
|
19
23
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
24
|
+
// Back-compat alias: the guard now covers REQUEST_CHANGES too, not just APPROVE.
|
|
25
|
+
export type ApproveIdempotencyGuard = ReviewVerdictGuard
|
|
26
|
+
|
|
27
|
+
function duplicateReason(verdict: ReviewVerdict): string {
|
|
28
|
+
if (verdict === 'APPROVE') {
|
|
29
|
+
return (
|
|
30
|
+
'This bot already holds a standing APPROVED review on this pull request. A second APPROVE would ' +
|
|
31
|
+
'post a redundant review. If you intended to change your verdict, request changes or dismiss the ' +
|
|
32
|
+
'prior review instead of re-approving.'
|
|
33
|
+
)
|
|
34
|
+
}
|
|
35
|
+
return (
|
|
36
|
+
'This bot already holds a standing CHANGES_REQUESTED review on this pull request. A second ' +
|
|
37
|
+
'REQUEST_CHANGES would post a redundant blocking review. The prior review is still live — push a fix ' +
|
|
38
|
+
'and APPROVE, or reply in the existing thread, instead of re-requesting changes.'
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const CONCURRENT_REASON =
|
|
43
|
+
'Another session in this agent is already submitting a formal review verdict for this pull request. ' +
|
|
44
|
+
'Only one verdict may land per PR — do not submit a second review; the in-flight one will post.'
|
|
45
|
+
|
|
46
|
+
// The standing verdict a fresh attempt would duplicate. APPROVE duplicates a
|
|
47
|
+
// standing APPROVED; REQUEST_CHANGES duplicates a standing CHANGES_REQUESTED.
|
|
48
|
+
function duplicatesStanding(verdict: ReviewVerdict, effective: EffectiveVerdict): boolean {
|
|
49
|
+
return verdict === 'APPROVE' ? effective === 'APPROVED' : effective === 'CHANGES_REQUESTED'
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// How long a reservation may sit before it is treated as abandoned. A normal
|
|
53
|
+
// `gh` review submit completes in seconds; this only guards against a tool.after
|
|
54
|
+
// that never fires (crash mid-command), so it must outlast a slow command yet
|
|
55
|
+
// never strand a PR for long.
|
|
56
|
+
const LEASE_TTL_MS = 5 * 60_000
|
|
57
|
+
|
|
58
|
+
type Reservation = { key: string; token: number; createdAt: number }
|
|
23
59
|
|
|
24
|
-
//
|
|
25
|
-
//
|
|
60
|
+
// MODULE-LEVEL singletons, shared by every plugin instance in this process. The
|
|
61
|
+
// github-cli-auth plugin's `plugin: async (ctx) => ...` factory may run once per
|
|
62
|
+
// session, giving each its own closure — but all of those closures import THIS
|
|
63
|
+
// module, so they coordinate through one Map. A closure-local Set (the prior
|
|
64
|
+
// design) could not see a concurrent session's in-flight verdict, which is how
|
|
65
|
+
// three sessions each landed an APPROVE on the same PR within ten seconds.
|
|
66
|
+
const inFlightByPr = new Map<string, Reservation>()
|
|
67
|
+
const reservationByCall = new Map<string, Reservation>()
|
|
68
|
+
let tokenSeq = 0
|
|
69
|
+
|
|
70
|
+
// Makes a formal `gh ... event=APPROVE|REQUEST_CHANGES` idempotent per PR across
|
|
71
|
+
// turns, sessions, and (in-process) concurrent fan-out. Two layers:
|
|
72
|
+
//
|
|
73
|
+
// 1. A process-wide in-flight lease keyed by `workspace#prNumber`, held from
|
|
74
|
+
// tool.before through tool.after. While one verdict is mid-flight, every
|
|
75
|
+
// other session's verdict for the same PR is blocked — even though GitHub
|
|
76
|
+
// has not yet recorded the in-flight review. This is the layer the old
|
|
77
|
+
// closure-local Set could not provide: separate plugin instances meant
|
|
78
|
+
// separate Sets, so concurrent sessions never saw each other.
|
|
26
79
|
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
// supersession: a later CHANGES_REQUESTED / DISMISSED demotes an earlier
|
|
34
|
-
// APPROVED, so the bot may legitimately re-approve.
|
|
80
|
+
// 2. The authoritative GitHub effective-state read, consulted AFTER the lease
|
|
81
|
+
// is acquired. It catches the cross-restart case (lease lost) and tracks
|
|
82
|
+
// supersession: a later CHANGES_REQUESTED/DISMISSED demotes an earlier
|
|
83
|
+
// APPROVED, so a genuine re-verdict is allowed. Reads fail OPEN — a
|
|
84
|
+
// transient error must never strand a genuine first verdict; the lease
|
|
85
|
+
// still covers the concurrent case while the command runs.
|
|
35
86
|
//
|
|
36
|
-
// The
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
// (PR back to CHANGES_REQUESTED), a stale local lock must not keep blocking a
|
|
41
|
-
// genuine re-approve — only the remote read decides, and it now reports
|
|
42
|
-
// alreadyApproved=false. Reads fail OPEN: a transient GitHub error must never
|
|
43
|
-
// permanently strand a first approval; the in-flight reservation still covers
|
|
44
|
-
// the concurrent case.
|
|
87
|
+
// The lease is released only in release() (tool.after) or on a terminal block,
|
|
88
|
+
// never after the remote read — releasing early reopens the TOCTOU the lease
|
|
89
|
+
// exists to close. Release is keyed by a per-call token so a late/stale
|
|
90
|
+
// tool.after for a superseded reservation cannot drop a newer session's lease.
|
|
45
91
|
export function createApproveIdempotencyGuard(deps: {
|
|
46
92
|
resolveEffectiveApproval: EffectiveApprovalResolver
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
const
|
|
93
|
+
now?: () => number
|
|
94
|
+
}): ReviewVerdictGuard {
|
|
95
|
+
const now = deps.now ?? Date.now
|
|
50
96
|
|
|
51
97
|
return {
|
|
52
98
|
async guard(args): Promise<ApproveBlock | null> {
|
|
53
|
-
if (args.verdict !== 'APPROVE') return null
|
|
99
|
+
if (args.verdict !== 'APPROVE' && args.verdict !== 'REQUEST_CHANGES') return null
|
|
54
100
|
const key = prKey(args.workspace, args.prNumber)
|
|
55
101
|
|
|
56
|
-
// Reserve BEFORE the await so two calls racing into guard() for the same
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
102
|
+
// Reserve BEFORE the await so two calls racing into guard() for the same PR
|
|
103
|
+
// cannot both observe an empty map: the loser sees the winner's in-flight
|
|
104
|
+
// lease and is blocked. An expired lease (tool.after never fired) is
|
|
105
|
+
// reclaimable so a crash cannot permanently strand the PR.
|
|
106
|
+
const held = inFlightByPr.get(key)
|
|
107
|
+
if (held !== undefined && now() - held.createdAt < LEASE_TTL_MS) {
|
|
108
|
+
return { block: true, reason: CONCURRENT_REASON }
|
|
109
|
+
}
|
|
110
|
+
const reservation: Reservation = { key, token: ++tokenSeq, createdAt: now() }
|
|
111
|
+
inFlightByPr.set(key, reservation)
|
|
112
|
+
reservationByCall.set(args.callId, reservation)
|
|
63
113
|
|
|
64
114
|
const remote = await deps.resolveEffectiveApproval({ workspace: args.workspace, prNumber: args.prNumber })
|
|
65
|
-
if (remote.ok && remote.
|
|
66
|
-
// Standing
|
|
67
|
-
// a blocked command never reaches tool.after, so release() won't run
|
|
68
|
-
// this callId. Leaving the
|
|
69
|
-
// GitHub read is authoritative for the standing
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
pendingApprovals.delete(key)
|
|
73
|
-
return { block: true, reason: DUPLICATE_REASON }
|
|
115
|
+
if (remote.ok && duplicatesStanding(args.verdict, remote.effective)) {
|
|
116
|
+
// Standing verdict upstream already matches. Block, and release the lease
|
|
117
|
+
// now: a blocked command never reaches tool.after, so release() won't run
|
|
118
|
+
// for this callId. Leaving the lease set would resurrect the strand bug —
|
|
119
|
+
// the GitHub read is authoritative for the standing case.
|
|
120
|
+
releaseReservation(args.callId, reservation)
|
|
121
|
+
return { block: true, reason: duplicateReason(args.verdict) }
|
|
74
122
|
}
|
|
75
123
|
|
|
76
124
|
return null
|
|
77
125
|
},
|
|
78
126
|
|
|
79
127
|
release(args): void {
|
|
80
|
-
const
|
|
81
|
-
if (
|
|
82
|
-
|
|
83
|
-
// Always drop the in-flight lock, success or fail. On success the standing
|
|
84
|
-
// approval now lives on GitHub, so future APPROVEs are caught by the remote
|
|
85
|
-
// read (which tracks supersession); the local lock must not outlive the
|
|
86
|
-
// in-flight window and shadow that read.
|
|
87
|
-
pendingApprovals.delete(key)
|
|
128
|
+
const reservation = reservationByCall.get(args.callId)
|
|
129
|
+
if (reservation === undefined) return
|
|
130
|
+
releaseReservation(args.callId, reservation)
|
|
88
131
|
},
|
|
89
132
|
}
|
|
90
133
|
}
|
|
91
134
|
|
|
135
|
+
// Drop the lease only if THIS reservation still owns the key. A stale tool.after
|
|
136
|
+
// for a reservation that was already superseded (e.g. reclaimed after TTL by a
|
|
137
|
+
// newer session) must not yank the live session's lease.
|
|
138
|
+
function releaseReservation(callId: string, reservation: Reservation): void {
|
|
139
|
+
reservationByCall.delete(callId)
|
|
140
|
+
const current = inFlightByPr.get(reservation.key)
|
|
141
|
+
if (current !== undefined && current.token === reservation.token) {
|
|
142
|
+
inFlightByPr.delete(reservation.key)
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
92
146
|
function prKey(workspace: string, prNumber: number): string {
|
|
93
147
|
return `${workspace}#${prNumber}`
|
|
94
148
|
}
|
|
149
|
+
|
|
150
|
+
// Test-only: clear the process-wide lease state between cases.
|
|
151
|
+
export function __resetReviewVerdictGuardForTest(): void {
|
|
152
|
+
inFlightByPr.clear()
|
|
153
|
+
reservationByCall.clear()
|
|
154
|
+
tokenSeq = 0
|
|
155
|
+
}
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import { GITHUB_API_BASE, githubJsonHeaders } from '@/channels/adapters/github/auth-pat'
|
|
2
2
|
|
|
3
|
-
import type { EffectiveApprovalResolver } from './approve-idempotency'
|
|
4
|
-
|
|
5
|
-
// Resolves
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// first approval.
|
|
3
|
+
import type { EffectiveApprovalResolver, EffectiveVerdict } from './approve-idempotency'
|
|
4
|
+
|
|
5
|
+
// Resolves THIS bot's standing decisive review on a PR, used by the review
|
|
6
|
+
// verdict guard to stop a second formal verdict after a restart (the in-process
|
|
7
|
+
// lease covers the same-container case but is lost when the container bounces).
|
|
8
|
+
// Every failure returns { ok: false } so the guard fails open — a transient read
|
|
9
|
+
// error must never permanently block a genuine first verdict.
|
|
11
10
|
export function createGithubEffectiveApprovalResolver(deps: {
|
|
12
11
|
resolveToken: (workspace: string) => Promise<string | null>
|
|
13
12
|
fetchImpl?: typeof fetch
|
|
@@ -27,10 +26,16 @@ export function createGithubEffectiveApprovalResolver(deps: {
|
|
|
27
26
|
if (reviews === null) return { ok: false }
|
|
28
27
|
|
|
29
28
|
const lastDecisive = reviews.filter((r) => isSelf(r.login, r.isBot, self) && isDecisive(r.state)).at(-1)
|
|
30
|
-
return { ok: true,
|
|
29
|
+
return { ok: true, effective: toEffective(lastDecisive?.state) }
|
|
31
30
|
}
|
|
32
31
|
}
|
|
33
32
|
|
|
33
|
+
function toEffective(state: string | undefined): EffectiveVerdict {
|
|
34
|
+
if (state === 'APPROVED') return 'APPROVED'
|
|
35
|
+
if (state === 'CHANGES_REQUESTED') return 'CHANGES_REQUESTED'
|
|
36
|
+
return 'NONE'
|
|
37
|
+
}
|
|
38
|
+
|
|
34
39
|
// A bot's effective review is its LATEST decisive one. COMMENTED/PENDING are
|
|
35
40
|
// non-deciding noise that must not clear an earlier APPROVED/CHANGES_REQUESTED;
|
|
36
41
|
// a later CHANGES_REQUESTED or DISMISSED supersedes an earlier APPROVED. The
|
|
@@ -11,7 +11,7 @@ import { classifyGhToken } from './token-class'
|
|
|
11
11
|
export default definePlugin({
|
|
12
12
|
plugin: async (ctx) => {
|
|
13
13
|
const resolveTokenForRepo = ctx.github.resolveTokenForRepo
|
|
14
|
-
const
|
|
14
|
+
const verdictGuard = createApproveIdempotencyGuard({
|
|
15
15
|
resolveEffectiveApproval: createGithubEffectiveApprovalResolver({
|
|
16
16
|
resolveToken: async (workspace) => {
|
|
17
17
|
const result = await resolveTokenForRepo(workspace)
|
|
@@ -28,7 +28,7 @@ export default definePlugin({
|
|
|
28
28
|
|
|
29
29
|
const review = await noteReviewCommand({ callId: event.callId, command })
|
|
30
30
|
if (review.detected !== null) {
|
|
31
|
-
const block = await
|
|
31
|
+
const block = await verdictGuard.guard({
|
|
32
32
|
callId: event.callId,
|
|
33
33
|
workspace: review.detected.workspace,
|
|
34
34
|
prNumber: review.detected.prNumber,
|
|
@@ -70,7 +70,7 @@ export default definePlugin({
|
|
|
70
70
|
callId: event.callId,
|
|
71
71
|
result: event.result,
|
|
72
72
|
})
|
|
73
|
-
|
|
73
|
+
verdictGuard.release({ callId: event.callId, succeeded: committed })
|
|
74
74
|
},
|
|
75
75
|
},
|
|
76
76
|
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Discord renders no GitHub-flavored Markdown tables — a `| a | b |` block
|
|
2
|
+
// shows up as literal pipes and dashes, so an agent reply that leans on a table
|
|
3
|
+
// (very common) becomes unreadable. Discord DOES preserve whitespace verbatim
|
|
4
|
+
// inside inline code spans, so we re-emit each table row as a single
|
|
5
|
+
// backtick-wrapped line with columns padded to a fixed width. Columns line up
|
|
6
|
+
// because every row is the same monospaced inline-code span. The header row is
|
|
7
|
+
// additionally wrapped in `**...**` so it reads as a bold caption above the body.
|
|
8
|
+
//
|
|
9
|
+
// This is a line-walker, not a Markdown parser: it only touches blocks that
|
|
10
|
+
// match the pipe-table shape (a `|`-bearing line followed by a `|---|` alignment
|
|
11
|
+
// row) and leaves every other byte — prose, code fences, lists — untouched.
|
|
12
|
+
|
|
13
|
+
const TABLE_SEP_RE = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$/
|
|
14
|
+
const FENCE_RE = /^(\s*)(```+|~~~+)(.*)$/
|
|
15
|
+
|
|
16
|
+
export function convertDiscordTables(input: string): string {
|
|
17
|
+
if (input === '') return ''
|
|
18
|
+
if (!input.includes('|')) return input
|
|
19
|
+
|
|
20
|
+
const lines = input.split('\n')
|
|
21
|
+
const out: string[] = []
|
|
22
|
+
let i = 0
|
|
23
|
+
let openFence: string | null = null
|
|
24
|
+
|
|
25
|
+
while (i < lines.length) {
|
|
26
|
+
const line = lines[i]!
|
|
27
|
+
|
|
28
|
+
// A code fence (``` / ~~~) suspends table detection until it closes — a
|
|
29
|
+
// table-shaped block inside a fence is literal text, not a table. The close
|
|
30
|
+
// must use the same fence char and be at least as long as the opener, per
|
|
31
|
+
// CommonMark.
|
|
32
|
+
const fence = FENCE_RE.exec(line)
|
|
33
|
+
if (fence !== null) {
|
|
34
|
+
const marker = fence[2]!
|
|
35
|
+
if (openFence === null) {
|
|
36
|
+
openFence = marker
|
|
37
|
+
} else if (marker[0] === openFence[0] && marker.length >= openFence.length) {
|
|
38
|
+
openFence = null
|
|
39
|
+
}
|
|
40
|
+
out.push(line)
|
|
41
|
+
i++
|
|
42
|
+
continue
|
|
43
|
+
}
|
|
44
|
+
if (openFence !== null) {
|
|
45
|
+
out.push(line)
|
|
46
|
+
i++
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// A table needs a `|`-bearing header line immediately followed by the
|
|
51
|
+
// alignment row; same disambiguation rule chunkMarkdown uses so a stray
|
|
52
|
+
// leading `|` in prose is not mistaken for a table.
|
|
53
|
+
if (line.includes('|') && i + 1 < lines.length && TABLE_SEP_RE.test(lines[i + 1]!)) {
|
|
54
|
+
const start = i
|
|
55
|
+
i += 2
|
|
56
|
+
while (i < lines.length && lines[i]!.includes('|') && lines[i]!.trim() !== '') {
|
|
57
|
+
i++
|
|
58
|
+
}
|
|
59
|
+
const tableLines = lines.slice(start, i)
|
|
60
|
+
out.push(renderTable(tableLines))
|
|
61
|
+
continue
|
|
62
|
+
}
|
|
63
|
+
out.push(line)
|
|
64
|
+
i++
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return out.join('\n')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function renderTable(tableLines: string[]): string {
|
|
71
|
+
const headerCells = splitRow(tableLines[0]!)
|
|
72
|
+
const bodyRows = tableLines.slice(2).map(splitRow)
|
|
73
|
+
const widths = computeWidths([headerCells, ...bodyRows])
|
|
74
|
+
|
|
75
|
+
const header = wrapCode(padRow(headerCells, widths))
|
|
76
|
+
const renderedRows = [`**${header}**`, ...bodyRows.map((cells) => wrapCode(padRow(cells, widths)))]
|
|
77
|
+
return renderedRows.join('\n')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function splitRow(row: string): string[] {
|
|
81
|
+
// Trim one optional leading/trailing pipe, then split on the rest. A trailing
|
|
82
|
+
// backslash before a pipe escapes it, but GFM table escaping is rare in agent
|
|
83
|
+
// output — we keep it simple and split on bare pipes.
|
|
84
|
+
let trimmed = row.trim()
|
|
85
|
+
if (trimmed.startsWith('|')) trimmed = trimmed.slice(1)
|
|
86
|
+
if (trimmed.endsWith('|')) trimmed = trimmed.slice(0, -1)
|
|
87
|
+
return trimmed.split('|').map((cell) => cell.trim())
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function computeWidths(rows: string[][]): number[] {
|
|
91
|
+
const widths: number[] = []
|
|
92
|
+
for (const row of rows) {
|
|
93
|
+
for (let c = 0; c < row.length; c++) {
|
|
94
|
+
const cellWidth = displayWidth(row[c]!)
|
|
95
|
+
if (widths[c] === undefined || cellWidth > widths[c]!) {
|
|
96
|
+
widths[c] = cellWidth
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return widths
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function padRow(cells: string[], widths: number[]): string {
|
|
104
|
+
const padded = widths.map((width, c) => padToWidth(cells[c] ?? '', width))
|
|
105
|
+
// Two spaces between columns keeps them visually distinct inside the
|
|
106
|
+
// monospaced span without a vertical-bar separator.
|
|
107
|
+
return padded.join(' ')
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function padToWidth(cell: string, width: number): string {
|
|
111
|
+
const pad = width - displayWidth(cell)
|
|
112
|
+
return pad > 0 ? cell + ' '.repeat(pad) : cell
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Discord's monospaced inline-code font renders CJK ideographs, full-width
|
|
116
|
+
// punctuation, and most emoji at two columns, while combining/zero-width marks
|
|
117
|
+
// take none. `String.prototype.padEnd` counts UTF-16 code units, so padding by
|
|
118
|
+
// `.length` leaves wide-character tables visually ragged. We iterate by code
|
|
119
|
+
// point and sum per-glyph column widths so every cell pads to the same VISUAL
|
|
120
|
+
// width. The ranges below are the standard East-Asian-Wide / Wide blocks plus
|
|
121
|
+
// the common emoji planes; this is the same wcwidth approximation editors use.
|
|
122
|
+
export function displayWidth(text: string): number {
|
|
123
|
+
let width = 0
|
|
124
|
+
for (const ch of text) {
|
|
125
|
+
width += charWidth(ch.codePointAt(0)!)
|
|
126
|
+
}
|
|
127
|
+
return width
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function charWidth(cp: number): number {
|
|
131
|
+
if (isZeroWidth(cp)) return 0
|
|
132
|
+
if (isWide(cp)) return 2
|
|
133
|
+
return 1
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function isZeroWidth(cp: number): boolean {
|
|
137
|
+
return (
|
|
138
|
+
cp === 0x200b || // zero-width space
|
|
139
|
+
(cp >= 0x0300 && cp <= 0x036f) || // combining diacritical marks
|
|
140
|
+
(cp >= 0x200c && cp <= 0x200f) || // ZWNJ/ZWJ/directional marks
|
|
141
|
+
(cp >= 0xfe00 && cp <= 0xfe0f) // variation selectors
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isWide(cp: number): boolean {
|
|
146
|
+
return (
|
|
147
|
+
(cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
|
|
148
|
+
(cp >= 0x2e80 && cp <= 0x303e) || // CJK radicals, Kangxi
|
|
149
|
+
(cp >= 0x3041 && cp <= 0x33ff) || // Hiragana, Katakana, CJK symbols
|
|
150
|
+
(cp >= 0x3400 && cp <= 0x4dbf) || // CJK Ext A
|
|
151
|
+
(cp >= 0x4e00 && cp <= 0x9fff) || // CJK Unified Ideographs
|
|
152
|
+
(cp >= 0xa000 && cp <= 0xa4cf) || // Yi
|
|
153
|
+
(cp >= 0xac00 && cp <= 0xd7a3) || // Hangul Syllables
|
|
154
|
+
(cp >= 0xf900 && cp <= 0xfaff) || // CJK Compatibility Ideographs
|
|
155
|
+
(cp >= 0xfe30 && cp <= 0xfe4f) || // CJK Compatibility Forms
|
|
156
|
+
(cp >= 0xff00 && cp <= 0xff60) || // Fullwidth Forms
|
|
157
|
+
(cp >= 0xffe0 && cp <= 0xffe6) || // Fullwidth signs
|
|
158
|
+
(cp >= 0x2600 && cp <= 0x26ff) || // Miscellaneous Symbols (☀ ♻ ⚠ …)
|
|
159
|
+
(cp >= 0x2700 && cp <= 0x27bf) || // Dingbats (✅ ✔ ✨ ➡ …)
|
|
160
|
+
(cp >= 0x2b00 && cp <= 0x2bff) || // Misc Symbols and Arrows (⭐ …)
|
|
161
|
+
(cp >= 0x1f300 && cp <= 0x1faff) || // emoji, symbols, pictographs
|
|
162
|
+
(cp >= 0x20000 && cp <= 0x3fffd) // CJK Ext B+ (supplementary ideographic)
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// CommonMark inline code: the delimiter must be a backtick run LONGER than any
|
|
167
|
+
// run inside the content, otherwise an embedded `` ` `` (e.g. a cell holding
|
|
168
|
+
// `bun test`) closes the span early and corrupts the row. When the content
|
|
169
|
+
// begins or ends with a backtick, one space of padding is inserted on each side
|
|
170
|
+
// so the delimiter is not adjacent to a content backtick; CommonMark strips that
|
|
171
|
+
// single padding space on render, leaving our column widths intact.
|
|
172
|
+
function wrapCode(text: string): string {
|
|
173
|
+
const fence = '`'.repeat(longestBacktickRun(text) + 1)
|
|
174
|
+
const needsPad = text.startsWith('`') || text.endsWith('`')
|
|
175
|
+
const pad = needsPad ? ' ' : ''
|
|
176
|
+
return `${fence}${pad}${text}${pad}${fence}`
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function longestBacktickRun(text: string): number {
|
|
180
|
+
let longest = 0
|
|
181
|
+
let run = 0
|
|
182
|
+
for (const ch of text) {
|
|
183
|
+
if (ch === '`') {
|
|
184
|
+
run++
|
|
185
|
+
if (run > longest) longest = run
|
|
186
|
+
} else {
|
|
187
|
+
run = 0
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return longest
|
|
191
|
+
}
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
type InboundDropReason,
|
|
40
40
|
renderPlaceholder,
|
|
41
41
|
} from './discord-bot-classify'
|
|
42
|
+
import { convertDiscordTables } from './discord-bot-format'
|
|
42
43
|
import { createDiscordReactionCallback, createDiscordRemoveReactionCallback } from './discord-bot-reactions'
|
|
43
44
|
import { enrichDiscordMessageReferences } from './discord-bot-reference'
|
|
44
45
|
import {
|
|
@@ -647,7 +648,7 @@ export function createOutboundCallback(deps: {
|
|
|
647
648
|
if (msg.adapter !== 'discord-bot') {
|
|
648
649
|
return { ok: false, error: `unknown adapter: ${msg.adapter}` }
|
|
649
650
|
}
|
|
650
|
-
const text = msg.text ?? ''
|
|
651
|
+
const text = convertDiscordTables(msg.text ?? '')
|
|
651
652
|
const attachments = msg.attachments ?? []
|
|
652
653
|
if (text === '' && attachments.length === 0) {
|
|
653
654
|
return { ok: false, error: 'message has neither text nor attachments' }
|