polygram 0.38.0 → 0.38.2

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,454 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ createHash,
5
+ createHmac,
6
+ timingSafeEqual,
7
+ } = require('node:crypto');
8
+ const {
9
+ getSessionKey,
10
+ getTopicName,
11
+ } = require('../session-key');
12
+
13
+ const SHA256_RE = /^[a-f0-9]{64}$/;
14
+ const TOKEN_RE = /^[a-f0-9]{64}$/;
15
+ const UUID_RE = /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i;
16
+ const PROVIDERS = new Set(['claude', 'codex']);
17
+ const LIVE_HANDLER_STATUSES = new Set(['dispatched', 'processing']);
18
+ const DEPLOY_REQUEST_KEYS = new Set([
19
+ 'op',
20
+ 'id',
21
+ 'secret',
22
+ 'foreground_expectation',
23
+ 'qualification_expectation',
24
+ ]);
25
+ const EXPECTATION_KEYS = [
26
+ 'schema_version',
27
+ 'daemon_instance_id',
28
+ 'pid',
29
+ 'provider',
30
+ 'configured_scope_sha256',
31
+ 'target_token',
32
+ ];
33
+ const PROBE_KEYS = [
34
+ 'op',
35
+ 'id',
36
+ 'secret',
37
+ 'provider',
38
+ 'configured_scope_sha256',
39
+ ];
40
+
41
+ function isPlainObject(value) {
42
+ return value != null
43
+ && typeof value === 'object'
44
+ && !Array.isArray(value)
45
+ && Object.getPrototypeOf(value) === Object.prototype;
46
+ }
47
+
48
+ function hasExactKeys(value, keys) {
49
+ if (!isPlainObject(value)) return false;
50
+ const actual = Object.keys(value);
51
+ return actual.length === keys.length
52
+ && keys.every((key) => Object.hasOwn(value, key));
53
+ }
54
+
55
+ function isBoundedString(value, maxBytes = 256) {
56
+ return typeof value === 'string'
57
+ && value.length > 0
58
+ && Buffer.byteLength(value, 'utf8') <= maxBytes
59
+ && !/[\x00-\x1f\x7f]/.test(value);
60
+ }
61
+
62
+ function validRequestId(value) {
63
+ return UUID_RE.test(value ?? '');
64
+ }
65
+
66
+ function configuredScopeSha256({
67
+ botName,
68
+ chatId,
69
+ threadId,
70
+ chatName,
71
+ topicName,
72
+ isolateTopics,
73
+ } = {}) {
74
+ return createHash('sha256').update(JSON.stringify([
75
+ String(botName),
76
+ String(chatId),
77
+ threadId == null ? null : String(threadId),
78
+ String(chatName),
79
+ topicName == null ? null : String(topicName),
80
+ isolateTopics === true,
81
+ ])).digest('hex');
82
+ }
83
+
84
+ function configuredScopes({ config, botName } = {}) {
85
+ const scopes = [];
86
+ for (const [chatId, chatConfig] of Object.entries(config?.chats ?? {})) {
87
+ if (!chatConfig || typeof chatConfig !== 'object') continue;
88
+ const base = {
89
+ botName,
90
+ chatId: String(chatId),
91
+ threadId: null,
92
+ chatName: String(chatConfig.name ?? ''),
93
+ topicName: null,
94
+ isolateTopics: chatConfig.isolateTopics === true,
95
+ sessionKey: getSessionKey(String(chatId), null, chatConfig),
96
+ };
97
+ scopes.push(Object.freeze({
98
+ ...base,
99
+ digest: configuredScopeSha256(base),
100
+ }));
101
+ for (const threadId of Object.keys(chatConfig.topics ?? {})) {
102
+ const topic = {
103
+ ...base,
104
+ threadId: String(threadId),
105
+ topicName: getTopicName(chatConfig, String(threadId)),
106
+ sessionKey: getSessionKey(String(chatId), String(threadId), chatConfig),
107
+ };
108
+ scopes.push(Object.freeze({
109
+ ...topic,
110
+ digest: configuredScopeSha256(topic),
111
+ }));
112
+ }
113
+ }
114
+ return scopes;
115
+ }
116
+
117
+ function resolveConfiguredScope({ config, botName, digest } = {}) {
118
+ if (!SHA256_RE.test(digest ?? '')) return null;
119
+ const matches = configuredScopes({ config, botName })
120
+ .filter((scope) => scope.digest === digest);
121
+ return matches.length === 1 ? matches[0] : null;
122
+ }
123
+
124
+ function normalizeDeployForegroundExpectation(request) {
125
+ if (!isPlainObject(request)) return null;
126
+ const keys = Object.keys(request);
127
+ const unknown = keys.some((key) => !DEPLOY_REQUEST_KEYS.has(key));
128
+ const present = Object.hasOwn(request, 'foreground_expectation');
129
+ if (!present && !unknown) return undefined;
130
+ if (
131
+ unknown
132
+ || request.op !== 'deploy_restart'
133
+ || !validRequestId(request.id)
134
+ || !isBoundedString(request.secret, 1024)
135
+ ) return null;
136
+
137
+ const expectation = request.foreground_expectation;
138
+ if (
139
+ !hasExactKeys(expectation, EXPECTATION_KEYS)
140
+ || expectation.schema_version !== 1
141
+ || !UUID_RE.test(expectation.daemon_instance_id ?? '')
142
+ || !Number.isSafeInteger(expectation.pid)
143
+ || expectation.pid <= 0
144
+ || !PROVIDERS.has(expectation.provider)
145
+ || !SHA256_RE.test(expectation.configured_scope_sha256 ?? '')
146
+ || !TOKEN_RE.test(expectation.target_token ?? '')
147
+ ) {
148
+ return null;
149
+ }
150
+ return Object.freeze({
151
+ daemonInstanceId: expectation.daemon_instance_id,
152
+ pid: expectation.pid,
153
+ provider: expectation.provider,
154
+ configuredScopeSha256: expectation.configured_scope_sha256,
155
+ targetToken: expectation.target_token,
156
+ });
157
+ }
158
+
159
+ function normalizedActiveTarget(value) {
160
+ if (!isPlainObject(value)) return null;
161
+ const sessionKey = value.sessionKey;
162
+ const chatId = value.chatId;
163
+ const threadId = value.threadId;
164
+ const telegramMessageId = value.telegramMessageId;
165
+ if (
166
+ !isBoundedString(sessionKey, 512)
167
+ || !isBoundedString(chatId, 128)
168
+ || !(threadId === null || isBoundedString(threadId, 128))
169
+ || !isBoundedString(telegramMessageId, 128)
170
+ ) return null;
171
+ return {
172
+ sessionKey,
173
+ chatId,
174
+ threadId,
175
+ telegramMessageId,
176
+ };
177
+ }
178
+
179
+ function normalizedSelectedTarget(value) {
180
+ if (!isPlainObject(value)) return null;
181
+ if (
182
+ !Number.isSafeInteger(value.messageId)
183
+ || value.messageId <= 0
184
+ || !isBoundedString(value.chatId, 128)
185
+ || !(value.threadId === null || isBoundedString(value.threadId, 128))
186
+ || !isBoundedString(value.telegramMessageId, 128)
187
+ || !isBoundedString(value.sessionKey, 512)
188
+ || !PROVIDERS.has(value.provider)
189
+ || !isBoundedString(value.handlerStatus, 64)
190
+ ) return null;
191
+ return {
192
+ messageId: value.messageId,
193
+ chatId: value.chatId,
194
+ threadId: value.threadId,
195
+ telegramMessageId: value.telegramMessageId,
196
+ sessionKey: value.sessionKey,
197
+ provider: value.provider,
198
+ handlerStatus: value.handlerStatus,
199
+ };
200
+ }
201
+
202
+ function sameTarget(left, right) {
203
+ return left.sessionKey === right.sessionKey
204
+ && left.chatId === right.chatId
205
+ && left.threadId === right.threadId
206
+ && left.telegramMessageId === right.telegramMessageId;
207
+ }
208
+
209
+ function sameBinding(left, right) {
210
+ return sameTarget(left, right)
211
+ && left.messageId === right.messageId
212
+ && left.provider === right.provider
213
+ && left.scopeDigest === right.scopeDigest;
214
+ }
215
+
216
+ function safeTokenEqual(left, right) {
217
+ if (!TOKEN_RE.test(left ?? '') || !TOKEN_RE.test(right ?? '')) return false;
218
+ return timingSafeEqual(Buffer.from(left), Buffer.from(right));
219
+ }
220
+
221
+ function createForegroundCanaryAuthorizer({
222
+ botName,
223
+ daemonIdentity,
224
+ config,
225
+ tokenSecret,
226
+ getActiveHandlerCount,
227
+ getActiveHandlerTargets,
228
+ getForegroundCanaryTarget,
229
+ now = Date.now,
230
+ tokenTtlMs = 300_000,
231
+ tokenLimit = 64,
232
+ } = {}) {
233
+ if (!isBoundedString(botName, 128)) {
234
+ throw new TypeError('foreground canary bot name is required');
235
+ }
236
+ if (
237
+ !isPlainObject(daemonIdentity)
238
+ || !UUID_RE.test(daemonIdentity.daemon_instance_id ?? '')
239
+ || !Number.isSafeInteger(daemonIdentity.pid)
240
+ || daemonIdentity.pid <= 0
241
+ || !isBoundedString(daemonIdentity.package_version, 128)
242
+ ) {
243
+ throw new TypeError('foreground canary daemon identity is invalid');
244
+ }
245
+ if (!isBoundedString(tokenSecret, 1024)) {
246
+ throw new TypeError('foreground canary token secret is required');
247
+ }
248
+ if (typeof getActiveHandlerCount !== 'function') {
249
+ throw new TypeError('foreground canary active-handler count reader is required');
250
+ }
251
+ if (typeof getActiveHandlerTargets !== 'function') {
252
+ throw new TypeError('foreground canary active-handler reader is required');
253
+ }
254
+ if (typeof getForegroundCanaryTarget !== 'function') {
255
+ throw new TypeError('foreground canary database reader is required');
256
+ }
257
+ if (!Number.isSafeInteger(tokenTtlMs) || tokenTtlMs <= 0) {
258
+ throw new TypeError('foreground canary token TTL is invalid');
259
+ }
260
+ if (!Number.isSafeInteger(tokenLimit) || tokenLimit <= 0) {
261
+ throw new TypeError('foreground canary token limit is invalid');
262
+ }
263
+
264
+ const tokens = new Map();
265
+
266
+ function reject(rejectionCode) {
267
+ return { rejectionCode };
268
+ }
269
+
270
+ function inspectLiveTarget({ provider, scopeDigest }) {
271
+ const scope = resolveConfiguredScope({ config, botName, digest: scopeDigest });
272
+ if (!scope) return reject('configured-scope-mismatch');
273
+
274
+ let activeCount;
275
+ let active;
276
+ try {
277
+ activeCount = getActiveHandlerCount();
278
+ active = getActiveHandlerTargets();
279
+ } catch {
280
+ return reject('target-unavailable');
281
+ }
282
+ if (activeCount !== 1 || !Array.isArray(active) || active.length !== 1) {
283
+ return reject('daemon-busy');
284
+ }
285
+ const activeTarget = normalizedActiveTarget(active[0]);
286
+ if (!activeTarget) return reject('source-mismatch');
287
+ if (
288
+ activeTarget.chatId !== scope.chatId
289
+ || activeTarget.threadId !== scope.threadId
290
+ || activeTarget.sessionKey !== scope.sessionKey
291
+ ) {
292
+ return reject('source-mismatch');
293
+ }
294
+
295
+ let selected;
296
+ try {
297
+ selected = normalizedSelectedTarget(getForegroundCanaryTarget({
298
+ botName,
299
+ chatId: activeTarget.chatId,
300
+ telegramMessageId: activeTarget.telegramMessageId,
301
+ }));
302
+ } catch {
303
+ return reject('target-unavailable');
304
+ }
305
+ if (!selected) return reject('target-not-live');
306
+ if (!sameTarget(activeTarget, selected)) return reject('source-mismatch');
307
+ if (selected.provider !== provider) return reject('provider-mismatch');
308
+ if (!LIVE_HANDLER_STATUSES.has(selected.handlerStatus)) {
309
+ return reject('target-not-live');
310
+ }
311
+ return {
312
+ binding: Object.freeze({
313
+ ...selected,
314
+ scopeDigest,
315
+ }),
316
+ };
317
+ }
318
+
319
+ function probe(request) {
320
+ if (
321
+ !hasExactKeys(request, PROBE_KEYS)
322
+ || request.op !== 'foreground_canary_target'
323
+ || !validRequestId(request.id)
324
+ || !isBoundedString(request.secret, 1024)
325
+ || !PROVIDERS.has(request.provider)
326
+ || !SHA256_RE.test(request.configured_scope_sha256 ?? '')
327
+ ) {
328
+ return {
329
+ outcome: 'rejected',
330
+ rejection_code: 'invalid-request',
331
+ };
332
+ }
333
+
334
+ const issuedAt = now();
335
+ for (const [requestId, stored] of tokens) {
336
+ if (issuedAt > stored.expiresAt) tokens.delete(requestId);
337
+ }
338
+ if (!tokens.has(request.id) && tokens.size >= tokenLimit) {
339
+ return {
340
+ outcome: 'rejected',
341
+ rejection_code: 'target-unavailable',
342
+ };
343
+ }
344
+
345
+ const inspected = inspectLiveTarget({
346
+ provider: request.provider,
347
+ scopeDigest: request.configured_scope_sha256,
348
+ });
349
+ if (!inspected.binding) {
350
+ return {
351
+ outcome: 'rejected',
352
+ rejection_code: inspected.rejectionCode,
353
+ };
354
+ }
355
+
356
+ const token = createHmac('sha256', tokenSecret).update(JSON.stringify([
357
+ request.id,
358
+ daemonIdentity.daemon_instance_id,
359
+ daemonIdentity.pid,
360
+ inspected.binding.provider,
361
+ inspected.binding.scopeDigest,
362
+ inspected.binding.sessionKey,
363
+ inspected.binding.messageId,
364
+ inspected.binding.telegramMessageId,
365
+ issuedAt,
366
+ ])).digest('hex');
367
+ tokens.set(request.id, Object.freeze({
368
+ token,
369
+ binding: inspected.binding,
370
+ expiresAt: issuedAt + tokenTtlMs,
371
+ }));
372
+ return {
373
+ outcome: 'live',
374
+ bot: botName,
375
+ daemon_instance_id: daemonIdentity.daemon_instance_id,
376
+ pid: daemonIdentity.pid,
377
+ package_version: daemonIdentity.package_version,
378
+ provider: request.provider,
379
+ configured_scope_sha256: request.configured_scope_sha256,
380
+ target_token: token,
381
+ };
382
+ }
383
+
384
+ function authorizeRestart({ requestId, expectation } = {}) {
385
+ if (!validRequestId(requestId) || !isPlainObject(expectation)) {
386
+ return { accepted: false, rejectionCode: 'invalid-request' };
387
+ }
388
+ if (
389
+ expectation.daemonInstanceId !== daemonIdentity.daemon_instance_id
390
+ || expectation.pid !== daemonIdentity.pid
391
+ ) {
392
+ return { accepted: false, rejectionCode: 'daemon-identity-mismatch' };
393
+ }
394
+
395
+ const stored = tokens.get(requestId);
396
+ if (!stored || !safeTokenEqual(stored.token, expectation.targetToken)) {
397
+ return { accepted: false, rejectionCode: 'target-token-invalid' };
398
+ }
399
+ if (now() > stored.expiresAt) {
400
+ tokens.delete(requestId);
401
+ return { accepted: false, rejectionCode: 'target-token-expired' };
402
+ }
403
+ if (
404
+ expectation.provider !== stored.binding.provider
405
+ || expectation.configuredScopeSha256 !== stored.binding.scopeDigest
406
+ ) {
407
+ tokens.delete(requestId);
408
+ return { accepted: false, rejectionCode: 'target-binding-mismatch' };
409
+ }
410
+
411
+ const inspected = inspectLiveTarget({
412
+ provider: expectation.provider,
413
+ scopeDigest: expectation.configuredScopeSha256,
414
+ });
415
+ if (!inspected.binding) {
416
+ tokens.delete(requestId);
417
+ return { accepted: false, rejectionCode: inspected.rejectionCode };
418
+ }
419
+ if (!sameBinding(stored.binding, inspected.binding)) {
420
+ tokens.delete(requestId);
421
+ return { accepted: false, rejectionCode: 'source-mismatch' };
422
+ }
423
+
424
+ tokens.delete(requestId);
425
+ return {
426
+ accepted: true,
427
+ authorizationEvent: Object.freeze({
428
+ bot: botName,
429
+ daemon_instance_id: daemonIdentity.daemon_instance_id,
430
+ pid: daemonIdentity.pid,
431
+ package_version: daemonIdentity.package_version,
432
+ provider: stored.binding.provider,
433
+ configured_scope_sha256: stored.binding.scopeDigest,
434
+ session_key: stored.binding.sessionKey,
435
+ source_message_id: stored.binding.messageId,
436
+ restart_request_sha256: createHash('sha256')
437
+ .update(requestId)
438
+ .digest('hex'),
439
+ }),
440
+ };
441
+ }
442
+
443
+ return Object.freeze({
444
+ probe,
445
+ authorizeRestart,
446
+ });
447
+ }
448
+
449
+ module.exports = {
450
+ configuredScopeSha256,
451
+ createForegroundCanaryAuthorizer,
452
+ normalizeDeployForegroundExpectation,
453
+ resolveConfiguredScope,
454
+ };
@@ -9,11 +9,18 @@ function parseSystemdInvocationId(env = process.env) {
9
9
  return value;
10
10
  }
