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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/db.js +45 -0
- package/lib/handlers/dispatcher.js +13 -0
- package/lib/ipc/handlers.js +4 -0
- package/lib/ops/clean-restart-qualification.js +1 -0
- package/lib/ops/deploy-restart-handler.js +111 -0
- package/lib/ops/foreground-canary-target.js +454 -0
- package/package.json +1 -1
- package/polygram.js +35 -23
|
@@ -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.
|
|
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
|
};
|
package/lib/ipc/handlers.js
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
});
|