switchroom 0.14.9 → 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.
@@ -263,6 +263,8 @@ import { formatUpdateStatusLine } from './update-status-line.js'
263
263
  import type { HostdRequest } from '../../src/host-control/protocol.js'
264
264
  import type { AgentAudit } from '../welcome-text.js'
265
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'
266
268
 
267
269
  import { createIpcServer, type IpcClient, type IpcServer } from './ipc-server.js'
268
270
  import { handleRequestDriveApproval } from './drive-write-approval.js'
@@ -4652,6 +4654,89 @@ const ipcServer: IpcServer = createIpcServer({
4652
4654
  log: (msg) => process.stderr.write(`telegram gateway: ipc — ${msg}\n`),
4653
4655
  })
4654
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
+
4655
4740
  // ─── Opportunistic idle-drain of pendingInboundBuffer ─────────────────────
4656
4741
  // pendingInboundBuffer otherwise drains only on (a) bridge re-register
4657
4742
  // (onClientRegistered) or (b) the silence-poke framework fallback
@@ -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
+ }