11
11
 
12
- function lifecycleDetail(detail, invocationId) {
12
+ function lifecycleDetail(detail, invocationId, daemonIdentity = null) {
13
13
  const value = detail && typeof detail === 'object' ? detail : {};
14
- return invocationId == null
14
+ const withDaemonIdentity = daemonIdentity == null
15
15
  ? value
16
- : { ...value, invocation_id: invocationId };
16
+ : {
17
+ ...value,
18
+ daemon_instance_id: daemonIdentity.daemon_instance_id,
19
+ pid: daemonIdentity.pid,
20
+ };
21
+ return invocationId == null
22
+ ? withDaemonIdentity
23
+ : { ...withDaemonIdentity, invocation_id: invocationId };
17
24
  }
18
25
 
19
26
  module.exports = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.38.0",
3
+ "version": "0.38.2",
4
4
  "description": "Telegram daemon for Claude Code that preserves the OpenClaw per-chat session model. Migration path for OpenClaw users moving to Claude Code.",
5
5
  "main": "lib/ipc/client.js",
6
6
  "bin": {
@@ -63,7 +63,7 @@
63
63
  "dependencies": {
64
64
  "@anthropic-ai/claude-agent-sdk": "0.2.123",
65
65
  "@modelcontextprotocol/sdk": "^1.29.0",
66
- "@shumkov/orchestra": "0.10.14",
66
+ "@shumkov/orchestra": "0.10.16",
67
67
  "better-sqlite3": "^12.9.0",
68
68
  "grammy": "^1.42.0",
69
69
  "markdown-it": "^14.1.1",