polygram 0.38.1 → 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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://anthropic.com/claude-code/plugin.schema.json",
3
3
  "name": "polygram",
4
- "version": "0.38.1",
4
+ "version": "0.38.2",
5
5
  "description": "Telegram integration for Claude Code that preserves the OpenClaw per-chat session model. Migration target for OpenClaw users. Multi-bot, multi-chat, per-topic isolation; SQLite transcripts; inline-keyboard approvals. Bundles /polygram:status|logs|pair-code|approvals admin commands plus history (transcript queries) and polygram-send (out-of-turn IPC sends with file-upload validation) skills.",
6
6
  "keywords": [
7
7
  "telegram",
package/lib/db.js CHANGED
@@ -462,6 +462,25 @@ function wrap(db) {
462
462
  ORDER BY id DESC LIMIT 1
463
463
  `);
464
464
 
465
+ const getForegroundCanaryTargetStmt = db.prepare(`
466
+ SELECT message.id AS message_id,
467
+ message.chat_id,
468
+ message.thread_id,
469
+ selection.telegram_message_id,
470
+ message.handler_status,
471
+ selection.session_key,
472
+ selection.provider
473
+ FROM messages AS message
474
+ JOIN inbound_runtime_selections AS selection
475
+ ON selection.bot_name = message.bot_name
476
+ AND selection.telegram_chat_id = message.chat_id
477
+ AND selection.telegram_message_id = ?
478
+ WHERE message.direction = 'in'
479
+ AND message.bot_name = ?
480
+ AND message.chat_id = ?
481
+ AND message.msg_id = ?
482
+ `);
483
+
465
484
  const setMessageTextStmt = db.prepare(`
466
485
  UPDATE messages
467
486
  SET text = @text
@@ -4698,6 +4717,32 @@ function wrap(db) {
4698
4717
  `).get(messageId);
4699
4718
  },
4700
4719
 
4720
+ getForegroundCanaryTarget({ botName, chatId, telegramMessageId }) {
4721
+ const messageIdText = String(telegramMessageId);
4722
+ const messageId = Number(messageIdText);
4723
+ if (
4724
+ !Number.isSafeInteger(messageId)
4725
+ || messageId <= 0
4726
+ || String(messageId) !== messageIdText
4727
+ ) return null;
4728
+ const row = getForegroundCanaryTargetStmt.get(
4729
+ messageIdText,
4730
+ String(botName),
4731
+ String(chatId),
4732
+ messageId,
4733
+ );
4734
+ if (!row) return null;
4735
+ return {
4736
+ messageId: row.message_id,
4737
+ chatId: String(row.chat_id),
4738
+ threadId: row.thread_id == null ? null : String(row.thread_id),
4739
+ telegramMessageId: row.telegram_message_id,
4740
+ sessionKey: row.session_key,
4741
+ provider: row.provider,
4742
+ handlerStatus: row.handler_status,
4743
+ };
4744
+ },
4745
+
4701
4746
  listFailedAttachments({ since = null, limit = 100 } = {}) {
4702
4747
  const cutoff = since ?? Date.now() - 24 * 60 * 60 * 1000;
4703
4748
  return db.prepare(`
@@ -77,6 +77,7 @@ function createDispatcher({
77
77
  // Per-session in-flight handler count.
78
78
  const inFlightHandlers = new Map();
79
79
  const activeHandlers = new Set();
80
+ const activeHandlerTargets = new Set();
80
81
 
81
82
  function trackTask(task) {
82
83
  activeHandlers.add(task);
@@ -265,6 +266,15 @@ function createDispatcher({
265
266
  }
266
267
 
267
268
  function dispatchHandleMessage(sessionKey, chatId, msg, bot) {
269
+ const activeHandlerTarget = Object.freeze({
270
+ sessionKey: String(sessionKey),
271
+ chatId: String(chatId),
272
+ threadId: msg?.message_thread_id == null
273
+ ? null
274
+ : String(msg.message_thread_id),
275
+ telegramMessageId: String(msg?.message_id),
276
+ });
277
+ activeHandlerTargets.add(activeHandlerTarget);
268
278
  const count = (inFlightHandlers.get(sessionKey) || 0) + 1;
269
279
  inFlightHandlers.set(sessionKey, count);
270
280
  const warnAt = queueWarnThreshold();
@@ -562,6 +572,7 @@ function createDispatcher({
562
572
  }
563
573
  }
564
574
  }).finally(() => {
575
+ activeHandlerTargets.delete(activeHandlerTarget);
565
576
  const n = (inFlightHandlers.get(sessionKey) || 1) - 1;
566
577
  if (n <= 0) inFlightHandlers.delete(sessionKey);
567
578
  else inFlightHandlers.set(sessionKey, n);
@@ -575,6 +586,8 @@ function createDispatcher({
575
586
  errorReplyText,
576
587
  queueWarnThreshold,
577
588
  inFlightHandlers, // exposed so polygram.js can introspect for shutdown drain
589
+ getActiveHandlerCount: () => activeHandlers.size,
590
+ getActiveHandlerTargets: () => [...activeHandlerTargets],
578
591
  awaitSettlement,
579
592
  trackTask,
580
593
  };
@@ -22,6 +22,7 @@ const {
22
22
  * reload, so a value bound at wiring time can go stale.
23
23
  * @param {(req) => any} opts.handleSendOverIpc
24
24
  * @param {(req) => any} opts.requestDeployRestart
25
+ * @param {(req) => any} opts.requestForegroundCanaryTarget
25
26
  * @param {() => any} opts.inspectCleanRestartQualification
26
27
  */
27
28
  function createIpcHandlers({
@@ -30,6 +31,7 @@ function createIpcHandlers({
30
31
  getInFlightHandlers,
31
32
  handleSendOverIpc,
32
33
  requestDeployRestart,
34
+ requestForegroundCanaryTarget,
33
35
  inspectCleanRestartQualification,
34
36
  } = {}) {
35
37
  return {
@@ -46,6 +48,8 @@ function createIpcHandlers({
46
48
 
47
49
  deploy_restart: (req) => requestDeployRestart(req),
48
50
 
51
+ foreground_canary_target: (req) => requestForegroundCanaryTarget(req),
52
+
49
53
  clean_restart_qualification: async (req) => {
50
54
  assertClosedQualificationRequest(req);
51
55
  let observation = null;
@@ -130,6 +130,7 @@ function normalizeDeployQualificationExpectation(request) {
130
130
  const requestKeys = Object.keys(request);
131
131
  const allowedRequestKeys = new Set([
132
132
  'op', 'id', 'secret', 'qualification_expectation',
133
+ 'foreground_expectation',
133
134
  ]);
134
135
  const hasUnknownRequestField = requestKeys.some(
135
136
  (key) => !allowedRequestKeys.has(key),
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ requireRestartRequestId,
5
+ } = require('../ipc/restart-request-id');
6
+ const {
7
+ normalizeDeployQualificationExpectation,
8
+ } = require('./clean-restart-qualification');
9
+ const {
10
+ normalizeDeployForegroundExpectation,
11
+ } = require('./foreground-canary-target');
12
+
13
+ function createDeployRestartHandler({
14
+ getIsShuttingDown,
15
+ getPid,
16
+ foregroundCanaryAuthorizer,
17
+ persistForegroundCanaryAuthorization,
18
+ shutdown,
19
+ logger = console,
20
+ } = {}) {
21
+ if (typeof getIsShuttingDown !== 'function') {
22
+ throw new TypeError('deploy restart shutdown-state reader is required');
23
+ }
24
+ if (typeof getPid !== 'function') {
25
+ throw new TypeError('deploy restart PID reader is required');
26
+ }
27
+ if (typeof persistForegroundCanaryAuthorization !== 'function') {
28
+ throw new TypeError('foreground canary authorization persistence is required');
29
+ }
30
+ if (typeof shutdown !== 'function') {
31
+ throw new TypeError('deploy restart shutdown function is required');
32
+ }
33
+
34
+ function rejected(oldPid, restartRequestId, rejectionCode) {
35
+ return {
36
+ accepted: false,
37
+ old_pid: oldPid,
38
+ restart_request_id: restartRequestId,
39
+ rejection_code: rejectionCode,
40
+ };
41
+ }
42
+
43
+ return function requestDeployRestart(request) {
44
+ const oldPid = getPid();
45
+ const isForegroundRequest = request != null
46
+ && typeof request === 'object'
47
+ && Object.hasOwn(request, 'foreground_expectation');
48
+ if (getIsShuttingDown() && !isForegroundRequest) {
49
+ const error = new Error('shutdown already in progress');
50
+ error.code = 'SHUTDOWN_IN_PROGRESS';
51
+ throw error;
52
+ }
53
+ let restartRequestId;
54
+ try {
55
+ restartRequestId = requireRestartRequestId(request?.id);
56
+ } catch {
57
+ return rejected(oldPid, null, 'invalid-request');
58
+ }
59
+ if (getIsShuttingDown()) {
60
+ return rejected(oldPid, restartRequestId, 'shutdown-in-progress');
61
+ }
62
+
63
+ const qualificationExpectation = normalizeDeployQualificationExpectation(request);
64
+ const foregroundExpectation = normalizeDeployForegroundExpectation(request);
65
+ if (qualificationExpectation === null || foregroundExpectation === null) {
66
+ return rejected(oldPid, restartRequestId, 'invalid-request');
67
+ }
68
+
69
+ if (foregroundExpectation !== undefined) {
70
+ if (typeof foregroundCanaryAuthorizer?.authorizeRestart !== 'function') {
71
+ return rejected(oldPid, restartRequestId, 'target-unavailable');
72
+ }
73
+ const authorization = foregroundCanaryAuthorizer.authorizeRestart({
74
+ requestId: restartRequestId,
75
+ expectation: foregroundExpectation,
76
+ });
77
+ if (!authorization?.accepted) {
78
+ return rejected(
79
+ oldPid,
80
+ restartRequestId,
81
+ authorization?.rejectionCode || 'target-unavailable',
82
+ );
83
+ }
84
+ try {
85
+ persistForegroundCanaryAuthorization(authorization.authorizationEvent);
86
+ } catch {
87
+ return rejected(
88
+ oldPid,
89
+ restartRequestId,
90
+ 'authorization-evidence-unavailable',
91
+ );
92
+ }
93
+ }
94
+
95
+ shutdown({
96
+ continuationAuthorized: true,
97
+ trigger: 'deploy-ipc',
98
+ restartRequestId,
99
+ qualificationExpectation,
100
+ }).catch((error) => {
101
+ logger.error?.(`[shutdown] deploy restart failed: ${error.message}`);
102
+ });
103
+ return {
104
+ accepted: true,
105
+ old_pid: oldPid,
106
+ restart_request_id: restartRequestId,
107
+ };
108
+ };
109
+ }
110
+
111
+ module.exports = { createDeployRestartHandler };
@@ -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
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "polygram",
3
- "version": "0.38.1",
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": {
package/polygram.js CHANGED
@@ -31,13 +31,15 @@ const {
31
31
  const { buildPrompt, resolvePromptBackend } = require('./lib/prompt');
32
32
  const { countInFlight } = require('./lib/queue-utils');
33
33
  const { createIpcHandlers } = require('./lib/ipc/handlers');
34
- const {
35
- requireRestartRequestId,
36
- } = require('./lib/ipc/restart-request-id');
37
34
  const {
38
35
  buildQualificationEvent,
39
- normalizeDeployQualificationExpectation,
40
36
  } = require('./lib/ops/clean-restart-qualification');
37
+ const {
38
+ createForegroundCanaryAuthorizer,
39
+ } = require('./lib/ops/foreground-canary-target');
40
+ const {
41
+ createDeployRestartHandler,
42
+ } = require('./lib/ops/deploy-restart-handler');
41
43
  const { classifyOrphanSweep } = require('./lib/ops/tmux-preflight');
42
44
  const {
43
45
  parseSystemdInvocationId,
@@ -1075,6 +1077,8 @@ let attemptAutoResume = null;
1075
1077
  let errorReplyText = null;
1076
1078
  let queueWarnThreshold = null;
1077
1079
  let inFlightHandlers = null;
1080
+ let getActiveHandlerCount = null;
1081
+ let getActiveHandlerTargets = null;
1078
1082
  let awaitHandlerSettlement = null;
1079
1083
  let trackHandlerTask = null;
1080
1084
  let recoverCodexRequest = null;
@@ -4423,6 +4427,8 @@ async function main() {
4423
4427
  errorReplyText,
4424
4428
  queueWarnThreshold,
4425
4429
  inFlightHandlers,
4430
+ getActiveHandlerCount,
4431
+ getActiveHandlerTargets,
4426
4432
  awaitSettlement: awaitHandlerSettlement,
4427
4433
  trackTask: trackHandlerTask,
4428
4434
  } = createDispatcher({
@@ -4849,6 +4855,27 @@ async function main() {
4849
4855
  // scripts, hook); also exported to spawned Claude processes via env.
4850
4856
  const ipcSecret = ipcServer.writeSecret(BOT_NAME);
4851
4857
  process.env.POLYGRAM_IPC_SECRET = ipcSecret;
4858
+ const foregroundCanaryAuthorizer = createForegroundCanaryAuthorizer({
4859
+ botName: BOT_NAME,
4860
+ daemonIdentity,
4861
+ config,
4862
+ tokenSecret: ipcSecret,
4863
+ getActiveHandlerCount: () => getActiveHandlerCount(),
4864
+ getActiveHandlerTargets: () => getActiveHandlerTargets(),
4865
+ getForegroundCanaryTarget: (input) => (
4866
+ db.getForegroundCanaryTarget(input)
4867
+ ),
4868
+ });
4869
+ const requestDeployRestart = createDeployRestartHandler({
4870
+ getIsShuttingDown: () => isShuttingDown,
4871
+ getPid: () => process.pid,
4872
+ foregroundCanaryAuthorizer,
4873
+ persistForegroundCanaryAuthorization: (detail) => (
4874
+ db.logEvent('foreground-canary-target-authorized', detail)
4875
+ ),
4876
+ shutdown,
4877
+ logger: console,
4878
+ });
4852
4879
  ipcCloser = await ipcServer.start({
4853
4880
  path: ipcServer.socketPathFor(BOT_NAME),
4854
4881
  secret: ipcSecret,
@@ -4857,28 +4884,13 @@ async function main() {
4857
4884
  daemonIdentity,
4858
4885
  getInFlightHandlers: () => inFlightHandlers,
4859
4886
  handleSendOverIpc: (req) => handleSendOverIpc(req),
4887
+ requestForegroundCanaryTarget: (req) => (
4888
+ foregroundCanaryAuthorizer.probe(req)
4889
+ ),
4860
4890
  inspectCleanRestartQualification: () => (
4861
4891
  pm.inspectCleanRestartQualification(undefined)
4862
4892
  ),
4863
- requestDeployRestart: (req) => {
4864
- if (isShuttingDown) {
4865
- const error = new Error('shutdown already in progress');
4866
- error.code = 'SHUTDOWN_IN_PROGRESS';
4867
- throw error;
4868
- }
4869
- const restartRequestId = requireRestartRequestId(req.id);
4870
- const qualificationExpectation = normalizeDeployQualificationExpectation(req);
4871
- const oldPid = process.pid;
4872
- shutdown({
4873
- continuationAuthorized: true,
4874
- trigger: 'deploy-ipc',
4875
- restartRequestId,
4876
- qualificationExpectation,
4877
- }).catch((error) => {
4878
- console.error(`[shutdown] deploy restart failed: ${error.message}`);
4879
- });
4880
- return { accepted: true, old_pid: oldPid };
4881
- },
4893
+ requestDeployRestart,
4882
4894
  }),
4883
4895
  logger: console,
4884
4896
  });