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.
- package/.claude-plugin/plugin.json +1 -1
- package/lib/approvals/store.js +45 -7
- package/lib/codex/runtime-controller.js +107 -1
- package/lib/db/event-detail-schema.js +523 -0
- package/lib/db/pairings.js +9 -1
- package/lib/db/secret-sweep.js +6 -3
- package/lib/db/sessions.js +2 -2
- package/lib/db.js +353 -49
- package/lib/handlers/abort.js +20 -3
- package/lib/handlers/approvals.js +2 -2
- package/lib/handlers/config-callback.js +7 -7
- package/lib/handlers/dispatcher.js +24 -9
- package/lib/handlers/questions.js +546 -45
- package/lib/handlers/slash-commands.js +54 -13
- package/lib/handlers/voice.js +1 -1
- package/lib/ipc/handlers.js +4 -0
- package/lib/ops/clean-restart-qualification.js +1 -0
- package/lib/ops/clean-shutdown.js +5 -0
- package/lib/ops/deploy-restart-handler.js +111 -0
- package/lib/ops/foreground-canary-target.js +454 -0
- package/lib/questions/questions.js +51 -0
- package/lib/questions/store.js +61 -5
- package/lib/rewind/execute.js +12 -2
- package/lib/rewind/rewind.js +1 -1
- package/lib/sdk/build-options.js +2 -2
- package/lib/sdk/callbacks.js +14 -9
- package/lib/secret-detect.js +280 -12
- package/lib/telegram/api.js +10 -2
- package/lib/telegram/rich-edit.js +9 -5
- package/lib/telegram/rich-send.js +6 -6
- package/migrations/020-drop-secret-fingerprint.sql +13 -0
- package/package.json +2 -2
- package/polygram.js +117 -68
|
@@ -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
|
+
};
|
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
|
|
17
17
|
'use strict';
|
|
18
18
|
|
|
19
|
+
const { sanitizeForDurableWrite } = require('../secret-detect');
|
|
20
|
+
|
|
19
21
|
const MAX_LABEL = 40; // Telegram button labels truncate ~64; keep well under
|
|
20
22
|
const MAX_OTHER = 1000; // cap on the user's free-text answer entering claude's context
|
|
21
23
|
const MAX_PREVIEW = 500;
|
|
@@ -168,6 +170,51 @@ function applyFreeText(state, text) {
|
|
|
168
170
|
return { state: next, kind: 'advanced', receipt: other, done: isDone(next) };
|
|
169
171
|
}
|
|
170
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Whether a recorded answer carries a credential the durable boundary would
|
|
175
|
+
* mask. It lives beside the answer shape so a new field cannot be added
|
|
176
|
+
* without this seeing it. Every text-bearing part counts: the header copied
|
|
177
|
+
* from the agent's question, the selected option labels, and a typed value.
|
|
178
|
+
* The durable-write sanitizer decides, so its allowlist applies — "password:
|
|
179
|
+
* required" is ordinary vocabulary, not a secret.
|
|
180
|
+
*/
|
|
181
|
+
function answerIsFlagged(answer) {
|
|
182
|
+
const parts = [
|
|
183
|
+
...(answer?.header != null ? [answer.header] : []),
|
|
184
|
+
...(answer?.selected || []),
|
|
185
|
+
...(answer?.other != null ? [answer.other] : []),
|
|
186
|
+
];
|
|
187
|
+
return parts.some((part) => sanitizeForDurableWrite(String(part)).changed);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The durable stand-in for an answer whose text is being held live. It carries
|
|
192
|
+
* no answer text at all — a placeholder string would be indistinguishable from
|
|
193
|
+
* something the user actually chose, and could be delivered as one. The header
|
|
194
|
+
* is sanitized because it is copied from the agent's question.
|
|
195
|
+
*/
|
|
196
|
+
function markOmitted(answer) {
|
|
197
|
+
return {
|
|
198
|
+
header: sanitizeForDurableWrite(String(answer?.header ?? '')).text,
|
|
199
|
+
selected: [],
|
|
200
|
+
secret_omitted: true,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const isOmitted = (answer) => !!(answer && answer.secret_omitted);
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Resolve the answers for delivery. Returns a discriminated result rather than
|
|
208
|
+
* throwing or substituting: a marker means the exact answer was held live and
|
|
209
|
+
* the holder is gone, and the caller must cancel instead of sending anything
|
|
210
|
+
* upstream. No value or marker is ever put in an error.
|
|
211
|
+
*/
|
|
212
|
+
function resolveForDelivery(state) {
|
|
213
|
+
const answers = Array.isArray(state?.answers) ? state.answers : [];
|
|
214
|
+
if (answers.some(isOmitted)) return { ok: false, reason: 'live-answer-missing' };
|
|
215
|
+
return { ok: true, result: { answers } };
|
|
216
|
+
}
|
|
217
|
+
|
|
171
218
|
/** Final tool result once the set is done. */
|
|
172
219
|
function assemble(state) {
|
|
173
220
|
return { answers: state.answers };
|
|
@@ -182,6 +229,10 @@ module.exports = {
|
|
|
182
229
|
applyTap,
|
|
183
230
|
applyFreeText,
|
|
184
231
|
assemble,
|
|
232
|
+
answerIsFlagged,
|
|
233
|
+
markOmitted,
|
|
234
|
+
isOmitted,
|
|
235
|
+
resolveForDelivery,
|
|
185
236
|
MAX_LABEL,
|
|
186
237
|
MAX_OTHER,
|
|
187
238
|
};
|
package/lib/questions/store.js
CHANGED
|
@@ -10,6 +10,39 @@
|
|
|
10
10
|
'use strict';
|
|
11
11
|
|
|
12
12
|
const { newToken, tokensEqual } = require('../approvals/store');
|
|
13
|
+
const { sanitizeDurableStructured, sanitizeDurableJsonText } = require('../secret-detect');
|
|
14
|
+
|
|
15
|
+
// Two copies of the agent's questions, both durable:
|
|
16
|
+
//
|
|
17
|
+
// questions_json — the audit copy of what the agent asked.
|
|
18
|
+
// state_json — the state machine, which re-reads its own `questions`
|
|
19
|
+
// copy on every tap to render the card and to match the
|
|
20
|
+
// option the user picked.
|
|
21
|
+
//
|
|
22
|
+
// Both are sanitized. The exact array lives only in the handler's live
|
|
23
|
+
// question context, for as long as the owning process does, and is hydrated
|
|
24
|
+
// back for each live interaction. A typed answer carrying a credential is held
|
|
25
|
+
// the same way: the row keeps a marker with no text, and a marked row that
|
|
26
|
+
// outlives its process is cancelled rather than replayed. Terminal masking of
|
|
27
|
+
// the whole retained state stays as the last line of defense for text no
|
|
28
|
+
// detector flagged.
|
|
29
|
+
|
|
30
|
+
// Both columns hold JSON, so they are sanitized as structure before
|
|
31
|
+
// serialization — masking a serialized document can splice across its
|
|
32
|
+
// delimiters, and an escaped quote hides a declared value from the detector.
|
|
33
|
+
const maskDurableJson = (value) => JSON.stringify(sanitizeDurableStructured(value));
|
|
34
|
+
const maskDurableJsonText = (json) => sanitizeDurableJsonText(json).text;
|
|
35
|
+
|
|
36
|
+
// `state.questions` is a second copy of the array `questions_json` holds, and
|
|
37
|
+
// the state machine reads it on every tap. Sanitizing only the column would
|
|
38
|
+
// leave the provider's exact question in the row, so the state's copy is
|
|
39
|
+
// sanitized on every write; the handler hydrates the exact array back for the
|
|
40
|
+
// live interaction.
|
|
41
|
+
const withSanitizedQuestions = (state) => {
|
|
42
|
+
const value = state && typeof state === 'object' ? state : {};
|
|
43
|
+
if (!Array.isArray(value.questions)) return value;
|
|
44
|
+
return { ...value, questions: sanitizeDurableStructured(value.questions) };
|
|
45
|
+
};
|
|
13
46
|
|
|
14
47
|
// A question waits for the user — the turn no longer times out while an `ask` is open
|
|
15
48
|
// (cli-process defers its ceilings during a question wait, docs/progress-is-not-turn-end-spec.md),
|
|
@@ -42,8 +75,19 @@ function createQuestionStore(rawDb, now = () => Date.now()) {
|
|
|
42
75
|
UPDATE pending_questions SET from_id = @from_id
|
|
43
76
|
WHERE id = @id AND from_id IS NULL AND status = 'pending'`);
|
|
44
77
|
const resolveStmt = rawDb.prepare(`
|
|
45
|
-
UPDATE pending_questions SET status = @status, answered_ts = @answered_ts
|
|
78
|
+
UPDATE pending_questions SET status = @status, answered_ts = @answered_ts,
|
|
79
|
+
state_json = @state_json
|
|
46
80
|
WHERE id = @id AND status = 'pending'`);
|
|
81
|
+
const resolveTxn = rawDb.transaction((id, status) => {
|
|
82
|
+
const current = getByIdStmt.get(id);
|
|
83
|
+
if (!current || current.status !== 'pending') return 0;
|
|
84
|
+
return resolveStmt.run({
|
|
85
|
+
id,
|
|
86
|
+
status,
|
|
87
|
+
answered_ts: now(),
|
|
88
|
+
state_json: maskDurableJsonText(current.state_json ?? '{}'),
|
|
89
|
+
}).changes;
|
|
90
|
+
});
|
|
47
91
|
const listTimedOutStmt = rawDb.prepare(`SELECT * FROM pending_questions WHERE status = 'pending' AND timeout_ts < ?`);
|
|
48
92
|
const listOpenStmt = rawDb.prepare(`SELECT * FROM pending_questions WHERE bot_name = ? AND status = 'pending'`);
|
|
49
93
|
|
|
@@ -61,8 +105,8 @@ function createQuestionStore(rawDb, now = () => Date.now()) {
|
|
|
61
105
|
turn_id,
|
|
62
106
|
tool_call_id,
|
|
63
107
|
callback_token: newToken(),
|
|
64
|
-
questions_json:
|
|
65
|
-
state_json: JSON.stringify(state
|
|
108
|
+
questions_json: maskDurableJson(questions ?? []),
|
|
109
|
+
state_json: JSON.stringify(withSanitizedQuestions(state)),
|
|
66
110
|
created_ts,
|
|
67
111
|
timeout_ts: created_ts + timeoutMs,
|
|
68
112
|
});
|
|
@@ -75,7 +119,11 @@ function createQuestionStore(rawDb, now = () => Date.now()) {
|
|
|
75
119
|
setMessageIds(id, ids) { return setMsgIdsStmt.run(JSON.stringify(ids ?? []), id).changes; },
|
|
76
120
|
|
|
77
121
|
updateState(id, state, awaitingOther = false) {
|
|
78
|
-
return updateStateStmt.run({
|
|
122
|
+
return updateStateStmt.run({
|
|
123
|
+
id,
|
|
124
|
+
state_json: JSON.stringify(withSanitizedQuestions(state)),
|
|
125
|
+
awaiting_other: awaitingOther ? 1 : 0,
|
|
126
|
+
}).changes;
|
|
79
127
|
},
|
|
80
128
|
|
|
81
129
|
/**
|
|
@@ -95,7 +143,15 @@ function createQuestionStore(rawDb, now = () => Date.now()) {
|
|
|
95
143
|
if (!['answered', 'cancelled', 'timeout', 'expired'].includes(status)) {
|
|
96
144
|
throw new Error(`bad status: ${status}`);
|
|
97
145
|
}
|
|
98
|
-
|
|
146
|
+
// Terminal transition: the answer has already been handed to the agent
|
|
147
|
+
// and no path replays a non-pending row, so the retained state can be
|
|
148
|
+
// masked. The row stays for audit; the credential does not.
|
|
149
|
+
//
|
|
150
|
+
// Read and write are one transaction. Read-then-write outside one would
|
|
151
|
+
// let a tap's updateState land in between and be overwritten by the
|
|
152
|
+
// state this call had already read — the answer state silently rolling
|
|
153
|
+
// back at the moment the row is sealed.
|
|
154
|
+
return resolveTxn(id, status);
|
|
99
155
|
},
|
|
100
156
|
|
|
101
157
|
sweepTimedOut() { return listTimedOutStmt.all(now()); },
|
package/lib/rewind/execute.js
CHANGED
|
@@ -26,7 +26,13 @@ function transcriptPathFor(cwd, sessionId) {
|
|
|
26
26
|
* @param {object} deps { db, pm, tg, bot, botName, logEvent, logger, buildForkImpl }
|
|
27
27
|
* @returns {(req) => Promise<{ok:boolean,error?:string,droppedCount?:number}>}
|
|
28
28
|
*/
|
|
29
|
-
function createRewindExecutor({
|
|
29
|
+
function createRewindExecutor({
|
|
30
|
+
db, pm, tg, bot, botName = 'bot', logEvent = () => {}, logger = console,
|
|
31
|
+
buildForkImpl = buildFork,
|
|
32
|
+
// Narrow async hook: retire the question state of the session whose process
|
|
33
|
+
// is being replaced. A callback, so this module never learns the store.
|
|
34
|
+
retireQuestionSession = null,
|
|
35
|
+
} = {}) {
|
|
30
36
|
// P3: delete the bot's own outbound messages sent after M (it can always delete its own).
|
|
31
37
|
async function deleteBotMessagesAfter({ chatId, threadId, afterMsgId }) {
|
|
32
38
|
let rows;
|
|
@@ -74,8 +80,12 @@ function createRewindExecutor({ db, pm, tg, bot, botName = 'bot', logEvent = ()
|
|
|
74
80
|
catch (e) {
|
|
75
81
|
warning = 'the previous session may still be running — send a new message to confirm the rewind took';
|
|
76
82
|
logger.error?.(`[${botName}] rewind kill: ${e.message}`);
|
|
77
|
-
logEvent('rewind-kill-failed', { session_key: req.sessionKey,
|
|
83
|
+
logEvent('rewind-kill-failed', { session_key: req.sessionKey, error_class: e.name || 'Error' });
|
|
78
84
|
}
|
|
85
|
+
// The replaced process owned any open question. Retire its state whether or
|
|
86
|
+
// not the kill was clean: the rewound session cannot complete that ask, and
|
|
87
|
+
// an exact answer must not outlive the process it belonged to.
|
|
88
|
+
await retireQuestionSession?.(req.sessionKey);
|
|
79
89
|
|
|
80
90
|
let droppedCount = 0;
|
|
81
91
|
try { droppedCount = await deleteBotMessagesAfter({ chatId: req.chatId, threadId: req.threadId, afterMsgId: req.target.msg_id }); }
|
package/lib/rewind/rewind.js
CHANGED
|
@@ -103,7 +103,7 @@ function createRewindHandler({ pm, tg, bot, botName = 'bot', executeRewind, logE
|
|
|
103
103
|
logEvent('rewind-done', { session_key: req.sessionKey, target_msg_id: req.target.msg_id });
|
|
104
104
|
} else {
|
|
105
105
|
await ack(req.chatId, req.threadId, `↩️ couldn't rewind — ${(result && result.error) || 'unknown error'}`);
|
|
106
|
-
logEvent('rewind-failed', { session_key: req.sessionKey, target_msg_id: req.target.msg_id,
|
|
106
|
+
logEvent('rewind-failed', { session_key: req.sessionKey, target_msg_id: req.target.msg_id, error_code: (result && result.error) ? 'rewind-failed' : null });
|
|
107
107
|
}
|
|
108
108
|
}
|
|
109
109
|
|
package/lib/sdk/build-options.js
CHANGED
|
@@ -89,8 +89,8 @@ function createBuildSdkOptions({
|
|
|
89
89
|
} catch (err) {
|
|
90
90
|
logger.error?.(`[${label}] agent-loader: ${err.message}`);
|
|
91
91
|
logEvent('agent-load-failed', {
|
|
92
|
-
chat_id: chatId, agent: effectiveAgent,
|
|
93
|
-
|
|
92
|
+
chat_id: chatId, agent: effectiveAgent, error_class: err.name || 'Error',
|
|
93
|
+
thread_id: threadId || null,
|
|
94
94
|
});
|
|
95
95
|
}
|
|
96
96
|
}
|