polygram 0.17.11 → 0.17.12

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.
Files changed (37) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/lib/error/classify.js +14 -0
  3. package/lib/handlers/dispatcher.js +54 -1
  4. package/lib/handlers/drop-redeliver.js +19 -0
  5. package/lib/handlers/should-handle.js +52 -0
  6. package/lib/media-group-buffer.js +29 -0
  7. package/lib/ops/auth-disabled-gate.js +43 -0
  8. package/lib/ops/heartbeat.js +56 -0
  9. package/lib/process/channels-tool-dispatcher.js +12 -1
  10. package/lib/sdk/callbacks.js +2 -1
  11. package/lib/secret-detect.js +13 -1
  12. package/lib/telegram/chunk.js +20 -6
  13. package/lib/telegram/process-agent-reply.js +5 -3
  14. package/lib/telegram/streamer.js +6 -1
  15. package/package.json +2 -1
  16. package/polygram.js +188 -41
  17. package/lib/async-lock.js +0 -49
  18. package/lib/claude-bin.js +0 -246
  19. package/lib/compaction-warn.js +0 -59
  20. package/lib/context-usage.js +0 -93
  21. package/lib/process/channels-bridge-protocol.js +0 -199
  22. package/lib/process/channels-bridge-server.js +0 -274
  23. package/lib/process/channels-bridge.mjs +0 -477
  24. package/lib/process/cli-process.js +0 -4029
  25. package/lib/process/factory.js +0 -215
  26. package/lib/process/hook-event-tail.js +0 -162
  27. package/lib/process/hook-settings.js +0 -181
  28. package/lib/process/polygram-hook-append.js +0 -71
  29. package/lib/process/process.js +0 -215
  30. package/lib/process/sdk-process.js +0 -880
  31. package/lib/process-guard.js +0 -296
  32. package/lib/process-manager.js +0 -628
  33. package/lib/tmux/log-tail.js +0 -334
  34. package/lib/tmux/orphan-sweep.js +0 -79
  35. package/lib/tmux/poll-scheduler.js +0 -110
  36. package/lib/tmux/startup-gate.js +0 -250
  37. package/lib/tmux/tmux-runner.js +0 -412
