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.
@@ -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 (DRAFT_MIRROR only).
1361
- // Each non-surface tool_use appends a line via `appendActivityLine`; the
1362
- // feed renders (via `renderActivityFeed`) as a capped chronological list
1363
- // into the in-place edited activity message and clears on reply. Reset
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-feed flag (RFC docs/rfcs/draft-mirror-preview.md). When enabled,
3258
- // the gateway streams a live "what it's doing" tool-activity feed for the
3259
- // turn. The PreToolUse sidecar emits a `tool_label` per tool call (flush-
3260
- // independent, so it stays real-time on fast/clustered-tool turns); each
3261
- // label appends to `turn.mirrorLines`, and `renderActivityFeed` renders the
3262
- // capped list into an in-place EDITED message (sendMessage + editMessageText)
3263
- // anchored as a native reply-quote to the user's question. The feed clears on
3264
- // the first reply (hand-off to the answer) and again at turn_end (the no-reply
3265
- // safety net). It does NOT touch the answer-stream's draft/visible lane the
3266
- // two render on separate surfaces, so they never collide. (The env name is
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
- // Two mutually-exclusive producers feed `activityPendingRender`
6950
- // (gated on DRAFT_MIRROR_ENABLED in handleSessionEvent):
6951
- // - feed ON: `renderActivityFeed` already emitted ready Telegram HTML
6952
- // with per-line markup (<b>→ current</b> / <i>✓ done</i>) and escaped
6953
- // each label's <,>,& itself (#1942 class) — send verbatim, do NOT
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). Feed-only the legacy summary is left
6963
- // visually unchanged. allow_sending_without_reply so a deleted source
6964
- // can't drop the send.
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 summary message — the user
7232
- // sees the real reply land in the same beat the summary
7233
- // disappears. Applies to both producers (legacy verb-count and
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
- // Tool-activity summary same shape Claude Code natively renders
7240
- // in its CLI/chat UI ("Ran 5 commands, read a file"). The gateway
7241
- // accumulates non-reply tool_use events into `turn.toolActivity`
7242
- // and sends ONE Telegram message that edits in place as more tools
7243
- // land. Stops editing once the model calls `reply` — the summary
7244
- // line stays as the final state. No model-side prompting; no per-
7245
- // tool labels. Just surface what's already in the stream.
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
- // DRAFT_MIRROR real-time driver. The PreToolUse hook wrote this
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
- // DRAFT_MIRROR: clear the activity feed at the real end of the turn.
7586
- // This is the no-reply safety net — a turn that ends without ever
7587
- // calling reply (the answer is delivered by turn-flush / silent-end)
7588
- // still has its feed removed. On a normal turn the feed was already
7589
- // cleared at the first reply (the hand-off); clearActivitySummary is
7590
- // idempotent, so the second call is a no-op.
7591
- if (DRAFT_MIRROR_ENABLED && turn != null) {
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
+ }