switchroom 0.13.33 → 0.13.36
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/bin/timezone-hook.sh +1 -1
- package/dist/agent-scheduler/index.js +8 -1
- package/dist/auth-broker/index.js +8 -1
- package/dist/cli/switchroom.js +176 -26
- package/dist/host-control/main.js +5222 -203
- package/dist/vault/approvals/kernel-server.js +9 -2
- package/dist/vault/broker/server.js +9 -2
- package/package.json +1 -1
- package/profiles/default/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/dist/gateway/gateway.js +234 -31
- package/telegram-plugin/docs/waiting-ux-spec.md +40 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +188 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +170 -15
- package/telegram-plugin/gateway/diff-preview-card.test.ts +2 -2
- package/telegram-plugin/gateway/diff-preview-card.ts +2 -2
- package/telegram-plugin/gateway/drive-write-approval.test.ts +70 -0
- package/telegram-plugin/gateway/drive-write-approval.ts +51 -2
- package/telegram-plugin/gateway/error-envelope-card.ts +64 -0
- package/telegram-plugin/gateway/gateway.ts +112 -15
- package/telegram-plugin/gateway/ipc-protocol.ts +10 -1
- package/telegram-plugin/gateway/oversize-card-body.test.ts +108 -0
- package/telegram-plugin/gateway/oversize-card-body.ts +114 -0
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +46 -1
- package/telegram-plugin/hooks/silent-end-interrupt-stop.mjs +118 -41
- package/telegram-plugin/hooks/silent-end-scan.mjs +190 -0
- package/telegram-plugin/pending-work-progress.ts +37 -1
- package/telegram-plugin/tests/boot-clears-clean-shutdown-marker.test.ts +75 -0
- package/telegram-plugin/tests/error-envelope-unlock-card.test.ts +79 -0
- package/telegram-plugin/tests/pending-work-progress.test.ts +134 -0
- package/telegram-plugin/tests/silent-end-integration.test.ts +268 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-integration.test.ts +242 -0
- package/telegram-plugin/tests/silent-end-interrupt-stop-scan.test.ts +314 -0
- package/telegram-plugin/tests/silent-end.test.ts +227 -38
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +51 -6
|
@@ -1,45 +1,65 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* Stop hook —
|
|
3
|
+
* Stop hook — deterministic guardrail that a turn ended with a final
|
|
4
|
+
* reply tool call.
|
|
4
5
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
6
|
+
* Closes #1775. The pre-fix hook depended on the gateway's
|
|
7
|
+
* `$TELEGRAM_STATE_DIR/silent-end-pending.json` file as its block/allow
|
|
8
|
+
* signal. That file is written by the gateway's `turn_end` handler,
|
|
9
|
+
* which runs DOWNSTREAM of session-tail processing the `turn_duration`
|
|
10
|
+
* JSONL line — and the JSONL line is itself written AFTER
|
|
11
|
+
* `stop_hook_summary`. Live evidence on clerk (12 correlated samples,
|
|
12
|
+
* 2026-05-25): state file lands ~175ms (range 111-287ms) after the
|
|
13
|
+
* hook fires. The race is structurally always lost. The hook never
|
|
14
|
+
* saw its OWN turn's silent-end signal; the mechanism only worked
|
|
15
|
+
* one-turn-delayed via stale state from prior turns.
|
|
10
16
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
17
|
+
* Fix: the hook now reads `transcript_path` from its event input
|
|
18
|
+
* (Claude Code flushes assistant content to the JSONL before firing
|
|
19
|
+
* Stop hooks — verified empirically because `secret-scrub-stop.mjs`
|
|
20
|
+
* already reads `transcript_path` at Stop time successfully) and
|
|
21
|
+
* scans the CURRENT turn's tool_use entries for a qualifying reply.
|
|
22
|
+
* No race window — the decision is derived from the transcript that
|
|
23
|
+
* is on disk at the moment the hook runs.
|
|
18
24
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
25
|
+
* The gateway's state file is preserved for retry-count
|
|
26
|
+
* bookkeeping (the 1-retry budget + `silent-end.ts` user-facing
|
|
27
|
+
* fallback chain). The SIGNAL changes; the budget mechanism does
|
|
28
|
+
* not.
|
|
29
|
+
*
|
|
30
|
+
* #1664 — "no final answer delivered" covers two cases: (a) the turn
|
|
31
|
+
* ended with zero outbound, and (b) the model sent only an interim
|
|
32
|
+
* ack via reply/stream_reply but left its real answer as plain
|
|
33
|
+
* transcript text. The transcript scan handles BOTH cleanly:
|
|
34
|
+
* - case (a) → no tool_use of reply tools in the turn → block
|
|
35
|
+
* - case (b) → tool_use present but `isFinalAnswerReply` returns
|
|
36
|
+
* false on every call → block
|
|
23
37
|
*
|
|
24
38
|
* Carve-outs preserved:
|
|
25
|
-
* -
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
39
|
+
* - NO_REPLY / HEARTBEAT_OK silent markers (`gateway.ts:6692`) → allow
|
|
40
|
+
* - Sub-agent (`isSidechain:true`) lines → skipped (the parent's
|
|
41
|
+
* reply obligation is not satisfied by a sub-agent's reply tool)
|
|
42
|
+
* - Cron-fired turns DO carry a topic chat and reach the silent-end
|
|
43
|
+
* path (`silent-end.ts:219-224`) — they must emit NO_REPLY
|
|
44
|
+
* explicitly, not be specially exempted here
|
|
29
45
|
*
|
|
30
46
|
* Protocol:
|
|
31
47
|
* Input: JSON on stdin — { session_id, transcript_path, ... }
|
|
32
48
|
* Output: exit 0 + empty stdout → allow stop.
|
|
33
49
|
* exit 0 + JSON stdout { decision: "block", reason: "..." } → re-prompt.
|
|
34
50
|
*
|
|
35
|
-
* Fail-open on
|
|
36
|
-
*
|
|
51
|
+
* Fail-open on every error path (no transcript / unreadable / no
|
|
52
|
+
* turn-start anchor / state-file write failure) — blocking on a
|
|
53
|
+
* malfunction is worse than the original race because it loops
|
|
54
|
+
* every session close.
|
|
37
55
|
*/
|
|
38
56
|
|
|
39
57
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
|
|
40
58
|
import { join } from 'node:path'
|
|
41
59
|
import { homedir } from 'node:os'
|
|
42
60
|
|
|
61
|
+
import { scanTurnForFinalReply } from './silent-end-scan.mjs'
|
|
62
|
+
|
|
43
63
|
// MUST stay in sync with SILENT_END_MAX_RETRIES in telegram-plugin/silent-end.ts
|
|
44
64
|
// (this hook is a standalone .mjs and can't import the TS module).
|
|
45
65
|
const MAX_RETRIES = 1
|
|
@@ -60,52 +80,109 @@ function main() {
|
|
|
60
80
|
const raw = readStdin().trim()
|
|
61
81
|
if (!raw) process.exit(0)
|
|
62
82
|
|
|
63
|
-
|
|
64
|
-
let _event
|
|
83
|
+
let event
|
|
65
84
|
try {
|
|
66
|
-
|
|
85
|
+
event = JSON.parse(raw)
|
|
67
86
|
} catch {
|
|
68
87
|
process.exit(0)
|
|
69
88
|
}
|
|
70
89
|
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
//
|
|
90
|
+
const transcriptPath = event?.transcript_path
|
|
91
|
+
if (!transcriptPath || typeof transcriptPath !== 'string' || !existsSync(transcriptPath)) {
|
|
92
|
+
// No transcript → can't scan → fail-open. Pre-fix the hook fell
|
|
93
|
+
// back to the state-file signal here; we deliberately do NOT do
|
|
94
|
+
// that anymore because the state-file signal is structurally
|
|
95
|
+
// stale (race-loses every time).
|
|
76
96
|
process.exit(0)
|
|
77
97
|
}
|
|
78
98
|
|
|
79
|
-
let
|
|
99
|
+
let jsonl
|
|
80
100
|
try {
|
|
81
|
-
|
|
82
|
-
} catch {
|
|
83
|
-
|
|
101
|
+
jsonl = readFileSync(transcriptPath, 'utf8')
|
|
102
|
+
} catch (err) {
|
|
103
|
+
process.stderr.write(
|
|
104
|
+
`[silent-end-interrupt] failed to read transcript ${transcriptPath}: ${err.message}\n`,
|
|
105
|
+
)
|
|
106
|
+
process.exit(0)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const decision = scanTurnForFinalReply(jsonl)
|
|
110
|
+
|
|
111
|
+
// 'allow' (qualifying reply or silent marker) and 'unknown' (no
|
|
112
|
+
// turn-start anchor in the scanned range — session restart,
|
|
113
|
+
// compaction, etc.) both allow the stop.
|
|
114
|
+
if (decision.decided !== 'block') {
|
|
84
115
|
process.exit(0)
|
|
85
116
|
}
|
|
86
117
|
|
|
118
|
+
// Retry-budget bookkeeping. The state file is read/written here
|
|
119
|
+
// as a counter ONLY — the decision was already made from the
|
|
120
|
+
// transcript above. If a state file exists from a prior turn that
|
|
121
|
+
// never got cleared (clean shutdown not perfect), this read still
|
|
122
|
+
// works; if absent, retryCount defaults to 0.
|
|
123
|
+
const stateDir = getStateDir()
|
|
124
|
+
const statePath = join(stateDir, 'silent-end-pending.json')
|
|
125
|
+
|
|
126
|
+
let state = {}
|
|
127
|
+
if (existsSync(statePath)) {
|
|
128
|
+
try {
|
|
129
|
+
state = JSON.parse(readFileSync(statePath, 'utf8'))
|
|
130
|
+
} catch {
|
|
131
|
+
// Corrupt — treat as fresh.
|
|
132
|
+
state = {}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
87
136
|
const retryCount = typeof state.retryCount === 'number' ? state.retryCount : 0
|
|
88
137
|
|
|
89
138
|
if (retryCount >= MAX_RETRIES) {
|
|
90
|
-
//
|
|
91
|
-
//
|
|
139
|
+
// Budget spent. Let the session end so the gateway's
|
|
140
|
+
// `silent-end.ts:recordUndeliveredTurnEnd` path delivers the
|
|
141
|
+
// user-facing fallback (the gateway sees `silentEnd.exhausted ===
|
|
142
|
+
// true` and posts SILENT_END_FALLBACK_TEXT).
|
|
92
143
|
process.stderr.write(
|
|
93
144
|
`[silent-end-interrupt] retry exhausted (retryCount=${retryCount} >= MAX_RETRIES=${MAX_RETRIES}) — allowing stop\n`,
|
|
94
145
|
)
|
|
95
146
|
process.exit(0)
|
|
96
147
|
}
|
|
97
148
|
|
|
98
|
-
//
|
|
149
|
+
// Persist incremented retry count so a follow-up Stop in the same
|
|
150
|
+
// chat hits the exhaustion branch above. The gateway's existing
|
|
151
|
+
// clearSilentEndState path (`silent-end.ts:155-180`) handles
|
|
152
|
+
// resetting on successful delivery.
|
|
153
|
+
//
|
|
154
|
+
// CRITICAL: include `turnKey` (and the supporting `chatId` / `threadId`)
|
|
155
|
+
// when the scan derived them from the enqueue envelope. The gateway's
|
|
156
|
+
// `recordSilentTurnEnd` (`silent-end.ts:114`) preserves retryCount
|
|
157
|
+
// ONLY when `prev.turnKey === args.turnKey`. Without turnKey here,
|
|
158
|
+
// the gateway's later write (~175ms after the hook) sees `prev.turnKey
|
|
159
|
+
// === undefined`, fails the match, and resets retryCount to 0 — which
|
|
160
|
+
// doubles the effective re-prompt budget vs. the design. With turnKey
|
|
161
|
+
// present (same chatKey shape the gateway uses), the match succeeds
|
|
162
|
+
// and the budget is honored.
|
|
163
|
+
const nextState = {
|
|
164
|
+
...state,
|
|
165
|
+
retryCount: retryCount + 1,
|
|
166
|
+
timestamp: Date.now(),
|
|
167
|
+
}
|
|
168
|
+
if (decision.turnKey) {
|
|
169
|
+
nextState.turnKey = decision.turnKey
|
|
170
|
+
nextState.chatId = decision.chatId
|
|
171
|
+
if (decision.threadId != null) {
|
|
172
|
+
nextState.threadId = decision.threadId
|
|
173
|
+
}
|
|
174
|
+
}
|
|
99
175
|
try {
|
|
100
|
-
writeFileSync(statePath, JSON.stringify(
|
|
176
|
+
writeFileSync(statePath, JSON.stringify(nextState), 'utf8')
|
|
101
177
|
} catch (err) {
|
|
102
178
|
process.stderr.write(`[silent-end-interrupt] failed to update state file: ${err.message}\n`)
|
|
103
|
-
// Fail-open:
|
|
179
|
+
// Fail-open: a retry-count write failure shouldn't loop the
|
|
180
|
+
// session forever.
|
|
104
181
|
process.exit(0)
|
|
105
182
|
}
|
|
106
183
|
|
|
107
184
|
process.stderr.write(
|
|
108
|
-
`[silent-end-interrupt] blocking stop to re-prompt agent (
|
|
185
|
+
`[silent-end-interrupt] blocking stop to re-prompt agent (transcriptScan=${decision.reason} retryCount was ${retryCount})\n`,
|
|
109
186
|
)
|
|
110
187
|
|
|
111
188
|
process.stdout.write(
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for the silent-end Stop hook — extracted so unit tests
|
|
3
|
+
* can exercise the scan logic without spawning the .mjs subprocess.
|
|
4
|
+
*
|
|
5
|
+
* Closes the race documented in #1775: the gateway writes
|
|
6
|
+
* `silent-end-pending.json` only AFTER the Stop hook fires (the
|
|
7
|
+
* gateway's `turn_end` handler runs downstream of the `turn_duration`
|
|
8
|
+
* JSONL line, which is itself written AFTER `stop_hook_summary`). The
|
|
9
|
+
* fix: the hook stops depending on the gateway's state file as its
|
|
10
|
+
* SIGNAL and instead scans `transcript_path` directly. Claude Code
|
|
11
|
+
* flushes assistant content to the JSONL before firing Stop hooks
|
|
12
|
+
* (verified empirically: `telegram-plugin/hooks/secret-scrub-stop.mjs`
|
|
13
|
+
* already reads `transcript_path` at Stop time successfully in
|
|
14
|
+
* production), so a transcript scan is race-free.
|
|
15
|
+
*
|
|
16
|
+
* The state file is preserved for retry-count bookkeeping (the
|
|
17
|
+
* 1-retry budget + user-facing fallback chain in `silent-end.ts`),
|
|
18
|
+
* but it is no longer the signal that drives the block/allow
|
|
19
|
+
* decision.
|
|
20
|
+
*
|
|
21
|
+
* Same `isFinalAnswerReply` predicate the gateway applies at every
|
|
22
|
+
* reply callsite (`final-answer-detect.ts:78-83`):
|
|
23
|
+
* done===true OR !disableNotification OR text.length >= 200
|
|
24
|
+
*
|
|
25
|
+
* Plus the `NO_REPLY` / `HEARTBEAT_OK` silent-marker carve-out — if
|
|
26
|
+
* the model explicitly emitted that sentinel through the reply tool,
|
|
27
|
+
* the turn is "intentionally silent" and the hook must allow stop.
|
|
28
|
+
*
|
|
29
|
+
* Sidechain filter: sub-agent (Task) tool_use lines that leak into
|
|
30
|
+
* the parent transcript with `isSidechain:true` are skipped. The
|
|
31
|
+
* sub-agent's OWN replies live in `subagents/agent-<id>.jsonl` (per
|
|
32
|
+
* `session-tail.ts:277-281`) and never count toward the parent's
|
|
33
|
+
* delivery obligation.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const REPLY_TOOLS = new Set([
|
|
37
|
+
'mcp__switchroom-telegram__reply',
|
|
38
|
+
'mcp__switchroom-telegram__stream_reply',
|
|
39
|
+
])
|
|
40
|
+
const FINAL_ANSWER_MIN_CHARS = 200
|
|
41
|
+
// Match the gateway's silent-marker classifier (gateway.ts:6692 — the
|
|
42
|
+
// `isSilentFlushMarker` helper accepts trailing punctuation + case
|
|
43
|
+
// variants like "NO_REPLY." / "no_reply").
|
|
44
|
+
const SILENT_MARKER_RE = /^(NO_REPLY|HEARTBEAT_OK)[\s.!?]*$/i
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Predicate ported from `telegram-plugin/final-answer-detect.ts:78-83`.
|
|
48
|
+
* Kept in this .mjs so the hook is fully self-contained (no TS import).
|
|
49
|
+
* If the TS file ever diverges, the test fixture below (T14) catches it.
|
|
50
|
+
*/
|
|
51
|
+
export function isFinalAnswerReply({ text, disableNotification, done }) {
|
|
52
|
+
if (done === true) return true
|
|
53
|
+
if (!disableNotification) return true
|
|
54
|
+
if ((text ?? '').length >= FINAL_ANSWER_MIN_CHARS) return true
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Parse a `<channel ...>` envelope's chat_id and message_thread_id
|
|
60
|
+
* attributes. Same shape session-tail.ts:125-140 uses to derive these
|
|
61
|
+
* from the enqueue line's `content` string.
|
|
62
|
+
*
|
|
63
|
+
* Returns `null` if the envelope can't be parsed (caller treats as
|
|
64
|
+
* "no turn key derivable" and writes a turnKey-less state file —
|
|
65
|
+
* still functional, just loses retry-count preservation across the
|
|
66
|
+
* hook→gateway write order).
|
|
67
|
+
*
|
|
68
|
+
* @param {string} content
|
|
69
|
+
* @returns {{ chatId: string | null, threadId: number | null }}
|
|
70
|
+
*/
|
|
71
|
+
function parseChannelEnvelope(content) {
|
|
72
|
+
if (typeof content !== 'string') return { chatId: null, threadId: null }
|
|
73
|
+
const chatMatch = content.match(/chat_id="([^"]+)"/)
|
|
74
|
+
const threadMatch = content.match(/message_thread_id="([^"]+)"/)
|
|
75
|
+
const threadRaw = threadMatch ? Number(threadMatch[1]) : NaN
|
|
76
|
+
return {
|
|
77
|
+
chatId: chatMatch ? chatMatch[1] : null,
|
|
78
|
+
threadId: Number.isFinite(threadRaw) && threadRaw !== 0 ? threadRaw : null,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Build the turnKey the gateway will use for `recordSilentTurnEnd`'s
|
|
84
|
+
* write of the state file. Matches `chatKey(chatId, threadId)` shape
|
|
85
|
+
* at `gateway/chat-key.ts:46`: `${chatId}:${threadId || '_'}`.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} chatId
|
|
88
|
+
* @param {number | null} threadId
|
|
89
|
+
* @returns {string}
|
|
90
|
+
*/
|
|
91
|
+
function buildTurnKey(chatId, threadId) {
|
|
92
|
+
return `${chatId}:${threadId == null || threadId === 0 ? '_' : threadId}`
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Scan a JSONL transcript and decide whether the current turn ended
|
|
97
|
+
* with a final reply delivered.
|
|
98
|
+
*
|
|
99
|
+
* Returns:
|
|
100
|
+
* { decided: 'allow', reason } — qualifying reply OR silent marker found
|
|
101
|
+
* { decided: 'block', reason, turnKey?, chatId?, threadId? }
|
|
102
|
+
* — turn-start found, no qualifying reply,
|
|
103
|
+
* no marker. `turnKey`/`chatId`/`threadId`
|
|
104
|
+
* populated from the enqueue's channel
|
|
105
|
+
* envelope so the hook can write a state
|
|
106
|
+
* file shape that matches what the
|
|
107
|
+
* gateway's `recordSilentTurnEnd` would
|
|
108
|
+
* write — keeping the retry-count
|
|
109
|
+
* preservation gate at
|
|
110
|
+
* `silent-end.ts:114` happy when the
|
|
111
|
+
* gateway's later write reads back the
|
|
112
|
+
* hook's state.
|
|
113
|
+
* { decided: 'unknown', reason } — couldn't locate turn-start; caller fail-open
|
|
114
|
+
*
|
|
115
|
+
* Turn-start anchor: the most recent `queue-operation`/`enqueue` line
|
|
116
|
+
* (the inbound message the gateway pushed onto the session). For
|
|
117
|
+
* queued mid-turn messages (multiple `enqueue` lines per "turn"), we
|
|
118
|
+
* anchor on the LAST enqueue — the model is responsible for at least
|
|
119
|
+
* the most recent message. (Mild over-allow risk on the multi-enqueue
|
|
120
|
+
* edge case where the model replied combined ahead of the second
|
|
121
|
+
* enqueue's append; accepted residual.)
|
|
122
|
+
*
|
|
123
|
+
* @param {string} jsonl
|
|
124
|
+
* @returns {{ decided: 'allow' | 'block' | 'unknown', reason: string, turnKey?: string, chatId?: string, threadId?: number | null }}
|
|
125
|
+
*/
|
|
126
|
+
export function scanTurnForFinalReply(jsonl) {
|
|
127
|
+
const lines = jsonl.split('\n')
|
|
128
|
+
|
|
129
|
+
// 1. Walk backward to most-recent queue-operation/enqueue.
|
|
130
|
+
let startIdx = -1
|
|
131
|
+
let envelope = { chatId: null, threadId: null }
|
|
132
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
133
|
+
const line = lines[i]
|
|
134
|
+
if (!line || line[0] !== '{') continue
|
|
135
|
+
let obj
|
|
136
|
+
try { obj = JSON.parse(line) } catch { continue }
|
|
137
|
+
if (obj?.type === 'queue-operation' && obj.operation === 'enqueue') {
|
|
138
|
+
startIdx = i
|
|
139
|
+
envelope = parseChannelEnvelope(obj.content)
|
|
140
|
+
break
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (startIdx < 0) {
|
|
144
|
+
return { decided: 'unknown', reason: 'no-turn-start' }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 2. Scan forward from the turn start; look for qualifying tool_use
|
|
148
|
+
// or silent-marker text.
|
|
149
|
+
for (let i = startIdx + 1; i < lines.length; i++) {
|
|
150
|
+
const line = lines[i]
|
|
151
|
+
if (!line || line[0] !== '{') continue
|
|
152
|
+
let obj
|
|
153
|
+
try { obj = JSON.parse(line) } catch { continue }
|
|
154
|
+
// Skip sub-agent contamination (defensive — sub-agent lines should
|
|
155
|
+
// be in a separate transcript file, but `isSidechain:true` is the
|
|
156
|
+
// documented marker if they leak).
|
|
157
|
+
if (obj?.isSidechain === true) continue
|
|
158
|
+
if (obj?.type !== 'assistant') continue
|
|
159
|
+
const content = obj?.message?.content
|
|
160
|
+
if (!Array.isArray(content)) continue
|
|
161
|
+
for (const c of content) {
|
|
162
|
+
if (c?.type !== 'tool_use') continue
|
|
163
|
+
if (!REPLY_TOOLS.has(c.name)) continue
|
|
164
|
+
const input = c.input ?? {}
|
|
165
|
+
const text = String(input.text ?? '')
|
|
166
|
+
// Silent-marker carve-out: the operator explicitly signaled
|
|
167
|
+
// "intentionally silent" (cron HEARTBEAT_OK, model-driven
|
|
168
|
+
// NO_REPLY). Don't block — same posture as the gateway's
|
|
169
|
+
// silent-marker suppression at gateway.ts:6692.
|
|
170
|
+
if (SILENT_MARKER_RE.test(text.trim())) {
|
|
171
|
+
return { decided: 'allow', reason: 'silent-marker' }
|
|
172
|
+
}
|
|
173
|
+
if (isFinalAnswerReply({
|
|
174
|
+
text,
|
|
175
|
+
disableNotification: input.disable_notification === true,
|
|
176
|
+
done: input.done === true,
|
|
177
|
+
})) {
|
|
178
|
+
return { decided: 'allow', reason: 'final-reply' }
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const block = { decided: 'block', reason: 'no-final-reply' }
|
|
184
|
+
if (envelope.chatId) {
|
|
185
|
+
block.chatId = envelope.chatId
|
|
186
|
+
block.threadId = envelope.threadId
|
|
187
|
+
block.turnKey = buildTurnKey(envelope.chatId, envelope.threadId)
|
|
188
|
+
}
|
|
189
|
+
return block
|
|
190
|
+
}
|
|
@@ -112,6 +112,20 @@ export interface PendingProgressDeps {
|
|
|
112
112
|
nowMs?: () => number
|
|
113
113
|
/** Optional poll interval override for tests. */
|
|
114
114
|
pollIntervalMs?: number
|
|
115
|
+
/**
|
|
116
|
+
* Defense-in-depth (#1760). When provided, returns the gateway's
|
|
117
|
+
* `activeTurnStartedAt` epoch ms for this chat key, or undefined if no
|
|
118
|
+
* turn is currently active. The ticker uses this on every fire to detect
|
|
119
|
+
* a stale ambient: if a NEWER turn has started (epoch > our activatedAt)
|
|
120
|
+
* the prior turn's cross-turn pending-progress is by definition orphaned
|
|
121
|
+
* (the turn_end teardown was missed, e.g. SDK event dropped) and the
|
|
122
|
+
* ticker self-terminates instead of editing a stale anchor. Converts the
|
|
123
|
+
* #1760 failure mode from "stuck forever" to "at most one stale tick."
|
|
124
|
+
*
|
|
125
|
+
* Defaults to undefined — preserves prior behaviour for tests that
|
|
126
|
+
* exercise the ticker without a gateway.
|
|
127
|
+
*/
|
|
128
|
+
isActiveTurnNewerThan?: (key: string, activatedAt: number) => boolean
|
|
115
129
|
}
|
|
116
130
|
|
|
117
131
|
interface State {
|
|
@@ -276,7 +290,14 @@ export function noteTurnEnd(key: string): void {
|
|
|
276
290
|
*/
|
|
277
291
|
export function clearPending(
|
|
278
292
|
key: string,
|
|
279
|
-
reason:
|
|
293
|
+
reason:
|
|
294
|
+
| 'inbound'
|
|
295
|
+
| 'handback'
|
|
296
|
+
| 'progress'
|
|
297
|
+
| 'timeout'
|
|
298
|
+
| 'manual'
|
|
299
|
+
| 'reply_finalize'
|
|
300
|
+
| 'stale_turn',
|
|
280
301
|
): void {
|
|
281
302
|
if (!stateByKey.has(key)) return
|
|
282
303
|
const s = stateByKey.get(key)!
|
|
@@ -337,6 +358,21 @@ function tick(now: number): void {
|
|
|
337
358
|
continue
|
|
338
359
|
}
|
|
339
360
|
|
|
361
|
+
// #1760 defense-in-depth: if a newer turn is currently active for
|
|
362
|
+
// this chat, the prior turn's cross-turn pending-progress is stale
|
|
363
|
+
// (the canonical teardown — turn_end or the next turn's reply-
|
|
364
|
+
// finalize — was missed). Drop the timer instead of editing the
|
|
365
|
+
// old anchor; the new turn will manage its own anchor via the
|
|
366
|
+
// regular noteOutbound / noteTurnEnd path. Converts "stuck forever"
|
|
367
|
+
// (the live #1760 evidence) into "at most one stale tick."
|
|
368
|
+
if (
|
|
369
|
+
activeDeps.isActiveTurnNewerThan != null
|
|
370
|
+
&& activeDeps.isActiveTurnNewerThan(key, s.activatedAt)
|
|
371
|
+
) {
|
|
372
|
+
clearPending(key, 'stale_turn')
|
|
373
|
+
continue
|
|
374
|
+
}
|
|
375
|
+
|
|
340
376
|
const sinceEdit = s.lastEditAt == null ? 0 : now - s.lastEditAt
|
|
341
377
|
if (sinceEdit < EDIT_INTERVAL_MS) continue
|
|
342
378
|
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regression guard for the marker-stale crash banner class.
|
|
3
|
+
*
|
|
4
|
+
* Pre-2026-05-25 the boot path read the clean-shutdown marker but
|
|
5
|
+
* never cleared it. A marker from a graceful shutdown 11 hours ago
|
|
6
|
+
* sat on disk untouched; subsequent boots after an unhandledRejection
|
|
7
|
+
* crash (which explicitly SKIPS writing a new marker, per
|
|
8
|
+
* gateway.ts:15107) read the stale marker, classified the age as
|
|
9
|
+
* >5min, and fired `boot.clean_shutdown_marker_stale age=39976s` →
|
|
10
|
+
* `reason=crash` → `agent-crashed` operator-event banner posted to
|
|
11
|
+
* the user's chat.
|
|
12
|
+
*
|
|
13
|
+
* That misclassified the user-visible state ("clerk seems to be
|
|
14
|
+
* crashing") because the banner detail included the stale-marker
|
|
15
|
+
* artifact rather than just naming the actual crash.
|
|
16
|
+
*
|
|
17
|
+
* Fix: clear the marker after every successful boot reads it. The
|
|
18
|
+
* marker now describes the IMMEDIATELY PRECEDING shutdown only;
|
|
19
|
+
* a subsequent crash with no marker write leaves an empty marker
|
|
20
|
+
* file, and boot-reason.ts:84 correctly classifies via the
|
|
21
|
+
* sessionMarker fallback.
|
|
22
|
+
*
|
|
23
|
+
* The gateway IIFE is too entangled to instantiate in-process; this
|
|
24
|
+
* is a source-level pin matching the pattern used by
|
|
25
|
+
* `reply-terminal-reaction.test.ts` and `buffer-gate-broadened.test.ts`.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { describe, it, expect } from 'vitest'
|
|
29
|
+
import { readFileSync } from 'node:fs'
|
|
30
|
+
import { resolve } from 'node:path'
|
|
31
|
+
|
|
32
|
+
const gatewaySrc = readFileSync(
|
|
33
|
+
resolve(__dirname, '..', 'gateway', 'gateway.ts'),
|
|
34
|
+
'utf-8',
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
describe('boot path clears the clean-shutdown marker after reading it', () => {
|
|
38
|
+
it('imports clearCleanShutdownMarker (no longer the "intentionally not imported" comment)', () => {
|
|
39
|
+
// Pre-fix the import block had a `clearCleanShutdownMarker is
|
|
40
|
+
// intentionally NOT imported here` block-comment, with a rationale
|
|
41
|
+
// that was wrong for the unhandledRejection edge case. If a future
|
|
42
|
+
// commit re-removes the import (and re-adds the wrong comment),
|
|
43
|
+
// this test trips.
|
|
44
|
+
expect(gatewaySrc).toMatch(/^\s*clearCleanShutdownMarker,$/m)
|
|
45
|
+
// The old "intentionally NOT imported" comment must be gone.
|
|
46
|
+
expect(gatewaySrc).not.toMatch(/clearCleanShutdownMarker is intentionally NOT imported/)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('calls clearCleanShutdownMarker inside the marker-read block at boot', () => {
|
|
50
|
+
// Slice the marker-read block (between the boot.clean_shutdown_*
|
|
51
|
+
// diagnostic logs and the next `if (marker)` line). The clear call
|
|
52
|
+
// MUST appear inside this block, not later in the boot flow —
|
|
53
|
+
// future readers should see the read and clear together.
|
|
54
|
+
const anchor = gatewaySrc.indexOf('boot.clean_shutdown_detected')
|
|
55
|
+
expect(anchor).toBeGreaterThan(-1)
|
|
56
|
+
const slice = gatewaySrc.slice(anchor, anchor + 4000)
|
|
57
|
+
expect(slice).toMatch(/clearCleanShutdownMarker\(GATEWAY_CLEAN_SHUTDOWN_MARKER_PATH\)/)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('clear comment explains the unhandledRejection edge case', () => {
|
|
61
|
+
// Future maintainers MUST understand why the clear is here.
|
|
62
|
+
// The comment block above the call references the
|
|
63
|
+
// unhandledRejection / "crash path" semantics so the next
|
|
64
|
+
// engineer doesn't remove it as cleanup.
|
|
65
|
+
const callIdx = gatewaySrc.indexOf(
|
|
66
|
+
'clearCleanShutdownMarker(GATEWAY_CLEAN_SHUTDOWN_MARKER_PATH)',
|
|
67
|
+
)
|
|
68
|
+
expect(callIdx).toBeGreaterThan(-1)
|
|
69
|
+
// The 1500 chars immediately before the call should mention the
|
|
70
|
+
// failure mode this fixes (the comment block sits right above
|
|
71
|
+
// the call and is ~1100 chars at current writing).
|
|
72
|
+
const lead = gatewaySrc.slice(Math.max(0, callIdx - 1500), callIdx)
|
|
73
|
+
expect(lead).toMatch(/unhandledRejection|crash path/)
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Telegram bridge unlock-card safety (#1758 Phase 1).
|
|
3
|
+
*
|
|
4
|
+
* The bridge MUST validate `flip_yaml_flag.yaml_path` against the
|
|
5
|
+
* config-edit-validator allowlist before rendering a one-tap approval
|
|
6
|
+
* card. A malformed or hostile envelope from any backend could
|
|
7
|
+
* otherwise nudge the operator into approving an arbitrary flag flip.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect } from "vitest";
|
|
11
|
+
import { renderErrorEnvelopeCard } from "../gateway/error-envelope-card.js";
|
|
12
|
+
import type { HostdResponse } from "../../src/host-control/protocol.js";
|
|
13
|
+
|
|
14
|
+
function mkResp(fix: HostdResponse["error_envelope"]["fix"]): HostdResponse {
|
|
15
|
+
return {
|
|
16
|
+
v: 1,
|
|
17
|
+
request_id: "r-1",
|
|
18
|
+
result: "error",
|
|
19
|
+
exit_code: null,
|
|
20
|
+
duration_ms: 0,
|
|
21
|
+
error: "E_FOO: foo",
|
|
22
|
+
error_envelope: {
|
|
23
|
+
v: 1,
|
|
24
|
+
code: "E_FOO",
|
|
25
|
+
human: "foo",
|
|
26
|
+
fix,
|
|
27
|
+
request_id: "r-1",
|
|
28
|
+
},
|
|
29
|
+
} as HostdResponse;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("renderErrorEnvelopeCard — allowlist guard", () => {
|
|
33
|
+
it("renders an approval card for an allowlisted yaml_path", () => {
|
|
34
|
+
const resp = mkResp({
|
|
35
|
+
kind: "flip_yaml_flag",
|
|
36
|
+
yaml_path: "hostd.config_edit_enabled",
|
|
37
|
+
to: true,
|
|
38
|
+
});
|
|
39
|
+
const out = renderErrorEnvelopeCard(resp, "klanker", "a".repeat(32));
|
|
40
|
+
expect(out.kind).toBe("card");
|
|
41
|
+
if (out.kind === "card") {
|
|
42
|
+
expect(out.yaml_path).toBe("hostd.config_edit_enabled");
|
|
43
|
+
expect(out.to).toBe(true);
|
|
44
|
+
expect(out.card.text).toContain("klanker");
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("falls back to plain-text for a NON-allowlisted yaml_path", () => {
|
|
49
|
+
const resp = mkResp({
|
|
50
|
+
kind: "flip_yaml_flag",
|
|
51
|
+
yaml_path: "hostd.evil_backdoor_flag",
|
|
52
|
+
to: true,
|
|
53
|
+
});
|
|
54
|
+
const out = renderErrorEnvelopeCard(resp, "klanker", "a".repeat(32));
|
|
55
|
+
expect(out).toEqual({ kind: "plain-text" });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("falls back to plain-text for request_vault_grant (Phase 2 scope)", () => {
|
|
59
|
+
const resp = mkResp({
|
|
60
|
+
kind: "request_vault_grant",
|
|
61
|
+
vault_key: "openai/api-key",
|
|
62
|
+
});
|
|
63
|
+
const out = renderErrorEnvelopeCard(resp, "klanker", "a".repeat(32));
|
|
64
|
+
expect(out).toEqual({ kind: "plain-text" });
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("falls back to plain-text when no envelope is present", () => {
|
|
68
|
+
const resp: HostdResponse = {
|
|
69
|
+
v: 1,
|
|
70
|
+
request_id: "r-1",
|
|
71
|
+
result: "error",
|
|
72
|
+
exit_code: null,
|
|
73
|
+
duration_ms: 0,
|
|
74
|
+
error: "legacy string",
|
|
75
|
+
};
|
|
76
|
+
const out = renderErrorEnvelopeCard(resp, "klanker", "a".repeat(32));
|
|
77
|
+
expect(out).toEqual({ kind: "plain-text" });
|
|
78
|
+
});
|
|
79
|
+
});
|