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
package/lib/db.js
CHANGED
|
@@ -26,6 +26,48 @@ const {
|
|
|
26
26
|
const {
|
|
27
27
|
sanitizeCodexFaultProvenance,
|
|
28
28
|
} = require('./codex/fault-provenance');
|
|
29
|
+
const {
|
|
30
|
+
sanitizeForDurableWrite,
|
|
31
|
+
sanitizeDurableStructured,
|
|
32
|
+
sanitizeDurableJsonText,
|
|
33
|
+
} = require('./secret-detect');
|
|
34
|
+
const {
|
|
35
|
+
enforceEventDetailSchema,
|
|
36
|
+
} = require('./db/event-detail-schema');
|
|
37
|
+
|
|
38
|
+
// Pre-write secret boundary. Every durable text sink below routes its text
|
|
39
|
+
// through this so a recognized credential never reaches a row, the
|
|
40
|
+
// external-content FTS index that mirrors it, a persisted error, or telemetry.
|
|
41
|
+
// The caller's own value is untouched: the live provider turn always sees the
|
|
42
|
+
// original message. Detection is deterministic shape/keyword matching, so
|
|
43
|
+
// prose that never declares a credential is not covered here — the sweep and
|
|
44
|
+
// the agent-reported redaction remain the defense-in-depth behind it.
|
|
45
|
+
const durableText = (value) => (
|
|
46
|
+
typeof value === 'string' ? sanitizeForDurableWrite(value).text : value
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
// Truncating a masked text can cut a placeholder in half, leaving a fragment
|
|
50
|
+
// like `‹redacted:kv-sec` that reads as ordinary text and no longer survives
|
|
51
|
+
// a second masking pass unchanged. Cut before the placeholder that straddles
|
|
52
|
+
// the limit instead.
|
|
53
|
+
const truncateAfterMask = (text, limit) => {
|
|
54
|
+
if (typeof text !== 'string' || text.length <= limit) return text;
|
|
55
|
+
const cut = text.slice(0, limit);
|
|
56
|
+
const open = cut.lastIndexOf('‹');
|
|
57
|
+
if (open !== -1 && cut.indexOf('›', open) === -1) return cut.slice(0, open);
|
|
58
|
+
return cut;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// A JSON-valued column is sanitized as STRUCTURE, not as text: masking a
|
|
62
|
+
// serialized document can splice across its delimiters, and an escaped quote
|
|
63
|
+
// hides a declared value from the detector. Callers usually hand over an
|
|
64
|
+
// object; a string is parsed back so a caller that pre-serialized still gets
|
|
65
|
+
// the structural pass. Text that is not JSON falls back to string masking.
|
|
66
|
+
const durableJson = (value) => {
|
|
67
|
+
if (value == null) return value;
|
|
68
|
+
if (typeof value !== 'string') return JSON.stringify(sanitizeDurableStructured(value));
|
|
69
|
+
return sanitizeDurableJsonText(value).text;
|
|
70
|
+
};
|
|
29
71
|
|
|
30
72
|
// 0.8.0 Phase 1: bumped from 9 → 10. Adds migration
|
|
31
73
|
// 010-tool-use-id.sql (pending_approvals.tool_use_id column for the
|
|
@@ -45,7 +87,7 @@ const {
|
|
|
45
87
|
//
|
|
46
88
|
// 0.15: bumped 13 → 14. Adds migration 014-secret-redactions.sql
|
|
47
89
|
// (secret_redactions audit table + messages.secret_scanned_at).
|
|
48
|
-
const SCHEMA_VERSION =
|
|
90
|
+
const SCHEMA_VERSION = 20;
|
|
49
91
|
|
|
50
92
|
// Sentinel `error` value for outbound rows whose API call may or may not
|
|
51
93
|
// have reached Telegram. markStalePending writes it; hasOutboundReplyTo
|
|
@@ -462,6 +504,25 @@ function wrap(db) {
|
|
|
462
504
|
ORDER BY id DESC LIMIT 1
|
|
463
505
|
`);
|
|
464
506
|
|
|
507
|
+
const getForegroundCanaryTargetStmt = db.prepare(`
|
|
508
|
+
SELECT message.id AS message_id,
|
|
509
|
+
message.chat_id,
|
|
510
|
+
message.thread_id,
|
|
511
|
+
selection.telegram_message_id,
|
|
512
|
+
message.handler_status,
|
|
513
|
+
selection.session_key,
|
|
514
|
+
selection.provider
|
|
515
|
+
FROM messages AS message
|
|
516
|
+
JOIN inbound_runtime_selections AS selection
|
|
517
|
+
ON selection.bot_name = message.bot_name
|
|
518
|
+
AND selection.telegram_chat_id = message.chat_id
|
|
519
|
+
AND selection.telegram_message_id = ?
|
|
520
|
+
WHERE message.direction = 'in'
|
|
521
|
+
AND message.bot_name = ?
|
|
522
|
+
AND message.chat_id = ?
|
|
523
|
+
AND message.msg_id = ?
|
|
524
|
+
`);
|
|
525
|
+
|
|
465
526
|
const setMessageTextStmt = db.prepare(`
|
|
466
527
|
UPDATE messages
|
|
467
528
|
SET text = @text
|
|
@@ -1402,10 +1463,20 @@ function wrap(db) {
|
|
|
1402
1463
|
itemId,
|
|
1403
1464
|
detailJson,
|
|
1404
1465
|
})) {
|
|
1405
|
-
|
|
1466
|
+
const deferred = (
|
|
1467
|
+
kind === 'telegram-delivery-failed'
|
|
1468
|
+
&& isCleanRetirementDelivery(attempt)
|
|
1469
|
+
);
|
|
1470
|
+
return {
|
|
1471
|
+
changes: 0,
|
|
1472
|
+
attemptId,
|
|
1473
|
+
kind,
|
|
1474
|
+
...(deferred ? { deferred: true } : {}),
|
|
1475
|
+
};
|
|
1406
1476
|
}
|
|
1407
1477
|
}
|
|
1408
1478
|
assertCodexCheckpointOwner(generation, kind);
|
|
1479
|
+
let deferred = false;
|
|
1409
1480
|
|
|
1410
1481
|
if (kind === 'request-prepared') {
|
|
1411
1482
|
requiredCodexString(method, 'request method');
|
|
@@ -1709,21 +1780,24 @@ function wrap(db) {
|
|
|
1709
1780
|
);
|
|
1710
1781
|
}
|
|
1711
1782
|
const deliveryFailed = kind === 'telegram-delivery-failed';
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
? '
|
|
1716
|
-
: attempt.terminal_status === '
|
|
1717
|
-
? '
|
|
1718
|
-
: '
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1783
|
+
deferred = deliveryFailed && isCleanRetirementDelivery(attempt);
|
|
1784
|
+
if (!deferred) {
|
|
1785
|
+
const linkedState = deliveryFailed
|
|
1786
|
+
? 'failed'
|
|
1787
|
+
: attempt.terminal_status === 'interrupted'
|
|
1788
|
+
? 'interrupted'
|
|
1789
|
+
: attempt.terminal_status === 'failed'
|
|
1790
|
+
? 'failed'
|
|
1791
|
+
: 'settled';
|
|
1792
|
+
settleCodexAttemptConsumerOutcome({
|
|
1793
|
+
attempt,
|
|
1794
|
+
generationId,
|
|
1795
|
+
linkedState,
|
|
1796
|
+
attemptRecoveryState: 'settled',
|
|
1797
|
+
settleQueuedReservation: true,
|
|
1798
|
+
ts,
|
|
1799
|
+
});
|
|
1800
|
+
}
|
|
1727
1801
|
} else if (
|
|
1728
1802
|
['active-start-cancelled', 'queued-send-cancelled'].includes(kind)
|
|
1729
1803
|
) {
|
|
@@ -1883,7 +1957,166 @@ function wrap(db) {
|
|
|
1883
1957
|
detailJson,
|
|
1884
1958
|
ts,
|
|
1885
1959
|
);
|
|
1886
|
-
return {
|
|
1960
|
+
return {
|
|
1961
|
+
changes: 1,
|
|
1962
|
+
attemptId,
|
|
1963
|
+
kind,
|
|
1964
|
+
...(deferred ? { deferred: true } : {}),
|
|
1965
|
+
};
|
|
1966
|
+
})();
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
function isCleanRetirementDelivery(attempt) {
|
|
1970
|
+
if (
|
|
1971
|
+
attempt?.method !== 'turn/start'
|
|
1972
|
+
|| attempt.terminal_status !== 'interrupted'
|
|
1973
|
+
|| !['terminal-pending', 'clean-pending'].includes(
|
|
1974
|
+
attempt.recovery_state,
|
|
1975
|
+
)
|
|
1976
|
+
) return false;
|
|
1977
|
+
return db.prepare(`
|
|
1978
|
+
SELECT 1
|
|
1979
|
+
FROM codex_attempt_checkpoints
|
|
1980
|
+
WHERE generation_id = ?
|
|
1981
|
+
AND attempt_id = ?
|
|
1982
|
+
AND kind = 'clean-retirement-requested'
|
|
1983
|
+
AND thread_id = ?
|
|
1984
|
+
AND turn_id = ?
|
|
1985
|
+
LIMIT 1
|
|
1986
|
+
`).get(
|
|
1987
|
+
attempt.generation_id,
|
|
1988
|
+
attempt.attempt_id,
|
|
1989
|
+
attempt.thread_id,
|
|
1990
|
+
attempt.turn_id,
|
|
1991
|
+
) != null;
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
function prepareCodexCleanRetirement(input) {
|
|
1995
|
+
const allowed = new Set([
|
|
1996
|
+
'generation_id',
|
|
1997
|
+
'session_key',
|
|
1998
|
+
'attempt_id',
|
|
1999
|
+
'provider_session_id',
|
|
2000
|
+
'provider_turn_id',
|
|
2001
|
+
'source_message_id',
|
|
2002
|
+
'stable_host_id',
|
|
2003
|
+
'boot_session_id',
|
|
2004
|
+
'ts',
|
|
2005
|
+
]);
|
|
2006
|
+
assertExactKeys(
|
|
2007
|
+
input,
|
|
2008
|
+
allowed,
|
|
2009
|
+
'CODEX_RETIREMENT_PREPARATION_REJECTED',
|
|
2010
|
+
'clean retirement preparation',
|
|
2011
|
+
);
|
|
2012
|
+
const generationId = requiredCodexString(
|
|
2013
|
+
input?.generation_id,
|
|
2014
|
+
'generation ID',
|
|
2015
|
+
);
|
|
2016
|
+
const sessionKey = requiredCodexString(input?.session_key, 'session key');
|
|
2017
|
+
const attemptId = requiredCodexString(input?.attempt_id, 'attempt ID');
|
|
2018
|
+
const providerSessionId = requiredCodexString(
|
|
2019
|
+
input?.provider_session_id,
|
|
2020
|
+
'provider session ID',
|
|
2021
|
+
);
|
|
2022
|
+
const providerTurnId = requiredCodexString(
|
|
2023
|
+
input?.provider_turn_id,
|
|
2024
|
+
'provider turn ID',
|
|
2025
|
+
);
|
|
2026
|
+
const sourceMessageId = requiredCodexString(
|
|
2027
|
+
input?.source_message_id == null
|
|
2028
|
+
? input?.source_message_id
|
|
2029
|
+
: String(input.source_message_id),
|
|
2030
|
+
'source message ID',
|
|
2031
|
+
);
|
|
2032
|
+
const stableHostId = requiredCodexString(
|
|
2033
|
+
input?.stable_host_id,
|
|
2034
|
+
'stable host identity',
|
|
2035
|
+
);
|
|
2036
|
+
const bootSessionId = requiredCodexString(
|
|
2037
|
+
input?.boot_session_id,
|
|
2038
|
+
'boot-session identity',
|
|
2039
|
+
);
|
|
2040
|
+
const ts = requiredTimestamp(
|
|
2041
|
+
input?.ts ?? Date.now(),
|
|
2042
|
+
'clean retirement preparation timestamp',
|
|
2043
|
+
);
|
|
2044
|
+
|
|
2045
|
+
return db.transaction(() => {
|
|
2046
|
+
const generation = assertCodexGenerationIdentity(
|
|
2047
|
+
generationId,
|
|
2048
|
+
stableHostId,
|
|
2049
|
+
bootSessionId,
|
|
2050
|
+
);
|
|
2051
|
+
assertCodexCheckpointOwner(generation, 'clean-retirement-requested');
|
|
2052
|
+
const attempt = requireCodexAttempt(attemptId, generationId);
|
|
2053
|
+
const accepted = hasCodexTurnCheckpoint(
|
|
2054
|
+
generationId,
|
|
2055
|
+
attemptId,
|
|
2056
|
+
'turn-accepted',
|
|
2057
|
+
providerTurnId,
|
|
2058
|
+
);
|
|
2059
|
+
if (
|
|
2060
|
+
generation.session_key !== sessionKey
|
|
2061
|
+
|| attempt.session_key !== sessionKey
|
|
2062
|
+
|| attempt.method !== 'turn/start'
|
|
2063
|
+
|| attempt.thread_id !== providerSessionId
|
|
2064
|
+
|| attempt.turn_id !== providerTurnId
|
|
2065
|
+
|| attempt.telegram_source_message_id !== sourceMessageId
|
|
2066
|
+
|| attempt.delivery_state !== 'response-observed'
|
|
2067
|
+
|| attempt.response_outcome !== 'result'
|
|
2068
|
+
|| attempt.recovery_state !== 'active'
|
|
2069
|
+
|| attempt.terminal_status != null
|
|
2070
|
+
|| !accepted
|
|
2071
|
+
) {
|
|
2072
|
+
throw codexError(
|
|
2073
|
+
'Codex clean retirement target is not the exact active accepted turn',
|
|
2074
|
+
'CODEX_RETIREMENT_PREPARATION_REJECTED',
|
|
2075
|
+
);
|
|
2076
|
+
}
|
|
2077
|
+
const existing = db.prepare(`
|
|
2078
|
+
SELECT thread_id, turn_id
|
|
2079
|
+
FROM codex_attempt_checkpoints
|
|
2080
|
+
WHERE generation_id = ?
|
|
2081
|
+
AND attempt_id = ?
|
|
2082
|
+
AND kind = 'clean-retirement-requested'
|
|
2083
|
+
`).all(generationId, attemptId);
|
|
2084
|
+
if (existing.length > 0) {
|
|
2085
|
+
if (
|
|
2086
|
+
existing.length === 1
|
|
2087
|
+
&& existing[0].thread_id === providerSessionId
|
|
2088
|
+
&& existing[0].turn_id === providerTurnId
|
|
2089
|
+
) {
|
|
2090
|
+
return {
|
|
2091
|
+
changes: 0,
|
|
2092
|
+
disposition: 'retirement-requested',
|
|
2093
|
+
generationId,
|
|
2094
|
+
attemptId,
|
|
2095
|
+
};
|
|
2096
|
+
}
|
|
2097
|
+
throw codexError(
|
|
2098
|
+
'Codex clean retirement target has conflicting ownership',
|
|
2099
|
+
'CODEX_RETIREMENT_PREPARATION_REJECTED',
|
|
2100
|
+
);
|
|
2101
|
+
}
|
|
2102
|
+
db.prepare(`
|
|
2103
|
+
INSERT INTO codex_attempt_checkpoints (
|
|
2104
|
+
generation_id, attempt_id, kind, thread_id, turn_id,
|
|
2105
|
+
request_id, item_id, detail_json, ts
|
|
2106
|
+
) VALUES (?, ?, 'clean-retirement-requested', ?, ?, NULL, NULL, NULL, ?)
|
|
2107
|
+
`).run(
|
|
2108
|
+
generationId,
|
|
2109
|
+
attemptId,
|
|
2110
|
+
providerSessionId,
|
|
2111
|
+
providerTurnId,
|
|
2112
|
+
ts,
|
|
2113
|
+
);
|
|
2114
|
+
return {
|
|
2115
|
+
changes: 1,
|
|
2116
|
+
disposition: 'retirement-requested',
|
|
2117
|
+
generationId,
|
|
2118
|
+
attemptId,
|
|
2119
|
+
};
|
|
1887
2120
|
})();
|
|
1888
2121
|
}
|
|
1889
2122
|
|
|
@@ -1911,7 +2144,7 @@ function wrap(db) {
|
|
|
1911
2144
|
}
|
|
1912
2145
|
return db.transaction(() => {
|
|
1913
2146
|
const result = recordCodexCheckpoint(checkpoint);
|
|
1914
|
-
if (!input.retireGeneration) {
|
|
2147
|
+
if (!input.retireGeneration || result.deferred === true) {
|
|
1915
2148
|
return { ...result, retired: false };
|
|
1916
2149
|
}
|
|
1917
2150
|
const generationId = requiredCodexString(
|
|
@@ -3769,7 +4002,7 @@ function wrap(db) {
|
|
|
3769
4002
|
msg_id: row.msg_id,
|
|
3770
4003
|
user: row.user || null,
|
|
3771
4004
|
user_id: row.user_id || null,
|
|
3772
|
-
text: row.text || '',
|
|
4005
|
+
text: durableText(row.text || ''),
|
|
3773
4006
|
reply_to_id: row.reply_to_id || null,
|
|
3774
4007
|
direction: row.direction || 'in',
|
|
3775
4008
|
source: row.source || 'polygram',
|
|
@@ -3779,7 +4012,7 @@ function wrap(db) {
|
|
|
3779
4012
|
effort: row.effort || null,
|
|
3780
4013
|
turn_id: row.turn_id || null,
|
|
3781
4014
|
status: row.status || 'received',
|
|
3782
|
-
error: row.error || null,
|
|
4015
|
+
error: durableText(row.error) || null,
|
|
3783
4016
|
cost_usd: row.cost_usd ?? null,
|
|
3784
4017
|
ts: row.ts || Date.now(),
|
|
3785
4018
|
});
|
|
@@ -3790,7 +4023,7 @@ function wrap(db) {
|
|
|
3790
4023
|
chat_id: String(row.chat_id),
|
|
3791
4024
|
thread_id: row.thread_id ? String(row.thread_id) : null,
|
|
3792
4025
|
user: row.user || null,
|
|
3793
|
-
text: row.text || '',
|
|
4026
|
+
text: durableText(row.text || ''),
|
|
3794
4027
|
source: row.source || 'polygram',
|
|
3795
4028
|
bot_name: row.bot_name || null,
|
|
3796
4029
|
turn_id: row.turn_id || null,
|
|
@@ -3806,7 +4039,10 @@ function wrap(db) {
|
|
|
3806
4039
|
},
|
|
3807
4040
|
|
|
3808
4041
|
markOutboundFailed(id, err) {
|
|
3809
|
-
return markOutboundFailedStmt.run({
|
|
4042
|
+
return markOutboundFailedStmt.run({
|
|
4043
|
+
id,
|
|
4044
|
+
error: truncateAfterMask(durableText(String(err)), 500),
|
|
4045
|
+
});
|
|
3810
4046
|
},
|
|
3811
4047
|
|
|
3812
4048
|
updateOutboundText({ chat_id, msg_id, text, ts }) {
|
|
@@ -3814,7 +4050,7 @@ function wrap(db) {
|
|
|
3814
4050
|
return updateOutboundTextStmt.run({
|
|
3815
4051
|
chat_id: String(chat_id),
|
|
3816
4052
|
msg_id: Number(msg_id),
|
|
3817
|
-
text,
|
|
4053
|
+
text: durableText(text),
|
|
3818
4054
|
ts: ts || Date.now(),
|
|
3819
4055
|
});
|
|
3820
4056
|
},
|
|
@@ -3879,6 +4115,7 @@ function wrap(db) {
|
|
|
3879
4115
|
},
|
|
3880
4116
|
|
|
3881
4117
|
createCodexGeneration,
|
|
4118
|
+
prepareCodexCleanRetirement,
|
|
3882
4119
|
recordCodexCheckpoint,
|
|
3883
4120
|
recordCodexDeliveryCheckpoint,
|
|
3884
4121
|
getCodexAttempt(attemptId) {
|
|
@@ -3944,7 +4181,7 @@ function wrap(db) {
|
|
|
3944
4181
|
return setMessageTextStmt.run({
|
|
3945
4182
|
chat_id: String(chat_id),
|
|
3946
4183
|
msg_id,
|
|
3947
|
-
text: text ?? '',
|
|
4184
|
+
text: durableText(text ?? ''),
|
|
3948
4185
|
});
|
|
3949
4186
|
},
|
|
3950
4187
|
|
|
@@ -3957,16 +4194,28 @@ function wrap(db) {
|
|
|
3957
4194
|
return row?.new_chat_id || String(chatId);
|
|
3958
4195
|
},
|
|
3959
4196
|
|
|
4197
|
+
// Telemetry passes two gates before it is persisted: the content-free
|
|
4198
|
+
// field schema decides WHICH fields may exist at all, then the durable
|
|
4199
|
+
// secret boundary masks recognized credentials in what survived. The
|
|
4200
|
+
// schema is the primary guarantee — masking alone would only remove
|
|
4201
|
+
// content a detector recognizes.
|
|
3960
4202
|
logEvent(kind, { chat_id = null, ...detail } = {}) {
|
|
4203
|
+
const { detail: allowed, dropped, droppedCount } = enforceEventDetailSchema(detail);
|
|
4204
|
+
// The count is always safe to record; the names only when they came
|
|
4205
|
+
// from the schema's own vocabulary (an unknown key can be content).
|
|
4206
|
+
if (droppedCount) allowed.dropped_field_count = droppedCount;
|
|
4207
|
+
if (dropped.length) allowed.dropped_fields = dropped;
|
|
3961
4208
|
return logEventStmt.run(
|
|
3962
4209
|
Date.now(),
|
|
3963
4210
|
chat_id ? String(chat_id) : null,
|
|
3964
4211
|
kind,
|
|
3965
|
-
Object.keys(
|
|
4212
|
+
Object.keys(allowed).length
|
|
4213
|
+
? JSON.stringify(sanitizeDurableStructured(allowed))
|
|
4214
|
+
: null,
|
|
3966
4215
|
);
|
|
3967
4216
|
},
|
|
3968
4217
|
|
|
3969
|
-
//
|
|
4218
|
+
// Find compact-command events from the recent window that
|
|
3970
4219
|
// never produced a matching compact-boundary on the same
|
|
3971
4220
|
// session_key. These are "interrupted by deploy" cases — polygram
|
|
3972
4221
|
// pushed /compact into SDK input but the daemon restarted before
|
|
@@ -3978,23 +4227,38 @@ function wrap(db) {
|
|
|
3978
4227
|
// session_key AND ts > compact_command.ts. If none within
|
|
3979
4228
|
// search-window, it's orphaned.
|
|
3980
4229
|
//
|
|
4230
|
+
// The command text itself is message content, so telemetry carries only
|
|
4231
|
+
// the id of the inbound message that holds it. The text is joined back
|
|
4232
|
+
// from that row — already sanitized by the durable-write boundary — and
|
|
4233
|
+
// is null when the row is gone or the event predates the id, which the
|
|
4234
|
+
// caller treats as "surface a retry notice instead of replaying".
|
|
4235
|
+
//
|
|
4236
|
+
// Events written before the hint moved out of telemetry still carry it in
|
|
4237
|
+
// their detail. Those stay recoverable, from the row when one exists and
|
|
4238
|
+
// otherwise from the stored copy, sanitized on the way out so a legacy
|
|
4239
|
+
// row cannot hand a credential back.
|
|
4240
|
+
//
|
|
3981
4241
|
// @param {object} opts
|
|
3982
4242
|
// @param {number} opts.olderThanMs - cutoff (only events newer than this are scanned)
|
|
3983
|
-
// @returns {Array<{ts, chat_id, thread_id, session_key, user, user_id, text_len}>}
|
|
4243
|
+
// @returns {Array<{ts, chat_id, thread_id, session_key, user, user_id, text_len, text}>}
|
|
3984
4244
|
findOrphanedCompactCommands({ olderThanMs = 30 * 60 * 1000 } = {}) {
|
|
3985
4245
|
const cutoff = Date.now() - olderThanMs;
|
|
3986
4246
|
const cmds = db.prepare(`
|
|
3987
|
-
SELECT id, ts, chat_id,
|
|
3988
|
-
json_extract(detail_json, '$.thread_id') AS thread_id,
|
|
3989
|
-
json_extract(detail_json, '$.session_key') AS session_key,
|
|
3990
|
-
json_extract(detail_json, '$.
|
|
3991
|
-
json_extract(detail_json, '$.
|
|
3992
|
-
|
|
3993
|
-
json_extract(detail_json, '$.text') AS
|
|
3994
|
-
FROM events
|
|
3995
|
-
|
|
3996
|
-
|
|
3997
|
-
|
|
4247
|
+
SELECT event.id, event.ts, event.chat_id,
|
|
4248
|
+
json_extract(event.detail_json, '$.thread_id') AS thread_id,
|
|
4249
|
+
json_extract(event.detail_json, '$.session_key') AS session_key,
|
|
4250
|
+
json_extract(event.detail_json, '$.user_id') AS user_id,
|
|
4251
|
+
json_extract(event.detail_json, '$.text_len') AS text_len,
|
|
4252
|
+
source.text AS row_text,
|
|
4253
|
+
json_extract(event.detail_json, '$.text') AS legacy_text
|
|
4254
|
+
FROM events AS event
|
|
4255
|
+
LEFT JOIN messages AS source
|
|
4256
|
+
ON source.chat_id = event.chat_id
|
|
4257
|
+
AND source.msg_id = json_extract(event.detail_json, '$.msg_id')
|
|
4258
|
+
AND source.direction = 'in'
|
|
4259
|
+
WHERE event.kind = 'compact-command'
|
|
4260
|
+
AND event.ts > ?
|
|
4261
|
+
ORDER BY event.ts ASC, event.id ASC
|
|
3998
4262
|
`).all(cutoff);
|
|
3999
4263
|
const orphans = [];
|
|
4000
4264
|
for (const c of cmds) {
|
|
@@ -4011,7 +4275,7 @@ function wrap(db) {
|
|
|
4011
4275
|
LIMIT 1
|
|
4012
4276
|
`).get(c.id, c.session_key);
|
|
4013
4277
|
if (boundary) continue;
|
|
4014
|
-
//
|
|
4278
|
+
// Also skip if a previous boot has already handled
|
|
4015
4279
|
// this orphan (silent replay via compact-replay event, OR
|
|
4016
4280
|
// surface-fallback via compact-failed-restart event). Both
|
|
4017
4281
|
// of those record `original_ts` in their detail_json
|
|
@@ -4025,7 +4289,15 @@ function wrap(db) {
|
|
|
4025
4289
|
LIMIT 1
|
|
4026
4290
|
`).get(c.ts);
|
|
4027
4291
|
if (handled) continue;
|
|
4028
|
-
|
|
4292
|
+
const { row_text, legacy_text, ...rest } = c;
|
|
4293
|
+
// Both sources are sanitized on the way out. The row is normally
|
|
4294
|
+
// masked at write, but rows written before this boundary existed are
|
|
4295
|
+
// not — and those are exactly the oldest, most likely to be replayed.
|
|
4296
|
+
const recovered = row_text ?? legacy_text;
|
|
4297
|
+
orphans.push({
|
|
4298
|
+
...rest,
|
|
4299
|
+
text: recovered != null ? durableText(recovered) : null,
|
|
4300
|
+
});
|
|
4029
4301
|
}
|
|
4030
4302
|
return orphans;
|
|
4031
4303
|
},
|
|
@@ -4049,7 +4321,8 @@ function wrap(db) {
|
|
|
4049
4321
|
num_assistant_messages: row.num_assistant_messages ?? null,
|
|
4050
4322
|
num_tool_uses: row.num_tool_uses ?? null,
|
|
4051
4323
|
result_subtype: row.result_subtype || null,
|
|
4052
|
-
|
|
4324
|
+
// The turn's failure text can echo the provider's own output.
|
|
4325
|
+
error: durableText(row.error) || null,
|
|
4053
4326
|
});
|
|
4054
4327
|
},
|
|
4055
4328
|
|
|
@@ -4092,7 +4365,12 @@ function wrap(db) {
|
|
|
4092
4365
|
chat_id: String(row.chat_id),
|
|
4093
4366
|
tool_name: String(row.tool_name),
|
|
4094
4367
|
match_type: row.match_type,
|
|
4095
|
-
|
|
4368
|
+
// The pattern is derived from a tool input, so it can carry a
|
|
4369
|
+
// credential into durable storage. Masking it means a rule whose
|
|
4370
|
+
// pattern contained one no longer matches that live input: the call
|
|
4371
|
+
// falls through to a fresh approval prompt rather than silently
|
|
4372
|
+
// auto-allowing or auto-denying it.
|
|
4373
|
+
input_pattern: durableText(row.input_pattern),
|
|
4096
4374
|
decision: row.decision,
|
|
4097
4375
|
issued_ts: row.issued_ts || Date.now(),
|
|
4098
4376
|
issued_by_user_id: row.issued_by_user_id != null
|
|
@@ -4539,7 +4817,6 @@ function wrap(db) {
|
|
|
4539
4817
|
redactSecretInChat({ chat_id, thread_id = null, secret, now = Date.now(), limit = 200 }) {
|
|
4540
4818
|
if (typeof secret !== 'string' || secret.length < 3) return { redacted: 0 };
|
|
4541
4819
|
const PLACEHOLDER = '‹redacted:reported›';
|
|
4542
|
-
const sha = require('crypto').createHash('sha256').update(secret).digest('hex');
|
|
4543
4820
|
const rows = (thread_id != null
|
|
4544
4821
|
? db.prepare(`SELECT id, msg_id, text FROM messages WHERE chat_id=? AND thread_id=? AND direction='in' ORDER BY id DESC LIMIT ?`).all(String(chat_id), String(thread_id), limit)
|
|
4545
4822
|
: db.prepare(`SELECT id, msg_id, text FROM messages WHERE chat_id=? AND direction='in' ORDER BY id DESC LIMIT ?`).all(String(chat_id), limit));
|
|
@@ -4549,8 +4826,8 @@ function wrap(db) {
|
|
|
4549
4826
|
if (!r.text || !r.text.includes(secret)) continue;
|
|
4550
4827
|
const newText = r.text.split(secret).join(PLACEHOLDER);
|
|
4551
4828
|
db.prepare('UPDATE messages SET text = ? WHERE id = ?').run(newText, r.id);
|
|
4552
|
-
db.prepare(`INSERT INTO secret_redactions (chat_id, msg_id, rule, tier, length,
|
|
4553
|
-
VALUES (
|
|
4829
|
+
db.prepare(`INSERT INTO secret_redactions (chat_id, msg_id, rule, tier, length, action, ts)
|
|
4830
|
+
VALUES (?,?,?,?,?,?,?)`).run(String(chat_id), r.msg_id, 'reported', 'reported', secret.length, 'redacted', now);
|
|
4554
4831
|
redacted += 1;
|
|
4555
4832
|
}
|
|
4556
4833
|
});
|
|
@@ -4586,7 +4863,8 @@ function wrap(db) {
|
|
|
4586
4863
|
file_id,
|
|
4587
4864
|
file_unique_id || null,
|
|
4588
4865
|
kind,
|
|
4589
|
-
|
|
4866
|
+
// A user-supplied filename can carry a declared credential.
|
|
4867
|
+
durableText(name) || null,
|
|
4590
4868
|
mime_type || null,
|
|
4591
4869
|
size_bytes ?? null,
|
|
4592
4870
|
ts || Date.now(),
|
|
@@ -4610,13 +4888,13 @@ function wrap(db) {
|
|
|
4610
4888
|
SET download_status = 'failed',
|
|
4611
4889
|
download_error = ?
|
|
4612
4890
|
WHERE id = ?
|
|
4613
|
-
`).run(String(error || 'unknown')
|
|
4891
|
+
`).run(truncateAfterMask(durableText(String(error || 'unknown')), 500), id);
|
|
4614
4892
|
},
|
|
4615
4893
|
|
|
4616
4894
|
setAttachmentTranscription(id, text) {
|
|
4617
4895
|
return db.prepare(`
|
|
4618
4896
|
UPDATE attachments SET transcription = ? WHERE id = ?
|
|
4619
|
-
`).run(text || null, id);
|
|
4897
|
+
`).run(durableJson(text) || null, id);
|
|
4620
4898
|
},
|
|
4621
4899
|
|
|
4622
4900
|
getAttachmentsByMessage(message_id) {
|
|
@@ -4698,6 +4976,32 @@ function wrap(db) {
|
|
|
4698
4976
|
`).get(messageId);
|
|
4699
4977
|
},
|
|
4700
4978
|
|
|
4979
|
+
getForegroundCanaryTarget({ botName, chatId, telegramMessageId }) {
|
|
4980
|
+
const messageIdText = String(telegramMessageId);
|
|
4981
|
+
const messageId = Number(messageIdText);
|
|
4982
|
+
if (
|
|
4983
|
+
!Number.isSafeInteger(messageId)
|
|
4984
|
+
|| messageId <= 0
|
|
4985
|
+
|| String(messageId) !== messageIdText
|
|
4986
|
+
) return null;
|
|
4987
|
+
const row = getForegroundCanaryTargetStmt.get(
|
|
4988
|
+
messageIdText,
|
|
4989
|
+
String(botName),
|
|
4990
|
+
String(chatId),
|
|
4991
|
+
messageId,
|
|
4992
|
+
);
|
|
4993
|
+
if (!row) return null;
|
|
4994
|
+
return {
|
|
4995
|
+
messageId: row.message_id,
|
|
4996
|
+
chatId: String(row.chat_id),
|
|
4997
|
+
threadId: row.thread_id == null ? null : String(row.thread_id),
|
|
4998
|
+
telegramMessageId: row.telegram_message_id,
|
|
4999
|
+
sessionKey: row.session_key,
|
|
5000
|
+
provider: row.provider,
|
|
5001
|
+
handlerStatus: row.handler_status,
|
|
5002
|
+
};
|
|
5003
|
+
},
|
|
5004
|
+
|
|
4701
5005
|
listFailedAttachments({ since = null, limit = 100 } = {}) {
|
|
4702
5006
|
const cutoff = since ?? Date.now() - 24 * 60 * 60 * 1000;
|
|
4703
5007
|
return db.prepare(`
|
package/lib/handlers/abort.js
CHANGED
|
@@ -69,6 +69,10 @@ function createHandleAbort({
|
|
|
69
69
|
isAbortRequest,
|
|
70
70
|
markSessionAborted,
|
|
71
71
|
clearAutosteeredReactions,
|
|
72
|
+
// Narrow async hook: retire the question state for a session whose process
|
|
73
|
+
// is being stopped. Kept as a callback so this handler never learns about
|
|
74
|
+
// the question store.
|
|
75
|
+
retireQuestionSession = null,
|
|
72
76
|
getSessionKey,
|
|
73
77
|
botName,
|
|
74
78
|
// Cancel-cheap (spec Finding 5): delay before the second background-shell
|
|
@@ -146,6 +150,12 @@ function createHandleAbort({
|
|
|
146
150
|
);
|
|
147
151
|
}
|
|
148
152
|
|
|
153
|
+
// Only after the stop is proven, and only ever cleanup: Codex cannot
|
|
154
|
+
// raise an interactive question, so this closes a row a Claude turn left
|
|
155
|
+
// behind on a session that has since moved to Codex. An unverified stop
|
|
156
|
+
// changes nothing, exactly as before.
|
|
157
|
+
if (stopVerified) await retireQuestionSession?.(sessionKey);
|
|
158
|
+
|
|
149
159
|
logEvent('abort-requested', {
|
|
150
160
|
chat_id: chatId,
|
|
151
161
|
user_id: msg.from?.id || null,
|
|
@@ -160,7 +170,8 @@ function createHandleAbort({
|
|
|
160
170
|
feedback_clear_error_code: feedbackClearErrorCode,
|
|
161
171
|
stop_verified: stopVerified,
|
|
162
172
|
stop_error_code: stopErrorCode,
|
|
163
|
-
|
|
173
|
+
// The stop phrasing is the user's own message; its size is the signal.
|
|
174
|
+
text_len: cleanText.length,
|
|
164
175
|
});
|
|
165
176
|
|
|
166
177
|
// 👍 means the complete native stop proof settled, not merely that an
|
|
@@ -301,6 +312,9 @@ function createHandleAbort({
|
|
|
301
312
|
}
|
|
302
313
|
|
|
303
314
|
clearAutosteeredReactions(sessionKey).catch(() => {});
|
|
315
|
+
// The stopped turn owned any open question; retire its state with the same
|
|
316
|
+
// operation every other retirement path uses.
|
|
317
|
+
await retireQuestionSession?.(sessionKey);
|
|
304
318
|
logEvent('abort-requested', {
|
|
305
319
|
chat_id: chatId, user_id: msg.from?.id || null,
|
|
306
320
|
had_active: hadActive,
|
|
@@ -320,9 +334,12 @@ function createHandleAbort({
|
|
|
320
334
|
in_flight: busyProbe.inFlight,
|
|
321
335
|
pending_turns: busyProbe.pendingTurns,
|
|
322
336
|
captured: busyProbe.captured,
|
|
323
|
-
|
|
337
|
+
// The pane tail is live TUI transcript; only whether one was
|
|
338
|
+
// captured is recorded, never its content.
|
|
324
339
|
} : null,
|
|
325
|
-
|
|
340
|
+
// The stop phrasing is the user's own message; its length is enough to
|
|
341
|
+
// tell a bare "stop" from a sentence that happened to contain one.
|
|
342
|
+
text_len: cleanText.length,
|
|
326
343
|
});
|
|
327
344
|
|
|
328
345
|
// Ack (locked design 2026-06-12, Ivan): a 👍 reaction on the user's stop
|
|
@@ -243,7 +243,7 @@ function createApprovals({
|
|
|
243
243
|
return;
|
|
244
244
|
}
|
|
245
245
|
logEvent('approval-resolved', {
|
|
246
|
-
id, status, by: userId,
|
|
246
|
+
id, status, by: userId, bot: botName,
|
|
247
247
|
});
|
|
248
248
|
|
|
249
249
|
// Edit the card to show the decision.
|
|
@@ -309,7 +309,7 @@ function createApprovals({
|
|
|
309
309
|
rows = approvals.sweepTimedOut();
|
|
310
310
|
} catch (err) {
|
|
311
311
|
logger.error?.(`[approvals] sweeper DB error: ${err.message}`);
|
|
312
|
-
logEvent('approval-sweep-failed', {
|
|
312
|
+
logEvent('approval-sweep-failed', { error_class: err.name || 'Error' });
|
|
313
313
|
return;
|
|
314
314
|
}
|
|
315
315
|
for (const row of rows) {
|