@shumkov/orchestra 0.2.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.
@@ -0,0 +1,200 @@
1
+ // provenance: polygram@0.17.11 lib/process/channels-bridge-protocol.js (git 746bca6) — verbatim*: env prefix WATER_, bridge name water-bridge, vendor path (SHARED-LIB.md).
2
+ /**
3
+ * Bridge ↔ daemon socket protocol — typed schemas.
4
+ *
5
+ * Wire format: newline-delimited JSON over a unix socket per session.
6
+ * Both endpoints (CliProcess and channels-bridge.mjs) speak the same
7
+ * message kinds. This module centralizes the shape so both sides safeParse
8
+ * inbound messages with the same constraints — protecting against malformed
9
+ * payloads silently corrupting pending-state Maps.
10
+ *
11
+ * Adding a new message kind:
12
+ * 1. Define its schema below as `<KindName>MessageSchema`
13
+ * 2. Add it to `AnyDaemonToBridgeMessage` or `AnyBridgeToDaemonMessage`
14
+ * 3. Handle it in the corresponding switch (cli-process.js
15
+ * _onBridgeMsg or channels-bridge.mjs handleDaemonMessage)
16
+ *
17
+ * Validation policy:
18
+ * - Daemon side uses `safeParse` and drops malformed messages with a warn
19
+ * (downgrades silent corruption into observable log)
20
+ * - Bridge side does the same on inbound from daemon
21
+ * - All validation happens AFTER hello-handshake auth (the auth gate is
22
+ * the first line of defense; schema is the second)
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const { z } = require('zod');
28
+
29
+ // ─── shared primitives ─────────────────────────────────────────────
30
+
31
+ const NonEmptyString = z.string().min(1);
32
+ const OptionalString = z.string().optional();
33
+ const ToolCallId = z.string().min(1);
34
+ const RequestId = z.string().min(1);
35
+ const TurnId = z.string().min(1);
36
+
37
+ // ─── bridge → daemon ───────────────────────────────────────────────
38
+
39
+ const HelloSchema = z.object({
40
+ kind: z.literal('hello'),
41
+ session_key: NonEmptyString,
42
+ secret: NonEmptyString,
43
+ }).passthrough();
44
+
45
+ const SessionInitSchema = z.object({
46
+ kind: z.literal('session_init'),
47
+ claude_session_id: z.string(), // may be empty if claude generated one before bridge sees it
48
+ }).passthrough();
49
+
50
+ const ToolCallMessageSchema = z.object({
51
+ kind: z.literal('tool'),
52
+ session: NonEmptyString,
53
+ tool_call_id: ToolCallId,
54
+ // 'ask' (0.12 interactive questions): a blocking tool whose answer rides back
55
+ // on a `question_answer` daemon→bridge message (NOT the fast `tool_ack`); its
56
+ // args are {chat_id, turn_id?, questions:[...]}, not reply-shaped. _dispatchToolCall
57
+ // branches on the name so the reply-only paths (chat_id-mismatch, content-dedup,
58
+ // reply-turn-binding) don't run for it.
59
+ name: z.enum(['reply', 'react', 'edit_message', 'ask']),
60
+ args: z.object({}).passthrough(),
61
+ }).passthrough();
62
+
63
+ const PermRequestMessageSchema = z.object({
64
+ kind: z.literal('perm_req'),
65
+ session: NonEmptyString,
66
+ request_id: RequestId,
67
+ tool_name: NonEmptyString,
68
+ description: z.string(),
69
+ input_preview: z.string(),
70
+ }).passthrough();
71
+
72
+ const PongMessageSchema = z.object({
73
+ kind: z.literal('pong'),
74
+ }).passthrough();
75
+
76
+ // 0.12 Phase 1.6: bridge tells daemon when claude has finished registering
77
+ // the bridge as an MCP server (claude sent its first ListToolsRequest).
78
+ // Polygram's _waitForBridgeHandshake gates on this in addition to hello,
79
+ // eliminating the cold-spawn race (Finding 0.3.A).
80
+ const McpReadyMessageSchema = z.object({
81
+ kind: z.literal('mcp-ready'),
82
+ session: NonEmptyString,
83
+ }).passthrough();
84
+
85
+ const AnyBridgeToDaemonMessage = z.discriminatedUnion('kind', [
86
+ HelloSchema,
87
+ SessionInitSchema,
88
+ ToolCallMessageSchema,
89
+ PermRequestMessageSchema,
90
+ PongMessageSchema,
91
+ McpReadyMessageSchema,
92
+ ]);
93
+
94
+ // ─── daemon → bridge ───────────────────────────────────────────────
95
+
96
+ const HelloAckSchema = z.object({
97
+ kind: z.literal('hello_ack'),
98
+ }).passthrough();
99
+
100
+ const HelloRejectSchema = z.object({
101
+ kind: z.literal('hello_reject'),
102
+ reason: z.string().optional(),
103
+ }).passthrough();
104
+
105
+ const UserMessageSchema = z.object({
106
+ kind: z.literal('user_msg'),
107
+ text: z.string(),
108
+ chat_id: z.union([z.string(), z.number()]).optional(),
109
+ user: OptionalString,
110
+ msg_id: z.union([z.string(), z.number()]).optional(),
111
+ turn_id: OptionalString,
112
+ }).passthrough();
113
+
114
+ const PermVerdictMessageSchema = z.object({
115
+ kind: z.literal('perm_verdict'),
116
+ request_id: RequestId,
117
+ behavior: z.enum(['allow', 'deny']),
118
+ }).passthrough();
119
+
120
+ const ToolAckMessageSchema = z.object({
121
+ kind: z.literal('tool_ack'),
122
+ tool_call_id: ToolCallId,
123
+ ok: z.boolean(),
124
+ error: z.string().optional(),
125
+ // 0.13: the delivered Telegram message_id, surfaced back to claude so it can
126
+ // `edit_message` that bubble for progressive status. Present on a successful
127
+ // `reply`/`edit_message` ack; absent on errors / re-acks.
128
+ message_id: z.union([z.number(), z.string()]).nullish(),
129
+ }).passthrough();
130
+
131
+ // 0.12 interactive questions: carries the user's answer back for an `ask` tool
132
+ // call. Separate from `tool_ack` (which has no payload field and resolves the
133
+ // fast reply round-trip) so a blocking question can return a structured result.
134
+ // `result` is one of {answers:[...]} | {cancelled:true} | {timedout:true}.
135
+ const QuestionAnswerMessageSchema = z.object({
136
+ kind: z.literal('question_answer'),
137
+ tool_call_id: ToolCallId,
138
+ result: z.object({}).passthrough(),
139
+ }).passthrough();
140
+
141
+ const PingMessageSchema = z.object({
142
+ kind: z.literal('ping'),
143
+ }).passthrough();
144
+
145
+ const AnyDaemonToBridgeMessage = z.discriminatedUnion('kind', [
146
+ HelloAckSchema,
147
+ HelloRejectSchema,
148
+ UserMessageSchema,
149
+ PermVerdictMessageSchema,
150
+ ToolAckMessageSchema,
151
+ QuestionAnswerMessageSchema,
152
+ PingMessageSchema,
153
+ ]);
154
+
155
+ // ─── helpers ──────────────────────────────────────────────────────
156
+
157
+ /**
158
+ * Parse + validate a bridge → daemon message. Returns
159
+ * {ok:true, msg} on success or {ok:false, error} on failure.
160
+ *
161
+ * @param {unknown} raw — already JSON.parsed object
162
+ * @returns {{ok: true, msg: object}|{ok: false, error: string}}
163
+ */
164
+ function parseBridgeToDaemonMessage(raw) {
165
+ const r = AnyBridgeToDaemonMessage.safeParse(raw);
166
+ if (r.success) return { ok: true, msg: r.data };
167
+ return { ok: false, error: zodErrorBrief(r.error, raw?.kind) };
168
+ }
169
+
170
+ function parseDaemonToBridgeMessage(raw) {
171
+ const r = AnyDaemonToBridgeMessage.safeParse(raw);
172
+ if (r.success) return { ok: true, msg: r.data };
173
+ return { ok: false, error: zodErrorBrief(r.error, raw?.kind) };
174
+ }
175
+
176
+ function zodErrorBrief(err, kindHint) {
177
+ const issues = (err?.issues || []).slice(0, 3).map(i => `${i.path.join('.')}: ${i.message}`);
178
+ return `kind=${kindHint || '?'} — ${issues.join('; ') || 'unknown'}`;
179
+ }
180
+
181
+ module.exports = {
182
+ // schemas (exported for tests + downstream consumers)
183
+ HelloSchema,
184
+ SessionInitSchema,
185
+ ToolCallMessageSchema,
186
+ PermRequestMessageSchema,
187
+ PongMessageSchema,
188
+ AnyBridgeToDaemonMessage,
189
+ HelloAckSchema,
190
+ HelloRejectSchema,
191
+ UserMessageSchema,
192
+ PermVerdictMessageSchema,
193
+ ToolAckMessageSchema,
194
+ QuestionAnswerMessageSchema,
195
+ PingMessageSchema,
196
+ AnyDaemonToBridgeMessage,
197
+ // helpers
198
+ parseBridgeToDaemonMessage,
199
+ parseDaemonToBridgeMessage,
200
+ };
@@ -0,0 +1,281 @@
1
+ // provenance: polygram@0.17.11 lib/process/channels-bridge-server.js (git 746bca6) — verbatim*: env prefix WATER_, bridge name water-bridge, vendor path (SHARED-LIB.md).
2
+ /**
3
+ * ChannelsBridgeServer — per-session unix-socket server for the bridge
4
+ * subprocess to connect back to.
5
+ *
6
+ * Extracted from CliProcess (M1 refactor) so the socket lifecycle —
7
+ * listen with restrictive umask, accept ONE bridge, hello-handshake auth,
8
+ * line-delimited JSON I/O, schema validation, single-bridge-per-session
9
+ * enforcement, clean teardown — lives in one focused class instead of
10
+ * sprawling across CliProcess.
11
+ *
12
+ * Owns:
13
+ * - net.Server lifecycle (listen / close)
14
+ * - socket file mode (0o600 via umask wrap + defensive chmod)
15
+ * - bridge connection state (single connection accepted)
16
+ * - hello-handshake secret verification
17
+ * - line-buffer + JSON parse + zod schema validation (channels-bridge-protocol)
18
+ *
19
+ * Does NOT own:
20
+ * - protocol semantics (tool routing, perm relay, turn lifecycle) — those
21
+ * stay in CliProcess, which subscribes to the events this class emits
22
+ * - claude/bridge process lifecycle
23
+ *
24
+ * Event surface (EventEmitter):
25
+ * 'bridge-ready' — daemon-side handshake (hello + session_init) complete
26
+ * 'mcp-ready' — claude-side MCP-server registration complete (first
27
+ * ListToolsRequest received from claude). 0.12 P1.6
28
+ * cold-spawn race fix — see channels-bridge.mjs.
29
+ * 'bridge-message', msg — every validated bridge→daemon message (post-auth)
30
+ * 'bridge-disconnected' — single-bridge connection closed
31
+ * 'error', err — socket-level errors (rare; non-fatal)
32
+ */
33
+
34
+ 'use strict';
35
+
36
+ const crypto = require('node:crypto');
37
+ const EventEmitter = require('node:events');
38
+ const fs = require('node:fs');
39
+ const net = require('node:net');
40
+
41
+ const { parseBridgeToDaemonMessage } = require('./channels-bridge-protocol');
42
+
43
+ class ChannelsBridgeServer extends EventEmitter {
44
+ /**
45
+ * @param {object} opts
46
+ * @param {string} opts.sockPath
47
+ * @param {string} opts.sessionKey — bridge must echo this in hello
48
+ * @param {string} opts.sockSecret — bridge must present this in hello
49
+ * @param {object} [opts.logger=console]
50
+ * @param {string} [opts.label='channels-bridge-server']
51
+ */
52
+ constructor({ sockPath, sessionKey, sockSecret, logger = console, label = 'channels-bridge-server' } = {}) {
53
+ super();
54
+ if (!sockPath) throw new TypeError('ChannelsBridgeServer: sockPath required');
55
+ if (!sessionKey) throw new TypeError('ChannelsBridgeServer: sessionKey required');
56
+ if (!sockSecret) throw new TypeError('ChannelsBridgeServer: sockSecret required');
57
+ this.sockPath = sockPath;
58
+ this.sessionKey = sessionKey;
59
+ this.sockSecret = sockSecret;
60
+ this.logger = logger;
61
+ this.label = label;
62
+
63
+ this.server = null;
64
+ this.conn = null; // current bridge connection (one per session)
65
+ this.authenticated = false;
66
+ }
67
+
68
+ /**
69
+ * Bind + listen on the unix socket with restrictive umask so the inode is
70
+ * created with mode 0o600 from birth (P1 #9 TOCTOU mitigation). Defensive
71
+ * chmod runs in the listen callback as belt-and-suspenders.
72
+ *
73
+ * @returns {Promise<void>}
74
+ */
75
+ async listen() {
76
+ return new Promise((resolve, reject) => {
77
+ try { fs.unlinkSync(this.sockPath); } catch {}
78
+
79
+ this.server = net.createServer({ allowHalfOpen: false }, conn => this._onConnect(conn));
80
+ this.server.on('error', err => {
81
+ this.logger.error?.(`[${this.label}] socket error: ${err.message}`);
82
+ this.emit('error', err);
83
+ });
84
+
85
+ const prevUmask = process.umask(0o077);
86
+ this.server.listen(this.sockPath, err => {
87
+ process.umask(prevUmask);
88
+ if (err) return reject(err);
89
+ try {
90
+ fs.chmodSync(this.sockPath, 0o600);
91
+ } catch (chmodErr) {
92
+ return reject(new Error(`failed to chmod 0600 ${this.sockPath}: ${chmodErr.message}`));
93
+ }
94
+ resolve();
95
+ });
96
+ });
97
+ }
98
+
99
+ /**
100
+ * Write a daemon→bridge message. Drops silently (with warn) if no live
101
+ * connection. Returns true if write was attempted, false if dropped.
102
+ */
103
+ writeMessage(obj) {
104
+ if (!this.conn || this.conn.destroyed) {
105
+ this.logger.warn?.(`[${this.label}] writeMessage — no live connection (kind=${obj?.kind})`);
106
+ return false;
107
+ }
108
+ try {
109
+ this.conn.write(JSON.stringify(obj) + '\n');
110
+ return true;
111
+ } catch (err) {
112
+ this.logger.warn?.(`[${this.label}] socket write failed: ${err.message}`);
113
+ return false;
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Forcibly destroy the bridge connection (used by the pong watchdog to
119
+ * trigger the normal close→drain→respawn chain).
120
+ */
121
+ destroyConnection() {
122
+ if (this.conn) try { this.conn.destroy(); } catch {}
123
+ }
124
+
125
+ /**
126
+ * Tear down the server + close the connection + unlink the socket file.
127
+ * Idempotent.
128
+ */
129
+ async close() {
130
+ if (this.conn) {
131
+ try { this.conn.end(); } catch {}
132
+ this.conn = null;
133
+ }
134
+ if (this.server) {
135
+ await new Promise(resolve => this.server.close(() => resolve()));
136
+ this.server = null;
137
+ }
138
+ try { fs.unlinkSync(this.sockPath); } catch {}
139
+ }
140
+
141
+ // ─── private ──────────────────────────────────────────────────────
142
+
143
+ _onConnect(conn) {
144
+ // Single bridge per session — reject second connections.
145
+ if (this.conn && !this.conn.destroyed) {
146
+ this.logger.warn?.(`[${this.label}] extra bridge connection rejected`);
147
+ try { conn.write(JSON.stringify({ kind: 'hello_reject', reason: 'already-connected' }) + '\n'); } catch {}
148
+ conn.end();
149
+ return;
150
+ }
151
+ this.conn = conn;
152
+ let buf = '';
153
+ let authenticated = false;
154
+
155
+ // utf8 setEncoding reassembles multibyte sequences split across data
156
+ // events (Node's internal StringDecoder). Without it, `buf += chunk`
157
+ // decodes each Buffer chunk independently and a char straddling the
158
+ // ~64KB chunk boundary becomes U+FFFD — silent corruption of large
159
+ // replies. Same class as the log-tail.js StringDecoder fix.
160
+ conn.setEncoding('utf8');
161
+ conn.on('data', chunk => {
162
+ buf += chunk;
163
+ let nl;
164
+ while ((nl = buf.indexOf('\n')) >= 0) {
165
+ const line = buf.slice(0, nl);
166
+ buf = buf.slice(nl + 1);
167
+ if (!line.trim()) continue;
168
+ let raw;
169
+ try { raw = JSON.parse(line); }
170
+ catch {
171
+ this.logger.warn?.(`[${this.label}] bad json from bridge: ${line.slice(0, 100)}`);
172
+ continue;
173
+ }
174
+
175
+ if (!authenticated) {
176
+ // Review F#7: harden the hello-handshake.
177
+ // 1. timingSafeEqual for the secret compare so a same-uid
178
+ // attacker can't byte-by-byte probe via response-timing.
179
+ // 2. ROTATE the secret after first successful auth (set to
180
+ // null) so a stale WATER_SOCK_SECRET leaked via
181
+ // /proc/<pid>/environ can't replay against this
182
+ // CliProcess after the legit bridge disconnects.
183
+ // The bridge process is one-shot per spawn anyway (it
184
+ // exits on socket close — see channels-bridge.mjs:109),
185
+ // so legitimate re-auth within one CliProcess
186
+ // instance never happens — only a hijacker would.
187
+ const verdict = this._verifyHelloAuth(raw);
188
+ if (verdict.ok) {
189
+ authenticated = true;
190
+ this.authenticated = true;
191
+ this.sockSecret = null; // invalidate — single-shot per instance
192
+ try { conn.write(JSON.stringify({ kind: 'hello_ack' }) + '\n'); } catch {}
193
+ continue;
194
+ }
195
+ this.logger.warn?.(`[${this.label}] hello rejected — reason=${verdict.reason}`);
196
+ try { conn.write(JSON.stringify({ kind: 'hello_reject', reason: 'auth' }) + '\n'); } catch {}
197
+ conn.end();
198
+ this.conn = null;
199
+ this.authenticated = false;
200
+ return;
201
+ }
202
+
203
+ // Post-auth: validate against schema, emit on success, drop+warn on fail.
204
+ const parsed = parseBridgeToDaemonMessage(raw);
205
+ if (!parsed.ok) {
206
+ this.logger.warn?.(
207
+ `[${this.label}] bridge msg schema invalid — ${parsed.error} — dropping`,
208
+ );
209
+ continue;
210
+ }
211
+ if (parsed.msg.kind === 'session_init') {
212
+ // session_init also signals the bridge is fully ready. Emit
213
+ // bridge-ready BEFORE the bridge-message so listeners that gate on
214
+ // bridge-ready can subscribe to the message stream.
215
+ this.emit('session-init', parsed.msg);
216
+ this.emit('bridge-ready');
217
+ continue;
218
+ }
219
+ if (parsed.msg.kind === 'mcp-ready') {
220
+ // 0.12 Phase 1.6: bridge signals that claude has finished
221
+ // registering it as an MCP server. Polygram gates send() on this
222
+ // (Finding 0.3.A — cold-spawn race).
223
+ this.emit('mcp-ready', parsed.msg);
224
+ continue;
225
+ }
226
+ this.emit('bridge-message', parsed.msg);
227
+ }
228
+ });
229
+
230
+ conn.on('close', () => {
231
+ if (this.conn === conn) {
232
+ this.conn = null;
233
+ this.authenticated = false;
234
+ this.emit('bridge-disconnected');
235
+ }
236
+ });
237
+
238
+ conn.on('error', err => {
239
+ this.logger.warn?.(`[${this.label}] bridge conn error: ${err.message}`);
240
+ });
241
+ }
242
+
243
+ /**
244
+ * Review F#7: hello-handshake verification, extracted as a pure method so it
245
+ * can be exercised in isolation. Returns `{ ok: true }` on accept or
246
+ * `{ ok: false, reason }` on reject. Uses crypto.timingSafeEqual for the
247
+ * secret compare and refuses if this.sockSecret has already been consumed
248
+ * (post-auth rotation).
249
+ *
250
+ * @param {object} raw — parsed bridge→daemon hello payload
251
+ * @returns {{ ok: true } | { ok: false, reason: string }}
252
+ */
253
+ _verifyHelloAuth(raw) {
254
+ if (this.sockSecret == null) {
255
+ return { ok: false, reason: 'secret-consumed' };
256
+ }
257
+ if (!raw || raw.kind !== 'hello') {
258
+ return { ok: false, reason: 'not-hello' };
259
+ }
260
+ if (raw.session_key !== this.sessionKey) {
261
+ return { ok: false, reason: 'wrong-session-key' };
262
+ }
263
+ if (typeof raw.secret !== 'string' || raw.secret.length === 0) {
264
+ return { ok: false, reason: 'no-secret' };
265
+ }
266
+ const a = Buffer.from(raw.secret, 'utf8');
267
+ const b = Buffer.from(this.sockSecret, 'utf8');
268
+ if (a.length !== b.length) {
269
+ // timingSafeEqual requires equal-length inputs; length mismatch is a
270
+ // wrong-secret signal but constant-time compares MUST short-circuit
271
+ // here (otherwise we'd leak the secret's length).
272
+ return { ok: false, reason: 'wrong-secret' };
273
+ }
274
+ if (!crypto.timingSafeEqual(a, b)) {
275
+ return { ok: false, reason: 'wrong-secret' };
276
+ }
277
+ return { ok: true };
278
+ }
279
+ }
280
+
281
+ module.exports = { ChannelsBridgeServer };