polygram 0.17.10 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/config.js +21 -0
- package/lib/error/classify.js +14 -0
- package/lib/handlers/dispatcher.js +54 -1
- package/lib/handlers/drop-redeliver.js +19 -0
- package/lib/handlers/should-handle.js +52 -0
- package/lib/media-group-buffer.js +29 -0
- package/lib/ops/auth-disabled-gate.js +43 -0
- package/lib/ops/heartbeat.js +56 -0
- package/lib/process/channels-tool-dispatcher.js +12 -1
- package/lib/prompt.js +22 -7
- package/lib/sdk/callbacks.js +2 -1
- package/lib/secret-detect.js +13 -1
- package/lib/telegram/chunk.js +20 -6
- package/lib/telegram/process-agent-reply.js +5 -3
- package/lib/telegram/streamer.js +6 -1
- package/package.json +2 -1
- package/polygram.js +202 -45
- package/lib/async-lock.js +0 -49
- package/lib/claude-bin.js +0 -246
- package/lib/compaction-warn.js +0 -59
- package/lib/context-usage.js +0 -93
- package/lib/process/channels-bridge-protocol.js +0 -199
- package/lib/process/channels-bridge-server.js +0 -274
- package/lib/process/channels-bridge.mjs +0 -477
- package/lib/process/cli-process.js +0 -4029
- package/lib/process/factory.js +0 -215
- package/lib/process/hook-event-tail.js +0 -162
- package/lib/process/hook-settings.js +0 -181
- package/lib/process/polygram-hook-append.js +0 -71
- package/lib/process/process.js +0 -215
- package/lib/process/sdk-process.js +0 -880
- package/lib/process-guard.js +0 -296
- package/lib/process-manager.js +0 -628
- package/lib/tmux/log-tail.js +0 -334
- package/lib/tmux/orphan-sweep.js +0 -79
- package/lib/tmux/poll-scheduler.js +0 -110
- package/lib/tmux/startup-gate.js +0 -250
- 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 };
|