switchroom 0.14.8 → 0.14.10
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/dist/agent-scheduler/index.js +160 -157
- package/dist/auth-broker/index.js +81 -80
- package/dist/cli/drive-write-pretool.mjs +10 -10
- package/dist/cli/notion-write-pretool.mjs +83 -82
- package/dist/cli/skill-validate-pretool.mjs +72 -72
- package/dist/cli/switchroom.js +455 -365
- package/dist/host-control/main.js +149 -148
- package/dist/vault/approvals/kernel-server.js +83 -82
- package/dist/vault/broker/server.js +84 -83
- package/package.json +3 -3
- package/telegram-plugin/dist/bridge/bridge.js +112 -112
- package/telegram-plugin/dist/gateway/gateway.js +1217 -599
- package/telegram-plugin/dist/server.js +160 -160
- package/telegram-plugin/gateway/gateway.ts +126 -97
- package/telegram-plugin/gateway/webhook-ingest-server.test.ts +125 -0
- package/telegram-plugin/gateway/webhook-ingest-server.ts +218 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +0 -216
- package/telegram-plugin/tool-activity-summary.ts +18 -197
|
@@ -53,14 +53,7 @@ import { OutboundDedupCache } from '../recent-outbound-dedup.js'
|
|
|
53
53
|
import { createInboundCoalescer, inboundCoalesceKey } from './inbound-coalesce.js'
|
|
54
54
|
import { StatusReactionController } from '../status-reactions.js'
|
|
55
55
|
import { isTelegramReplyTool, isTelegramSurfaceTool } from '../tool-names.js'
|
|
56
|
-
import {
|
|
57
|
-
makeEmptyActivityState,
|
|
58
|
-
registerAndRender,
|
|
59
|
-
describeToolUse,
|
|
60
|
-
appendActivityLine,
|
|
61
|
-
appendActivityLabel,
|
|
62
|
-
type ActivityState,
|
|
63
|
-
} from '../tool-activity-summary.js'
|
|
56
|
+
import { appendActivityLabel } from '../tool-activity-summary.js'
|
|
64
57
|
import { toolLabel } from '../tool-labels.js'
|
|
65
58
|
import { createTypingWrapper } from '../typing-wrap.js'
|
|
66
59
|
import { type DraftStreamHandle } from '../draft-stream.js'
|
|
@@ -270,6 +263,8 @@ import { formatUpdateStatusLine } from './update-status-line.js'
|
|
|
270
263
|
import type { HostdRequest } from '../../src/host-control/protocol.js'
|
|
271
264
|
import type { AgentAudit } from '../welcome-text.js'
|
|
272
265
|
import { shouldSweepChatAtBoot } from './boot-sweep-filter.js'
|
|
266
|
+
import { startWebhookIngestServer } from './webhook-ingest-server.js'
|
|
267
|
+
import { recordWebhookEvent } from '../../src/web/webhook-gateway-record.js'
|
|
273
268
|
|
|
274
269
|
import { createIpcServer, type IpcClient, type IpcServer } from './ipc-server.js'
|
|
275
270
|
import { handleRequestDriveApproval } from './drive-write-approval.js'
|
|
@@ -1352,16 +1347,14 @@ type CurrentTurn = {
|
|
|
1352
1347
|
// repeats until the pending matches the last-sent.
|
|
1353
1348
|
// Result: at most one Telegram call in flight at a time; the
|
|
1354
1349
|
// final state always lands.
|
|
1355
|
-
toolActivity: ActivityState
|
|
1356
1350
|
activityMessageId: number | null
|
|
1357
1351
|
activityInFlight: Promise<void> | null
|
|
1358
1352
|
activityPendingRender: string | null
|
|
1359
1353
|
activityLastSentRender: string | null
|
|
1360
|
-
// Accumulating friendly-action feed for this turn
|
|
1361
|
-
//
|
|
1362
|
-
//
|
|
1363
|
-
//
|
|
1364
|
-
// per turn.
|
|
1354
|
+
// Accumulating friendly-action feed for this turn. Each non-surface
|
|
1355
|
+
// tool_label appends a line via `appendActivityLabel`; the feed renders
|
|
1356
|
+
// (via `renderActivityFeed`) as a capped chronological list into the
|
|
1357
|
+
// in-place edited activity message and clears on reply. Reset per turn.
|
|
1365
1358
|
mirrorLines: string[]
|
|
1366
1359
|
// Issue #195 — answer-lane streaming. Lazily created on the first text
|
|
1367
1360
|
// event of a turn (once enough text has accumulated, the stream itself
|
|
@@ -3254,25 +3247,16 @@ const ANSWER_STREAM_VISIBLE_ENABLED = (() => {
|
|
|
3254
3247
|
return true
|
|
3255
3248
|
})()
|
|
3256
3249
|
|
|
3257
|
-
// Activity
|
|
3258
|
-
//
|
|
3259
|
-
//
|
|
3260
|
-
//
|
|
3261
|
-
//
|
|
3262
|
-
//
|
|
3263
|
-
//
|
|
3264
|
-
// the
|
|
3265
|
-
//
|
|
3266
|
-
//
|
|
3267
|
-
// historical: an earlier design mirrored into the compose-area draft; the feed
|
|
3268
|
-
// is now a normal edited message.) Default OFF (canary). Kill switch:
|
|
3269
|
-
// SWITCHROOM_DRAFT_MIRROR unset/0/false/off/no.
|
|
3270
|
-
const DRAFT_MIRROR_ENABLED = (() => {
|
|
3271
|
-
const raw = process.env.SWITCHROOM_DRAFT_MIRROR
|
|
3272
|
-
if (raw == null) return false
|
|
3273
|
-
const v = raw.trim().toLowerCase()
|
|
3274
|
-
return !(v === '0' || v === 'false' || v === 'off' || v === 'no')
|
|
3275
|
-
})()
|
|
3250
|
+
// Activity feed. The gateway streams a live "what it's doing" tool-activity
|
|
3251
|
+
// feed for every turn. The PreToolUse sidecar emits a `tool_label` per tool
|
|
3252
|
+
// call (flush-independent, so it stays real-time on fast/clustered-tool
|
|
3253
|
+
// turns); each label appends to `turn.mirrorLines`, and `renderActivityFeed`
|
|
3254
|
+
// renders the capped list into an in-place EDITED message (sendMessage +
|
|
3255
|
+
// editMessageText) anchored as a native reply-quote to the user's question.
|
|
3256
|
+
// The feed clears on the first reply (hand-off to the answer) and again at
|
|
3257
|
+
// turn_end (the no-reply safety net). It does NOT touch the answer-stream's
|
|
3258
|
+
// draft/visible lane — the two render on separate surfaces, so they never
|
|
3259
|
+
// collide.
|
|
3276
3260
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
3277
3261
|
const progressDriver: any = null
|
|
3278
3262
|
const unpinProgressCardForChat: ((chatId: string, threadId: number | undefined) => void) | null = null
|
|
@@ -4670,6 +4654,89 @@ const ipcServer: IpcServer = createIpcServer({
|
|
|
4670
4654
|
log: (msg) => process.stderr.write(`telegram gateway: ipc — ${msg}\n`),
|
|
4671
4655
|
})
|
|
4672
4656
|
|
|
4657
|
+
// ─── Webhook ingest server (RFC webhook-via-gateway-socket) ───────────────
|
|
4658
|
+
// Under the Docker runtime the host-side web receiver runs as the operator
|
|
4659
|
+
// UID and cannot write this agent's UID-owned dir (EACCES 500) nor connect
|
|
4660
|
+
// gateway.sock. When `channels.telegram.webhook_via_gateway` is set, the
|
|
4661
|
+
// receiver instead forwards verified+rendered events here over a dedicated
|
|
4662
|
+
// peercred-gated UDS; this gateway (agent UID) owns the jsonl append,
|
|
4663
|
+
// dedup, and dispatch firing. Wrapped so any failure is best-effort and can
|
|
4664
|
+
// NEVER crash gateway boot (which would take the agent down).
|
|
4665
|
+
;(() => {
|
|
4666
|
+
try {
|
|
4667
|
+
const selfAgent = process.env.SWITCHROOM_AGENT_NAME ?? ''
|
|
4668
|
+
if (!selfAgent) return
|
|
4669
|
+
|
|
4670
|
+
let viaGateway = false
|
|
4671
|
+
try {
|
|
4672
|
+
const cfg = loadSwitchroomConfig()
|
|
4673
|
+
const raw = cfg.agents?.[selfAgent]
|
|
4674
|
+
viaGateway = raw
|
|
4675
|
+
? resolveAgentConfig(cfg.defaults, cfg.profiles, raw).channels?.telegram
|
|
4676
|
+
?.webhook_via_gateway === true
|
|
4677
|
+
: false
|
|
4678
|
+
} catch (err) {
|
|
4679
|
+
process.stderr.write(
|
|
4680
|
+
`telegram gateway: webhook-ingest config probe failed: ${(err as Error).message}\n`,
|
|
4681
|
+
)
|
|
4682
|
+
}
|
|
4683
|
+
if (!viaGateway) return
|
|
4684
|
+
|
|
4685
|
+
// Allowed peer UIDs: the agent's own UID (self-connections) + the
|
|
4686
|
+
// operator/receiver UID emitted into the env by the compose generator
|
|
4687
|
+
// (SWITCHROOM_WEBHOOK_RECEIVER_UID == operatorUid). Fail-closed: if the
|
|
4688
|
+
// receiver UID is unset, only self can connect → receiver gets 503,
|
|
4689
|
+
// surfacing the misconfiguration instead of silently accepting any UID.
|
|
4690
|
+
const allowedUids: number[] = []
|
|
4691
|
+
const ownUid = typeof process.getuid === 'function' ? process.getuid() : null
|
|
4692
|
+
if (ownUid !== null) allowedUids.push(ownUid)
|
|
4693
|
+
const receiverUidRaw = process.env.SWITCHROOM_WEBHOOK_RECEIVER_UID
|
|
4694
|
+
const receiverUid = receiverUidRaw ? Number(receiverUidRaw) : NaN
|
|
4695
|
+
if (Number.isInteger(receiverUid)) allowedUids.push(receiverUid)
|
|
4696
|
+
|
|
4697
|
+
const socketPath = join(STATE_DIR, 'webhook.sock')
|
|
4698
|
+
|
|
4699
|
+
// In-process delivery of a synthesized webhook turn — the same
|
|
4700
|
+
// sendToAgent + buffer-on-failure primitive onInjectInbound uses, so a
|
|
4701
|
+
// webhook fire landing mid bridge-reconnect is buffered, not dropped.
|
|
4702
|
+
const webhookInject = (agentName: string, inbound: unknown): boolean => {
|
|
4703
|
+
// The wire record is a structural mirror of the bridge's
|
|
4704
|
+
// InboundMessage (same cast the scheduler's ipcDispatcher uses).
|
|
4705
|
+
const msg = inbound as InboundMessage
|
|
4706
|
+
const delivered = ipcServer.sendToAgent(agentName, msg)
|
|
4707
|
+
if (delivered) markClaudeBusyForInbound(msg)
|
|
4708
|
+
else pendingInboundBuffer.push(agentName, msg)
|
|
4709
|
+
return delivered
|
|
4710
|
+
}
|
|
4711
|
+
|
|
4712
|
+
startWebhookIngestServer({
|
|
4713
|
+
socketPath,
|
|
4714
|
+
allowedUids,
|
|
4715
|
+
log: (s) => process.stderr.write(`telegram gateway: ${s}`),
|
|
4716
|
+
onRecord: (req) =>
|
|
4717
|
+
recordWebhookEvent(
|
|
4718
|
+
{
|
|
4719
|
+
agent: selfAgent,
|
|
4720
|
+
source: req.source,
|
|
4721
|
+
event_type: req.event_type,
|
|
4722
|
+
ts: req.ts,
|
|
4723
|
+
rendered_text: req.rendered_text,
|
|
4724
|
+
payload: req.payload,
|
|
4725
|
+
...(req.delivery_id ? { delivery_id: req.delivery_id } : {}),
|
|
4726
|
+
},
|
|
4727
|
+
{
|
|
4728
|
+
inject: webhookInject,
|
|
4729
|
+
log: (s) => process.stderr.write(`telegram gateway: ${s}`),
|
|
4730
|
+
},
|
|
4731
|
+
),
|
|
4732
|
+
})
|
|
4733
|
+
} catch (err) {
|
|
4734
|
+
process.stderr.write(
|
|
4735
|
+
`telegram gateway: webhook-ingest server start failed (non-fatal): ${(err as Error).message}\n`,
|
|
4736
|
+
)
|
|
4737
|
+
}
|
|
4738
|
+
})()
|
|
4739
|
+
|
|
4673
4740
|
// ─── Opportunistic idle-drain of pendingInboundBuffer ─────────────────────
|
|
4674
4741
|
// pendingInboundBuffer otherwise drains only on (a) bridge re-register
|
|
4675
4742
|
// (onClientRegistered) or (b) the silence-poke framework fallback
|
|
@@ -6946,23 +7013,18 @@ async function drainActivitySummary(turn: CurrentTurn): Promise<void> {
|
|
|
6946
7013
|
while (turn.activityPendingRender !== turn.activityLastSentRender) {
|
|
6947
7014
|
const target = turn.activityPendingRender
|
|
6948
7015
|
if (target == null) break
|
|
6949
|
-
//
|
|
6950
|
-
// (
|
|
6951
|
-
//
|
|
6952
|
-
//
|
|
6953
|
-
|
|
6954
|
-
// re-escape or re-wrap (double-escaping would surface literal tags).
|
|
6955
|
-
// - feed OFF: the legacy verb-count summary is plain text — escape and
|
|
6956
|
-
// wrap in a single <i>.
|
|
6957
|
-
const html = DRAFT_MIRROR_ENABLED ? target : `<i>${escapeHtmlForTg(target)}</i>`
|
|
7016
|
+
// `renderActivityFeed` already emitted ready Telegram HTML with per-line
|
|
7017
|
+
// markup (<b>→ current</b> / <i>✓ done</i>) and escaped each label's
|
|
7018
|
+
// <,>,& itself (#1942 class) — send verbatim, do NOT re-escape or
|
|
7019
|
+
// re-wrap (double-escaping would surface literal tags).
|
|
7020
|
+
const html = target
|
|
6958
7021
|
const chat = turn.sessionChatId
|
|
6959
7022
|
const thread = turn.sessionThreadId
|
|
6960
7023
|
// Native reply-quote: anchor the feed message to the user's question so
|
|
6961
7024
|
// it renders as a quoted header (reply_parameters renders on a real
|
|
6962
|
-
// message; edits preserve it).
|
|
6963
|
-
//
|
|
6964
|
-
|
|
6965
|
-
const replyAnchor = DRAFT_MIRROR_ENABLED && turn.sourceMessageId != null
|
|
7025
|
+
// message; edits preserve it). allow_sending_without_reply so a deleted
|
|
7026
|
+
// source can't drop the send.
|
|
7027
|
+
const replyAnchor = turn.sourceMessageId != null
|
|
6966
7028
|
? { reply_parameters: { message_id: turn.sourceMessageId, allow_sending_without_reply: true } }
|
|
6967
7029
|
: {}
|
|
6968
7030
|
try {
|
|
@@ -7090,7 +7152,6 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
7090
7152
|
lastAssistantMsgId: null,
|
|
7091
7153
|
lastAssistantDone: false,
|
|
7092
7154
|
toolCallCount: 0,
|
|
7093
|
-
toolActivity: makeEmptyActivityState(),
|
|
7094
7155
|
activityMessageId: null,
|
|
7095
7156
|
activityInFlight: null,
|
|
7096
7157
|
activityPendingRender: null,
|
|
@@ -7228,51 +7289,20 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
7228
7289
|
turn.orphanedReplyTimeoutId = null
|
|
7229
7290
|
}
|
|
7230
7291
|
// The model's real reply takes over as the authoritative
|
|
7231
|
-
// surface, so delete the activity
|
|
7232
|
-
// sees the real reply land in the same beat the
|
|
7233
|
-
// disappears.
|
|
7234
|
-
// the DRAFT_MIRROR feed); turn_end is the no-reply safety net.
|
|
7292
|
+
// surface, so delete the activity feed message — the user
|
|
7293
|
+
// sees the real reply land in the same beat the feed
|
|
7294
|
+
// disappears. turn_end is the no-reply safety net.
|
|
7235
7295
|
if (wasFirstReply) {
|
|
7236
7296
|
clearActivitySummary(turn)
|
|
7237
7297
|
}
|
|
7238
7298
|
}
|
|
7239
|
-
//
|
|
7240
|
-
//
|
|
7241
|
-
//
|
|
7242
|
-
//
|
|
7243
|
-
//
|
|
7244
|
-
//
|
|
7245
|
-
//
|
|
7246
|
-
//
|
|
7247
|
-
// Single-flight coalescing (PR #1926 review): modern Claude emits
|
|
7248
|
-
// multiple tool_uses in a synchronous burst (parallel Reads,
|
|
7249
|
-
// Bashes, etc.). All would otherwise race past the message-id
|
|
7250
|
-
// capture and produce N messages. Pattern mirrors answer-stream:
|
|
7251
|
-
// update `activityPendingRender` synchronously here; a single
|
|
7252
|
-
// worker promise drains the pending state, sending or editing
|
|
7253
|
-
// exactly once at a time and re-running until pending matches
|
|
7254
|
-
// the last-sent. Captures `turn` so a late drain after turn-swap
|
|
7255
|
-
// can't corrupt the next turn's atom.
|
|
7256
|
-
//
|
|
7257
|
-
// This (flush-gated) tool_use path drives the summary ONLY when
|
|
7258
|
-
// DRAFT_MIRROR is OFF: the legacy generic verb-count summary
|
|
7259
|
-
// ("Ran 5 commands") via registerAndRender. When DRAFT_MIRROR is
|
|
7260
|
-
// ON the summary is instead driven by the real-time `tool_label`
|
|
7261
|
-
// event (PreToolUse sidecar, fires at tool-call time regardless of
|
|
7262
|
-
// when claude flushes the transcript) — see `case 'tool_label'`.
|
|
7263
|
-
// That's the determinism fix: on a fast/clustered-tool turn the
|
|
7264
|
-
// JSONL tool_use rows aren't on disk until ~turn-end, so sourcing
|
|
7265
|
-
// the feed here lost it; the sidecar is flush-independent. Both
|
|
7266
|
-
// producers feed `activityPendingRender` and clear on first reply.
|
|
7267
|
-
if (!DRAFT_MIRROR_ENABLED && !turn.replyCalled && !isTelegramSurfaceTool(name)) {
|
|
7268
|
-
const rendered = registerAndRender(turn.toolActivity, name)
|
|
7269
|
-
if (rendered != null) {
|
|
7270
|
-
turn.activityPendingRender = rendered
|
|
7271
|
-
if (turn.activityInFlight == null) {
|
|
7272
|
-
turn.activityInFlight = drainActivitySummary(turn)
|
|
7273
|
-
}
|
|
7274
|
-
}
|
|
7275
|
-
}
|
|
7299
|
+
// The live activity feed is driven by the real-time `tool_label`
|
|
7300
|
+
// event (PreToolUse sidecar) rather than this flush-gated tool_use
|
|
7301
|
+
// path — see `case 'tool_label'`. The sidecar fires at tool-call
|
|
7302
|
+
// time regardless of when claude flushes the transcript, which is
|
|
7303
|
+
// the determinism fix: on a fast/clustered-tool turn the JSONL
|
|
7304
|
+
// tool_use rows aren't on disk until ~turn-end, so sourcing the
|
|
7305
|
+
// feed here would lose them.
|
|
7276
7306
|
if (!ctrl) return
|
|
7277
7307
|
if (isTelegramSurfaceTool(name)) return
|
|
7278
7308
|
ctrl.setTool(name)
|
|
@@ -7282,13 +7312,12 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
7282
7312
|
return
|
|
7283
7313
|
}
|
|
7284
7314
|
case 'tool_label': {
|
|
7285
|
-
//
|
|
7315
|
+
// Real-time activity-feed driver. The PreToolUse hook wrote this
|
|
7286
7316
|
// label synchronously at tool-call time; the sidecar surfaced it
|
|
7287
7317
|
// here (~250ms) independent of the transcript flush. Accumulate it
|
|
7288
7318
|
// into the live feed and edit the activity message in place — this
|
|
7289
7319
|
// is what makes the feed deterministic on fast/clustered-tool turns
|
|
7290
7320
|
// where the JSONL tool_use rows arrive too late.
|
|
7291
|
-
if (!DRAFT_MIRROR_ENABLED) return
|
|
7292
7321
|
const turn = currentTurn
|
|
7293
7322
|
if (turn == null) return
|
|
7294
7323
|
// Surface tools (reply/stream_reply/react) are the conversation, not
|
|
@@ -7582,13 +7611,13 @@ function handleSessionEvent(ev: SessionEvent): void {
|
|
|
7582
7611
|
clearTimeout(turn.orphanedReplyTimeoutId)
|
|
7583
7612
|
turn.orphanedReplyTimeoutId = null
|
|
7584
7613
|
}
|
|
7585
|
-
//
|
|
7586
|
-
//
|
|
7587
|
-
//
|
|
7588
|
-
//
|
|
7589
|
-
//
|
|
7590
|
-
//
|
|
7591
|
-
if (
|
|
7614
|
+
// Clear the activity feed at the real end of the turn. This is the
|
|
7615
|
+
// no-reply safety net — a turn that ends without ever calling reply
|
|
7616
|
+
// (the answer is delivered by turn-flush / silent-end) still has its
|
|
7617
|
+
// feed removed. On a normal turn the feed was already cleared at the
|
|
7618
|
+
// first reply (the hand-off); clearActivitySummary is idempotent, so
|
|
7619
|
+
// the second call is a no-op.
|
|
7620
|
+
if (turn != null) {
|
|
7592
7621
|
clearActivitySummary(turn)
|
|
7593
7622
|
}
|
|
7594
7623
|
// #549 fix — flush any pending preamble BEFORE the answer stream is
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the peercred-gated webhook ingest UDS server
|
|
3
|
+
* (RFC docs/rfcs/webhook-via-gateway-socket.md).
|
|
4
|
+
*
|
|
5
|
+
* MUST run under `bun test`: the peer-credential gate calls
|
|
6
|
+
* `getPeerCred` (bun:ffi getsockopt SO_PEERCRED), which returns null
|
|
7
|
+
* under node/vitest — so the allow path is only exercisable under bun.
|
|
8
|
+
* This file is excluded from the vitest run (see vitest.config.ts) and
|
|
9
|
+
* listed in the `test:bun` / `test` scripts in package.json.
|
|
10
|
+
*
|
|
11
|
+
* Sockets live in an isolated tmpdir — never `~/.switchroom`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect, afterEach } from 'bun:test'
|
|
15
|
+
import net from 'node:net'
|
|
16
|
+
import { mkdtempSync } from 'node:fs'
|
|
17
|
+
import { tmpdir } from 'node:os'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
import {
|
|
20
|
+
startWebhookIngestServer,
|
|
21
|
+
type WebhookIngestServer,
|
|
22
|
+
type WebhookIngestRequest,
|
|
23
|
+
} from './webhook-ingest-server.js'
|
|
24
|
+
import { forwardToGateway } from '../../src/web/webhook-ingest-client.js'
|
|
25
|
+
|
|
26
|
+
const servers: WebhookIngestServer[] = []
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
for (const s of servers.splice(0)) s.close()
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
function tmpSocket(): string {
|
|
32
|
+
const dir = mkdtempSync(join(tmpdir(), 'webhook-ingest-srv-'))
|
|
33
|
+
return join(dir, 'webhook.sock')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function sampleReq(): WebhookIngestRequest {
|
|
37
|
+
return {
|
|
38
|
+
agent: 'reggie',
|
|
39
|
+
source: 'github',
|
|
40
|
+
event_type: 'pull_request',
|
|
41
|
+
ts: 1_700_000_000_000,
|
|
42
|
+
rendered_text: 'PR opened',
|
|
43
|
+
payload: { action: 'opened', number: 7 },
|
|
44
|
+
delivery_id: 'd-7',
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe('startWebhookIngestServer (peercred-gated)', () => {
|
|
49
|
+
it('accepts a connection from an allowed uid and round-trips onRecord', async () => {
|
|
50
|
+
const socketPath = tmpSocket()
|
|
51
|
+
let received: WebhookIngestRequest | null = null
|
|
52
|
+
const server = startWebhookIngestServer({
|
|
53
|
+
socketPath,
|
|
54
|
+
allowedUids: [process.getuid!()], // our own uid → allowed
|
|
55
|
+
onRecord: (req) => {
|
|
56
|
+
received = req
|
|
57
|
+
return { status: 'ok', ts: req.ts, dispatched: 1 }
|
|
58
|
+
},
|
|
59
|
+
log: () => {},
|
|
60
|
+
})
|
|
61
|
+
servers.push(server)
|
|
62
|
+
|
|
63
|
+
const resp = await forwardToGateway(socketPath, sampleReq())
|
|
64
|
+
|
|
65
|
+
expect(resp).not.toBeNull()
|
|
66
|
+
expect(resp!.status).toBe('ok')
|
|
67
|
+
expect(resp!.ts).toBe(1_700_000_000_000)
|
|
68
|
+
expect(resp!.dispatched).toBe(1)
|
|
69
|
+
expect(received).not.toBeNull()
|
|
70
|
+
expect(received!.agent).toBe('reggie')
|
|
71
|
+
expect(received!.event_type).toBe('pull_request')
|
|
72
|
+
expect(received!.delivery_id).toBe('d-7')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('denies a connection whose uid is not in allowedUids (onRecord never runs)', async () => {
|
|
76
|
+
const socketPath = tmpSocket()
|
|
77
|
+
let called = false
|
|
78
|
+
const server = startWebhookIngestServer({
|
|
79
|
+
socketPath,
|
|
80
|
+
allowedUids: [999_999], // definitely not our uid
|
|
81
|
+
onRecord: () => {
|
|
82
|
+
called = true
|
|
83
|
+
return { status: 'ok' }
|
|
84
|
+
},
|
|
85
|
+
log: () => {},
|
|
86
|
+
})
|
|
87
|
+
servers.push(server)
|
|
88
|
+
|
|
89
|
+
// The server destroys the connection in its accept callback BEFORE
|
|
90
|
+
// reading any bytes, so onRecord must never run. We assert the
|
|
91
|
+
// security property (no service for an unlisted uid) directly rather
|
|
92
|
+
// than via the client resolving: bun's net client does not observe a
|
|
93
|
+
// server-side destroy of a just-accepted UDS connection promptly, so
|
|
94
|
+
// awaiting forwardToGateway here would hang. Send raw bytes and give
|
|
95
|
+
// the server ample time to (not) process them.
|
|
96
|
+
const raw = net.createConnection(socketPath)
|
|
97
|
+
raw.on('connect', () => raw.write(JSON.stringify(sampleReq()) + '\n'))
|
|
98
|
+
raw.on('error', () => {})
|
|
99
|
+
await new Promise((r) => setTimeout(r, 300))
|
|
100
|
+
raw.destroy()
|
|
101
|
+
|
|
102
|
+
expect(called).toBe(false)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('returns null when the socket does not exist (gateway down)', async () => {
|
|
106
|
+
const socketPath = tmpSocket() // never bound
|
|
107
|
+
const resp = await forwardToGateway(socketPath, sampleReq(), { timeoutMs: 2000 })
|
|
108
|
+
expect(resp).toBeNull()
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('surfaces a gateway-side error status from onRecord', async () => {
|
|
112
|
+
const socketPath = tmpSocket()
|
|
113
|
+
const server = startWebhookIngestServer({
|
|
114
|
+
socketPath,
|
|
115
|
+
allowedUids: [process.getuid!()],
|
|
116
|
+
onRecord: () => ({ status: 'error', error: 'write failed' }),
|
|
117
|
+
log: () => {},
|
|
118
|
+
})
|
|
119
|
+
servers.push(server)
|
|
120
|
+
|
|
121
|
+
const resp = await forwardToGateway(socketPath, sampleReq())
|
|
122
|
+
expect(resp).not.toBeNull()
|
|
123
|
+
expect(resp!.status).toBe('error')
|
|
124
|
+
})
|
|
125
|
+
})
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook ingest UDS server (RFC docs/rfcs/webhook-via-gateway-socket.md).
|
|
3
|
+
*
|
|
4
|
+
* A dedicated, peercred-gated Unix socket the host-side web receiver
|
|
5
|
+
* forwards verified webhook events to. It is deliberately SEPARATE from
|
|
6
|
+
* the bridge IPC socket (`gateway.sock`):
|
|
7
|
+
*
|
|
8
|
+
* - `gateway.sock` is bound via `Bun.listen`, whose accepted socket does
|
|
9
|
+
* NOT expose a file descriptor — so SO_PEERCRED can't be read on it.
|
|
10
|
+
* This server uses `node:net` (whose `Socket._handle.fd` IS readable
|
|
11
|
+
* under Bun, verified empirically) precisely so the peer-credential
|
|
12
|
+
* gate works.
|
|
13
|
+
* - Keeping the chat-critical bridge socket untouched avoids any risk to
|
|
14
|
+
* the bridge-flap-sensitive reconnect path.
|
|
15
|
+
*
|
|
16
|
+
* Auth model — two independent layers:
|
|
17
|
+
* 1. **Filesystem**: the socket is chmod 0o666 so the host operator UID
|
|
18
|
+
* can connect at all (the agent dir itself is 0775/agent-UID, which
|
|
19
|
+
* blocks the operator from writing files but not from connecting a
|
|
20
|
+
* world-connectable socket).
|
|
21
|
+
* 2. **Peer credentials**: every connection's SO_PEERCRED UID must be in
|
|
22
|
+
* `allowedUids` (the agent's own UID + the operator/receiver UID).
|
|
23
|
+
* This is the load-bearing gate: it ensures only the trusted receiver
|
|
24
|
+
* (which enforces per-event HMAC) — or the agent itself — can inject a
|
|
25
|
+
* webhook turn. Fail-closed: unreadable creds or an unlisted UID is
|
|
26
|
+
* denied.
|
|
27
|
+
*
|
|
28
|
+
* Wire protocol: one JSON line in (`WebhookIngestRequest`), one JSON line
|
|
29
|
+
* out (`WebhookIngestResponse`), then the connection closes. No framing
|
|
30
|
+
* beyond the trailing newline; requests are small (a rendered event +
|
|
31
|
+
* payload).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import net from 'node:net'
|
|
35
|
+
import { chmodSync, existsSync, unlinkSync } from 'node:fs'
|
|
36
|
+
import { getPeerCred } from '../../src/vault/broker/peercred-ffi.js'
|
|
37
|
+
|
|
38
|
+
/** Forwarded by the receiver; structural mirror of WebhookGatewayRecord. */
|
|
39
|
+
export interface WebhookIngestRequest {
|
|
40
|
+
agent: string
|
|
41
|
+
source: string
|
|
42
|
+
event_type: string
|
|
43
|
+
ts: number
|
|
44
|
+
rendered_text: string
|
|
45
|
+
payload: Record<string, unknown>
|
|
46
|
+
delivery_id?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface WebhookIngestResponse {
|
|
50
|
+
status: 'ok' | 'deduped' | 'error'
|
|
51
|
+
ts?: number
|
|
52
|
+
error?: string
|
|
53
|
+
dispatched?: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface WebhookIngestServerOptions {
|
|
57
|
+
socketPath: string
|
|
58
|
+
/** SO_PEERCRED UIDs permitted to inject. Connections from any other UID
|
|
59
|
+
* (or with unreadable creds) are denied and the socket destroyed. */
|
|
60
|
+
allowedUids: number[]
|
|
61
|
+
/** Handle one verified, forwarded event. Synchronous return (the record
|
|
62
|
+
* path is file I/O + an in-process inject) wrapped in Promise for the
|
|
63
|
+
* server's await. */
|
|
64
|
+
onRecord: (req: WebhookIngestRequest) => WebhookIngestResponse | Promise<WebhookIngestResponse>
|
|
65
|
+
log?: (line: string) => void
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface WebhookIngestServer {
|
|
69
|
+
close: () => void
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const MAX_REQUEST_BYTES = 1024 * 1024 // 1 MiB — github payloads fit easily
|
|
73
|
+
|
|
74
|
+
/** Read the accepted connection's fd via the undocumented `_handle.fd`.
|
|
75
|
+
* Under Bun's node:net polyfill this is present (verified); returns null
|
|
76
|
+
* if absent so the caller fails closed. */
|
|
77
|
+
function fdOf(conn: net.Socket): number | null {
|
|
78
|
+
const handle = (conn as unknown as { _handle?: { fd?: number } })._handle
|
|
79
|
+
if (!handle || typeof handle.fd !== 'number' || handle.fd < 0) return null
|
|
80
|
+
return handle.fd
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Start the webhook ingest server. Never throws — bind failures are logged
|
|
85
|
+
* and surfaced via the returned server still being usable as a no-op close.
|
|
86
|
+
* The CALLER (gateway boot) additionally wraps this so a failure here can
|
|
87
|
+
* never take the agent down; this function's own try/catch is belt-and-
|
|
88
|
+
* suspenders.
|
|
89
|
+
*/
|
|
90
|
+
export function startWebhookIngestServer(
|
|
91
|
+
opts: WebhookIngestServerOptions,
|
|
92
|
+
): WebhookIngestServer {
|
|
93
|
+
const log = opts.log ?? ((s) => process.stderr.write(s))
|
|
94
|
+
const allowed = new Set(opts.allowedUids)
|
|
95
|
+
|
|
96
|
+
// Clear a stale socket from a previous (crashed) gateway. Safe: only this
|
|
97
|
+
// agent's gateway binds this path, and we're about to rebind it.
|
|
98
|
+
try {
|
|
99
|
+
if (existsSync(opts.socketPath)) unlinkSync(opts.socketPath)
|
|
100
|
+
} catch (err) {
|
|
101
|
+
log(`webhook-ingest-server: could not unlink stale socket: ${(err as Error).message}\n`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const server = net.createServer((conn) => {
|
|
105
|
+
// ── Peer-credential gate (fail-closed) ──────────────────────────────────
|
|
106
|
+
const fd = fdOf(conn)
|
|
107
|
+
const cred = fd !== null ? getPeerCred(fd) : null
|
|
108
|
+
if (cred === null || !allowed.has(cred.uid)) {
|
|
109
|
+
log(
|
|
110
|
+
`webhook-ingest-server: DENY connection uid=${cred?.uid ?? 'unknown'} ` +
|
|
111
|
+
`(allowed=${[...allowed].join(',')})\n`,
|
|
112
|
+
)
|
|
113
|
+
conn.destroy()
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let buf = ''
|
|
118
|
+
let handled = false
|
|
119
|
+
conn.setEncoding('utf8')
|
|
120
|
+
|
|
121
|
+
const reply = (resp: WebhookIngestResponse) => {
|
|
122
|
+
if (handled) return
|
|
123
|
+
handled = true
|
|
124
|
+
try {
|
|
125
|
+
conn.write(JSON.stringify(resp) + '\n')
|
|
126
|
+
} catch {
|
|
127
|
+
/* peer may have hung up */
|
|
128
|
+
}
|
|
129
|
+
conn.end()
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
conn.on('data', (chunk: string) => {
|
|
133
|
+
if (handled) return
|
|
134
|
+
buf += chunk
|
|
135
|
+
if (buf.length > MAX_REQUEST_BYTES) {
|
|
136
|
+
reply({ status: 'error', error: 'request too large' })
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
const nl = buf.indexOf('\n')
|
|
140
|
+
if (nl === -1) return // wait for the full line
|
|
141
|
+
const line = buf.slice(0, nl)
|
|
142
|
+
|
|
143
|
+
let req: WebhookIngestRequest
|
|
144
|
+
try {
|
|
145
|
+
req = JSON.parse(line) as WebhookIngestRequest
|
|
146
|
+
} catch {
|
|
147
|
+
reply({ status: 'error', error: 'malformed request' })
|
|
148
|
+
return
|
|
149
|
+
}
|
|
150
|
+
if (
|
|
151
|
+
!req ||
|
|
152
|
+
typeof req.agent !== 'string' ||
|
|
153
|
+
typeof req.source !== 'string' ||
|
|
154
|
+
typeof req.event_type !== 'string' ||
|
|
155
|
+
typeof req.rendered_text !== 'string' ||
|
|
156
|
+
typeof req.payload !== 'object' ||
|
|
157
|
+
req.payload === null
|
|
158
|
+
) {
|
|
159
|
+
reply({ status: 'error', error: 'invalid request shape' })
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
Promise.resolve()
|
|
164
|
+
.then(() => opts.onRecord(req))
|
|
165
|
+
.then((resp) => reply(resp))
|
|
166
|
+
.catch((err: unknown) => {
|
|
167
|
+
log(`webhook-ingest-server: onRecord threw: ${String(err)}\n`)
|
|
168
|
+
reply({ status: 'error', error: 'internal error' })
|
|
169
|
+
})
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
conn.on('error', (err) => {
|
|
173
|
+
log(`webhook-ingest-server: conn error: ${err.message}\n`)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
// Drop slow/idle clients so a stuck connection can't pin the socket.
|
|
177
|
+
conn.setTimeout(10_000, () => {
|
|
178
|
+
if (!handled) reply({ status: 'error', error: 'timeout' })
|
|
179
|
+
conn.destroy()
|
|
180
|
+
})
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
server.on('error', (err) => {
|
|
184
|
+
log(`webhook-ingest-server: server error: ${err.message}\n`)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
server.listen(opts.socketPath, () => {
|
|
189
|
+
try {
|
|
190
|
+
// World-connectable at the FS layer; peercred is the real gate.
|
|
191
|
+
chmodSync(opts.socketPath, 0o666)
|
|
192
|
+
} catch (err) {
|
|
193
|
+
log(`webhook-ingest-server: chmod failed: ${(err as Error).message}\n`)
|
|
194
|
+
}
|
|
195
|
+
log(
|
|
196
|
+
`webhook-ingest-server: listening at ${opts.socketPath} ` +
|
|
197
|
+
`(allowed uids: ${[...allowed].join(',')})\n`,
|
|
198
|
+
)
|
|
199
|
+
})
|
|
200
|
+
} catch (err) {
|
|
201
|
+
log(`webhook-ingest-server: listen failed: ${(err as Error).message}\n`)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
close: () => {
|
|
206
|
+
try {
|
|
207
|
+
server.close()
|
|
208
|
+
} catch {
|
|
209
|
+
/* ignore */
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
if (existsSync(opts.socketPath)) unlinkSync(opts.socketPath)
|
|
213
|
+
} catch {
|
|
214
|
+
/* ignore */
|
|
215
|
+
}
|
|
216
|
+
},
|
|
217
|
+
}
|
|
218
|
+
}
|