@@ -1,274 +0,0 @@
1
- /**
2
- * ChannelsBridgeServer — per-session unix-socket server for the bridge
3
- * subprocess to connect back to.
4
- *
5
- * Extracted from CliProcess (M1 refactor) so the socket lifecycle —
6
- * listen with restrictive umask, accept ONE bridge, hello-handshake auth,
7
- * line-delimited JSON I/O, schema validation, single-bridge-per-session
8
- * enforcement, clean teardown — lives in one focused class instead of
9
- * sprawling across CliProcess.
10
- *
11
- * Owns:
12
- * - net.Server lifecycle (listen / close)
13
- * - socket file mode (0o600 via umask wrap + defensive chmod)
14
- * - bridge connection state (single connection accepted)
15
- * - hello-handshake secret verification
16
- * - line-buffer + JSON parse + zod schema validation (channels-bridge-protocol)
17
- *
18
- * Does NOT own:
19
- * - protocol semantics (tool routing, perm relay, turn lifecycle) — those
20
- * stay in CliProcess, which subscribes to the events this class emits
21
- * - claude/bridge process lifecycle
22
- *
23
- * Event surface (EventEmitter):
24
- * 'bridge-ready' — daemon-side handshake (hello + session_init) complete
25
- * 'mcp-ready' — claude-side MCP-server registration complete (first
26
- * ListToolsRequest received from claude). 0.12 P1.6
27
- * cold-spawn race fix — see channels-bridge.mjs.
28
- * 'bridge-message', msg — every validated bridge→daemon message (post-auth)
29
- * 'bridge-disconnected' — single-bridge connection closed
30
- * 'error', err — socket-level errors (rare; non-fatal)
31
- */
32
-
33
- 'use strict';
34
-
35
- const crypto = require('node:crypto');
36
- const EventEmitter = require('node:events');
37
- const fs = require('node:fs');
38
- const net = require('node:net');
39
-
40
- const { parseBridgeToDaemonMessage } = require('./channels-bridge-protocol');
41
-
42
- class ChannelsBridgeServer extends EventEmitter {
43
- /**
44
- * @param {object} opts
45
- * @param {string} opts.sockPath
46
- * @param {string} opts.sessionKey — bridge must echo this in hello
47
- * @param {string} opts.sockSecret — bridge must present this in hello
48
- * @param {object} [opts.logger=console]
49
- * @param {string} [opts.label='channels-bridge-server']
50
- */
51
- constructor({ sockPath, sessionKey, sockSecret, logger = console, label = 'channels-bridge-server' } = {}) {
52
- super();
53
- if (!sockPath) throw new TypeError('ChannelsBridgeServer: sockPath required');
54
- if (!sessionKey) throw new TypeError('ChannelsBridgeServer: sessionKey required');
55
- if (!sockSecret) throw new TypeError('ChannelsBridgeServer: sockSecret required');
56
- this.sockPath = sockPath;
57
- this.sessionKey = sessionKey;
58
- this.sockSecret = sockSecret;
59
- this.logger = logger;
60
- this.label = label;
61
-
62
- this.server = null;
63
- this.conn = null; // current bridge connection (one per session)
64
- this.authenticated = false;
65
- }
66
-
67
- /**
68
- * Bind + listen on the unix socket with restrictive umask so the inode is
69
- * created with mode 0o600 from birth (P1 #9 TOCTOU mitigation). Defensive
70
- * chmod runs in the listen callback as belt-and-suspenders.
71
- *
72
- * @returns {Promise<void>}
73
- */
74
- async listen() {
75
- return new Promise((resolve, reject) => {
76
- try { fs.unlinkSync(this.sockPath); } catch {}
77
-
78
- this.server = net.createServer({ allowHalfOpen: false }, conn => this._onConnect(conn));
79
- this.server.on('error', err => {
80
- this.logger.error?.(`[${this.label}] socket error: ${err.message}`);
81
- this.emit('error', err);
82
- });
83
-
84
- const prevUmask = process.umask(0o077);
85
- this.server.listen(this.sockPath, err => {
86
- process.umask(prevUmask);
87
- if (err) return reject(err);
88
- try {
89
- fs.chmodSync(this.sockPath, 0o600);
90
- } catch (chmodErr) {
91
- return reject(new Error(`failed to chmod 0600 ${this.sockPath}: ${chmodErr.message}`));
92
- }
93
- resolve();
94
- });
95
- });
96
- }
97
-
98
- /**
99
- * Write a daemon→bridge message. Drops silently (with warn) if no live
100
- * connection. Returns true if write was attempted, false if dropped.
101
- */
102
- writeMessage(obj) {
103
- if (!this.conn || this.conn.destroyed) {
104
- this.logger.warn?.(`[${this.label}] writeMessage — no live connection (kind=${obj?.kind})`);
105
- return false;
106
- }
107
- try {
108
- this.conn.write(JSON.stringify(obj) + '\n');
109
- return true;
110
- } catch (err) {
111
- this.logger.warn?.(`[${this.label}] socket write failed: ${err.message}`);
112
- return false;
113
- }
114
- }
115
-
116
- /**
117
- * Forcibly destroy the bridge connection (used by the pong watchdog to
118
- * trigger the normal close→drain→respawn chain).
119
- */
120
- destroyConnection() {
121
- if (this.conn) try { this.conn.destroy(); } catch {}
122
- }
123
-
124
- /**
125
- * Tear down the server + close the connection + unlink the socket file.
126
- * Idempotent.
127
- */
128
- async close() {
129
- if (this.conn) {
130
- try { this.conn.end(); } catch {}
131
- this.conn = null;
132
- }
133
- if (this.server) {
134
- await new Promise(resolve => this.server.close(() => resolve()));
135
- this.server = null;
136
- }
137
- try { fs.unlinkSync(this.sockPath); } catch {}
138
- }
139
-
140
- // ─── private ──────────────────────────────────────────────────────
141
-
142
- _onConnect(conn) {
143
- // Single bridge per session — reject second connections.
144
- if (this.conn && !this.conn.destroyed) {
145
- this.logger.warn?.(`[${this.label}] extra bridge connection rejected`);
146
- try { conn.write(JSON.stringify({ kind: 'hello_reject', reason: 'already-connected' }) + '\n'); } catch {}
147
- conn.end();
148
- return;
149
- }
150
- this.conn = conn;
151
- let buf = '';
152
- let authenticated = false;
153
-
154
- conn.on('data', chunk => {
155
- buf += chunk;
156
- let nl;
157
- while ((nl = buf.indexOf('\n')) >= 0) {
158
- const line = buf.slice(0, nl);
159
- buf = buf.slice(nl + 1);
160
- if (!line.trim()) continue;
161
- let raw;
162
- try { raw = JSON.parse(line); }
163
- catch {
164
- this.logger.warn?.(`[${this.label}] bad json from bridge: ${line.slice(0, 100)}`);
165
- continue;
166
- }
167
-
168
- if (!authenticated) {
169
- // Review F#7: harden the hello-handshake.
170
- // 1. timingSafeEqual for the secret compare so a same-uid
171
- // attacker can't byte-by-byte probe via response-timing.
172
- // 2. ROTATE the secret after first successful auth (set to
173
- // null) so a stale POLYGRAM_SOCK_SECRET leaked via
174
- // /proc/<pid>/environ can't replay against this
175
- // CliProcess after the legit bridge disconnects.
176
- // The bridge process is one-shot per spawn anyway (it
177
- // exits on socket close — see channels-bridge.mjs:109),
178
- // so legitimate re-auth within one CliProcess
179
- // instance never happens — only a hijacker would.
180
- const verdict = this._verifyHelloAuth(raw);
181
- if (verdict.ok) {
182
- authenticated = true;
183
- this.authenticated = true;
184
- this.sockSecret = null; // invalidate — single-shot per instance
185
- try { conn.write(JSON.stringify({ kind: 'hello_ack' }) + '\n'); } catch {}
186
- continue;
187
- }
188
- this.logger.warn?.(`[${this.label}] hello rejected — reason=${verdict.reason}`);
189
- try { conn.write(JSON.stringify({ kind: 'hello_reject', reason: 'auth' }) + '\n'); } catch {}
190
- conn.end();
191
- this.conn = null;
192
- this.authenticated = false;
193
- return;
194
- }
195
-
196
- // Post-auth: validate against schema, emit on success, drop+warn on fail.
197
- const parsed = parseBridgeToDaemonMessage(raw);
198
- if (!parsed.ok) {
199
- this.logger.warn?.(
200
- `[${this.label}] bridge msg schema invalid — ${parsed.error} — dropping`,
201
- );
202
- continue;
203
- }
204
- if (parsed.msg.kind === 'session_init') {
205
- // session_init also signals the bridge is fully ready. Emit
206
- // bridge-ready BEFORE the bridge-message so listeners that gate on
207
- // bridge-ready can subscribe to the message stream.
208
- this.emit('session-init', parsed.msg);
209
- this.emit('bridge-ready');
210
- continue;
211
- }
212
- if (parsed.msg.kind === 'mcp-ready') {
213
- // 0.12 Phase 1.6: bridge signals that claude has finished
214
- // registering it as an MCP server. Polygram gates send() on this
215
- // (Finding 0.3.A — cold-spawn race).
216
- this.emit('mcp-ready', parsed.msg);
217
- continue;
218
- }
219
- this.emit('bridge-message', parsed.msg);
220
- }
221
- });
222
-
223
- conn.on('close', () => {
224
- if (this.conn === conn) {
225
- this.conn = null;
226
- this.authenticated = false;
227
- this.emit('bridge-disconnected');
228
- }
229
- });
230
-
231
- conn.on('error', err => {
232
- this.logger.warn?.(`[${this.label}] bridge conn error: ${err.message}`);
233
- });
234
- }
235
-
236
- /**
237
- * Review F#7: hello-handshake verification, extracted as a pure method so it
238
- * can be exercised in isolation. Returns `{ ok: true }` on accept or
239
- * `{ ok: false, reason }` on reject. Uses crypto.timingSafeEqual for the
240
- * secret compare and refuses if this.sockSecret has already been consumed
241
- * (post-auth rotation).
242
- *
243
- * @param {object} raw — parsed bridge→daemon hello payload
244
- * @returns {{ ok: true } | { ok: false, reason: string }}
245
- */
246
- _verifyHelloAuth(raw) {
247
- if (this.sockSecret == null) {
248
- return { ok: false, reason: 'secret-consumed' };
249
- }
250
- if (!raw || raw.kind !== 'hello') {
251
- return { ok: false, reason: 'not-hello' };
252
- }
253
- if (raw.session_key !== this.sessionKey) {
254
- return { ok: false, reason: 'wrong-session-key' };
255
- }
256
- if (typeof raw.secret !== 'string' || raw.secret.length === 0) {
257
- return { ok: false, reason: 'no-secret' };
258
- }
259
- const a = Buffer.from(raw.secret, 'utf8');
260
- const b = Buffer.from(this.sockSecret, 'utf8');
261
- if (a.length !== b.length) {
262
- // timingSafeEqual requires equal-length inputs; length mismatch is a
263
- // wrong-secret signal but constant-time compares MUST short-circuit
264
- // here (otherwise we'd leak the secret's length).
265
- return { ok: false, reason: 'wrong-secret' };
266
- }
267
- if (!crypto.timingSafeEqual(a, b)) {
268
- return { ok: false, reason: 'wrong-secret' };
269
- }
270
- return { ok: true };
271
- }
272
- }
273
-
274
- module.exports = { ChannelsBridgeServer };