polygram 0.38.1 → 0.38.3

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.3",
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",
@@ -12,6 +12,10 @@
12
12
 
13
13
  const crypto = require('crypto');
14
14
  const { canonicalizeToolInput } = require('../canonical-json');
15
+ const {
16
+ sanitizeDurableStructured,
17
+ sanitizeDurableJsonText,
18
+ } = require('../secret-detect');
15
19
 
16
20
  const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
17
21
  // 16 random bytes → 22 base64url chars ≈ 128 bits of entropy. Prevents
@@ -164,10 +168,41 @@ function createStore(rawDb, now = () => Date.now()) {
164
168
  if (!approver_chat_id) throw new Error('approver_chat_id required');
165
169
  if (!tool_name) throw new Error('tool_name required');
166
170
 
167
- const tool_input_json = typeof tool_input === 'string'
168
- ? tool_input
169
- : JSON.stringify(tool_input || {});
170
- const tool_input_digest = digestInput(tool_input_json);
171
+ // The row is an audit + display copy: the card renders it and the digest
172
+ // dedups repeated hook fires. The tool itself runs on the caller's own
173
+ // in-memory input, so masking recognized credentials here removes them
174
+ // from durable storage without touching what gets executed. Digesting
175
+ // the masked form also stops the digest from being a fingerprint of the
176
+ // credential, while staying deterministic for dedupe.
177
+ // Sanitize the STRUCTURE before serializing: a value the serializer
178
+ // escapes no longer looks like a declared credential to the detector,
179
+ // masking the serialized form can splice across its delimiters, and a
180
+ // credential named only by its key (`{"password":"…"}`) is invisible to
181
+ // a text pass. A caller that hands over an already-serialized document
182
+ // gets the same treatment — it is parsed back first, and only genuinely
183
+ // non-JSON input falls back to text masking.
184
+ let tool_input_json;
185
+ let sanitized;
186
+ if (typeof tool_input === 'string') {
187
+ const res = sanitizeDurableJsonText(tool_input);
188
+ tool_input_json = res.text;
189
+ sanitized = res.changed;
190
+ } else {
191
+ const source = tool_input || {};
192
+ tool_input_json = JSON.stringify(sanitizeDurableStructured(source));
193
+ sanitized = tool_input_json !== JSON.stringify(source);
194
+ }
195
+ // The legacy dedupe key is (turn, digest of the stored input), which is
196
+ // only sound while the stored input distinguishes two calls. Once a
197
+ // credential is masked it no longer does: two different secrets mask to
198
+ // the same text, so a shared digest would let one operator decision
199
+ // authorize a different command. A sanitized row therefore gets a
200
+ // random per-row identity — unique, and derived from nothing — and skips
201
+ // legacy reuse entirely. Dedupe by the provider's stable tool_use_id is
202
+ // unaffected: that key never depended on the input.
203
+ const tool_input_digest = sanitized
204
+ ? crypto.randomBytes(8).toString('hex')
205
+ : digestInput(tool_input_json);
171
206
  const requested_ts = now();
172
207
  const timeout_ts = requested_ts + timeoutMs;
173
208
  const callback_token = newToken();
@@ -186,9 +221,12 @@ function createStore(rawDb, now = () => Date.now()) {
186
221
  // which existing row counts as a "match."
187
222
  let row, reused = false;
188
223
  const tx = rawDb.transaction(() => {
189
- const existing = tool_use_id
190
- ? findDedupByToolUseIdStmt.get(bot_name, tool_use_id)
191
- : findDedupStmt.get(bot_name, turn_id, tool_input_digest);
224
+ let existing = null;
225
+ if (tool_use_id) {
226
+ existing = findDedupByToolUseIdStmt.get(bot_name, tool_use_id);
227
+ } else if (!sanitized) {
228
+ existing = findDedupStmt.get(bot_name, turn_id, tool_input_digest);
229
+ }
192
230
  if (existing) { row = existing; reused = true; return; }
193
231
  const res = insertStmt.run({
194
232
  bot_name,
@@ -222,6 +222,7 @@ function createCodexRuntimeController({
222
222
  || typeof db.settleCodexQueuedDispatch !== 'function'
223
223
  || typeof db.settleCodexStoppedGeneration !== 'function'
224
224
  || typeof db.settleCodexFailedGeneration !== 'function'
225
+ || typeof db.prepareCodexCleanRetirement !== 'function'
225
226
  || typeof db.recordCodexDeliveryCheckpoint !== 'function'
226
227
  || typeof resolveHostIdentity !== 'function'
227
228
  || typeof resolveBinary !== 'function'
@@ -416,6 +417,7 @@ function createCodexRuntimeController({
416
417
  codexHostIdentity: identity.stableHostId,
417
418
  codexBootSessionIdentity: identity.bootSessionId,
418
419
  codexRecoveryState,
420
+ codexRetirementPreparer: prepareCodexRetirement,
419
421
  codexRetirementVerifier: verifyCodexRetirement,
420
422
  }),
421
423
  });
@@ -1137,7 +1139,11 @@ function createCodexRuntimeController({
1137
1139
  checkpoint,
1138
1140
  retireGeneration,
1139
1141
  });
1140
- if (retireGeneration && persisted.retired !== true) {
1142
+ if (
1143
+ retireGeneration
1144
+ && persisted.retired !== true
1145
+ && persisted.deferred !== true
1146
+ ) {
1141
1147
  throw controllerError(
1142
1148
  'Codex delivery did not retire the verified stopped generation.',
1143
1149
  'CODEX_RETIREMENT_UNVERIFIED',
@@ -1239,6 +1245,106 @@ function createCodexRuntimeController({
1239
1245
  }
1240
1246
  }
1241
1247
 
1248
+ function prepareCodexRetirement(input = {}) {
1249
+ const expectedKeys = [
1250
+ 'sessionKey',
1251
+ 'generationId',
1252
+ 'attemptId',
1253
+ 'providerSessionId',
1254
+ 'providerTurnId',
1255
+ 'sourceMsgId',
1256
+ ];
1257
+ const keys = input && typeof input === 'object'
1258
+ ? Reflect.ownKeys(input)
1259
+ : [];
1260
+ const reject = (message, cause) => {
1261
+ throw controllerError(
1262
+ message,
1263
+ 'CODEX_RETIREMENT_PREPARATION_REJECTED',
1264
+ 'Do not interrupt or continue the unproven Codex turn.',
1265
+ cause,
1266
+ );
1267
+ };
1268
+ if (
1269
+ keys.length !== expectedKeys.length
1270
+ || expectedKeys.some((key) => !Object.hasOwn(input, key))
1271
+ ) {
1272
+ reject('Codex retirement preparation binding is incomplete.');
1273
+ }
1274
+ for (const key of expectedKeys.slice(0, 5)) {
1275
+ if (
1276
+ typeof input[key] !== 'string'
1277
+ || input[key].length === 0
1278
+ || input[key].length > 512
1279
+ ) {
1280
+ reject('Codex retirement preparation binding is malformed.');
1281
+ }
1282
+ }
1283
+ if (!(
1284
+ (
1285
+ typeof input.sourceMsgId === 'string'
1286
+ && input.sourceMsgId.length > 0
1287
+ && input.sourceMsgId.length <= 512
1288
+ )
1289
+ || (
1290
+ Number.isSafeInteger(input.sourceMsgId)
1291
+ && input.sourceMsgId > 0
1292
+ )
1293
+ )) {
1294
+ reject('Codex retirement source message binding is malformed.');
1295
+ }
1296
+
1297
+ const record = processesByGeneration.get(input.generationId);
1298
+ if (
1299
+ !record
1300
+ || currentProcessBySession.get(input.sessionKey) !== record
1301
+ || record.proc.sessionKey !== input.sessionKey
1302
+ || record.proc.generationId !== input.generationId
1303
+ || record.proc.providerSessionId !== input.providerSessionId
1304
+ || !record.durable
1305
+ || record.durabilityBlocked
1306
+ || record.retired
1307
+ || !(
1308
+ record.proc.runtime === 'codex'
1309
+ || record.proc.backend === 'codex'
1310
+ )
1311
+ ) {
1312
+ reject('Codex retirement preparation does not own the exact live turn.');
1313
+ }
1314
+
1315
+ const state = requireAvailableInitialization();
1316
+ let result;
1317
+ try {
1318
+ result = db.prepareCodexCleanRetirement({
1319
+ generation_id: input.generationId,
1320
+ session_key: input.sessionKey,
1321
+ attempt_id: input.attemptId,
1322
+ provider_session_id: input.providerSessionId,
1323
+ provider_turn_id: input.providerTurnId,
1324
+ source_message_id: String(input.sourceMsgId),
1325
+ stable_host_id: state.identity.stableHostId,
1326
+ boot_session_id: state.identity.bootSessionId,
1327
+ ts: Date.now(),
1328
+ });
1329
+ } catch (cause) {
1330
+ reject('Codex retirement preparation was not committed.', cause);
1331
+ }
1332
+ if (
1333
+ !result
1334
+ || ![0, 1].includes(result.changes)
1335
+ || result.disposition !== 'retirement-requested'
1336
+ || result.generationId !== input.generationId
1337
+ || result.attemptId !== input.attemptId
1338
+ ) {
1339
+ reject('Codex retirement preparation acknowledgement is inexact.');
1340
+ }
1341
+ return Object.freeze({
1342
+ committed: true,
1343
+ disposition: 'retirement-requested',
1344
+ ...input,
1345
+ });
1346
+ }
1347
+
1242
1348
  async function verifyCodexRetirement({
1243
1349
  sessionKey,
1244
1350
  generationId,