switchroom 0.18.11 → 0.18.13
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/dist/agent-scheduler/index.js +37 -5
- package/dist/auth-broker/index.js +116 -78
- package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
- package/dist/cli/ms-365-write-pretool.mjs +31 -8
- package/dist/cli/notion-write-pretool.mjs +38 -6
- package/dist/cli/skill-validate-pretool.mjs +144 -2847
- package/dist/cli/switchroom.js +3529 -4543
- package/dist/cli/ui/index.html +163 -17
- package/dist/host-control/main.js +605 -2847
- package/dist/vault/approvals/kernel-server.js +120 -13
- package/dist/vault/broker/server.js +259 -157
- package/package.json +3 -4
- package/profiles/_base/start.sh.hbs +65 -0
- package/profiles/_shared/vault-protocol.md.hbs +3 -1
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +2 -2
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/telegram-plugin/bridge/bridge.ts +37 -0
- package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
- package/telegram-plugin/dist/bridge/bridge.js +122 -4
- package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
- package/telegram-plugin/dist/server.js +139 -5
- package/telegram-plugin/flood-circuit-breaker.ts +493 -21
- package/telegram-plugin/format.ts +19 -0
- package/telegram-plugin/gateway/approval-hold.ts +602 -0
- package/telegram-plugin/gateway/auth-command.ts +92 -2
- package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
- package/telegram-plugin/gateway/boot-card.ts +12 -5
- package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
- package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
- package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
- package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
- package/telegram-plugin/gateway/gateway.ts +1695 -230
- package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
- package/telegram-plugin/gateway/idle-clear.ts +90 -6
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
- package/telegram-plugin/gateway/inject-handler.ts +8 -0
- package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
- package/telegram-plugin/gateway/ipc-server.ts +43 -0
- package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
- package/telegram-plugin/gateway/model-command.ts +9 -3
- package/telegram-plugin/gateway/pending-session-command.ts +13 -1
- package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
- package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
- package/telegram-plugin/gateway/queued-card-store.ts +217 -0
- package/telegram-plugin/gateway/session-model-file.ts +26 -1
- package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
- package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
- package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
- package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
- package/telegram-plugin/history.ts +51 -0
- package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
- package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
- package/telegram-plugin/hooks/hooks.json +9 -0
- package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
- package/telegram-plugin/model-unavailable.ts +41 -11
- package/telegram-plugin/operator-events.ts +23 -0
- package/telegram-plugin/outbound-field-redact.ts +69 -0
- package/telegram-plugin/package.json +0 -1
- package/telegram-plugin/permission-rule.ts +1 -0
- package/telegram-plugin/permission-title.ts +1 -0
- package/telegram-plugin/render/render.ts +32 -14
- package/telegram-plugin/retry-api-call.ts +212 -2
- package/telegram-plugin/scoped-approval.ts +11 -2
- package/telegram-plugin/secret-detect/chunker.ts +18 -4
- package/telegram-plugin/secret-detect/index.ts +12 -56
- package/telegram-plugin/send-gate-degraded.test.ts +574 -0
- package/telegram-plugin/send-gate-observability.test.ts +470 -0
- package/telegram-plugin/send-gate-observability.ts +355 -0
- package/telegram-plugin/send-gate.test.ts +717 -0
- package/telegram-plugin/send-gate.ts +1056 -0
- package/telegram-plugin/session-tail.ts +82 -7
- package/telegram-plugin/shared/bot-runtime.ts +17 -5
- package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
- package/telegram-plugin/status-pin-driver.ts +52 -7
- package/telegram-plugin/status-pin.ts +81 -0
- package/telegram-plugin/subagent-watcher.ts +173 -18
- package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
- package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
- package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
- package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
- package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
- package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
- package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
- package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
- package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
- package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
- package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
- package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
- package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
- package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
- package/telegram-plugin/tests/history.test.ts +115 -0
- package/telegram-plugin/tests/idle-clear.test.ts +233 -3
- package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
- package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
- package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
- package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
- package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
- package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
- package/telegram-plugin/tests/model-command.test.ts +14 -0
- package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
- package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
- package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
- package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
- package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
- package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
- package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
- package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
- package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
- package/telegram-plugin/tests/render/render.test.ts +88 -0
- package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
- package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
- package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
- package/telegram-plugin/tests/session-model-file.test.ts +50 -0
- package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
- package/telegram-plugin/tests/status-pin.test.ts +275 -1
- package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
- package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
- package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
- package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
- package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
- package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
- package/telegram-plugin/typing-emitter.ts +224 -0
- package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
- package/telegram-plugin/welcome-text.ts +42 -0
- package/telegram-plugin/worktree-watch-cwds.ts +194 -5
- package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
- package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
- package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
- package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
- package/vendor/hindsight-memory/scripts/session_end.py +14 -3
- package/vendor/hindsight-memory/scripts/session_start.py +21 -0
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
- package/vendor/hindsight-memory/tests/test_pending.py +44 -0
- package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
- package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
- package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
- package/telegram-plugin/channel-envelope-safety.ts +0 -56
- package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
- package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync, existsSync, statSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import {
|
|
6
|
+
readFloodWindows,
|
|
7
|
+
writeFloodWindow,
|
|
8
|
+
makeFloodWindowRecorder,
|
|
9
|
+
loadInitialFloodWindows,
|
|
10
|
+
floodWindowsPath,
|
|
11
|
+
floodStatePath,
|
|
12
|
+
writeFloodState,
|
|
13
|
+
computeFloodWait,
|
|
14
|
+
FLOOD_STATE_MODE,
|
|
15
|
+
FLOOD_WINDOWS_CORRUPT_SUPPRESS_MS,
|
|
16
|
+
} from '../flood-circuit-breaker.js'
|
|
17
|
+
import { createSendGate, type Clock } from '../send-gate.js'
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* #3084 PR 2 — restart-proof SCOPED flood windows (part3-design §7). Verifies
|
|
21
|
+
* the write-through/round-trip contract on real temp files (isolated per the
|
|
22
|
+
* repo's vault/shared-state test discipline) and that a gate reconstructed from
|
|
23
|
+
* disk is STILL blocked — the whole point of §7 (a boot mid-ban must not resend
|
|
24
|
+
* into the open window).
|
|
25
|
+
*/
|
|
26
|
+
describe('#3084 scoped flood-window persistence', () => {
|
|
27
|
+
let dir: string
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
dir = mkdtempSync(join(tmpdir(), 'flood-win-'))
|
|
30
|
+
})
|
|
31
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }))
|
|
32
|
+
|
|
33
|
+
it('round-trips a scoped window and prunes expired entries', () => {
|
|
34
|
+
const path = floodWindowsPath(dir)
|
|
35
|
+
const now = 1_000_000
|
|
36
|
+
writeFloodWindow(path, { scopeKey: 'chat:5', untilTs: now + 60_000, retryAfterSrc: '429', observedAt: now }, now)
|
|
37
|
+
writeFloodWindow(path, { scopeKey: 'global', untilTs: now + 10_000, retryAfterSrc: '429', observedAt: now }, now)
|
|
38
|
+
|
|
39
|
+
// Before either expires: both present.
|
|
40
|
+
const live = readFloodWindows(path, now + 5_000)
|
|
41
|
+
expect(live.map((w) => w.scopeKey).sort()).toEqual(['chat:5', 'global'])
|
|
42
|
+
|
|
43
|
+
// After the global one expires: only chat:5 survives (prune-on-read).
|
|
44
|
+
const later = readFloodWindows(path, now + 20_000)
|
|
45
|
+
expect(later.map((w) => w.scopeKey)).toEqual(['chat:5'])
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('EXTENDS never shortens a window on a later, shorter re-record (boot-never-shortens)', () => {
|
|
49
|
+
const path = floodWindowsPath(dir)
|
|
50
|
+
const now = 1_000_000
|
|
51
|
+
// A long ban is recorded.
|
|
52
|
+
writeFloodWindow(path, { scopeKey: 'global', untilTs: now + 600_000, retryAfterSrc: '429', observedAt: now }, now)
|
|
53
|
+
// A later, SHORTER 429 (or a restart re-observing a smaller retry_after)
|
|
54
|
+
// must not pull the expiry earlier.
|
|
55
|
+
writeFloodWindow(
|
|
56
|
+
path,
|
|
57
|
+
{ scopeKey: 'global', untilTs: now + 1_000 + 5_000, retryAfterSrc: '429', observedAt: now + 1_000 },
|
|
58
|
+
now + 1_000,
|
|
59
|
+
)
|
|
60
|
+
const w = readFloodWindows(path, now + 2_000).find((x) => x.scopeKey === 'global')
|
|
61
|
+
expect(w?.untilTs).toBe(now + 600_000)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('loadInitialFloodWindows combines the global flood-wait.json window with scoped windows', () => {
|
|
65
|
+
const now = 1_000_000
|
|
66
|
+
const statePath = floodStatePath(dir)
|
|
67
|
+
const winPath = floodWindowsPath(dir)
|
|
68
|
+
writeFloodState(statePath, computeFloodWait(null, 300, now)) // global, +300s
|
|
69
|
+
writeFloodWindow(winPath, { scopeKey: 'chat:9', untilTs: now + 120_000, retryAfterSrc: '429', observedAt: now }, now)
|
|
70
|
+
|
|
71
|
+
const initial = loadInitialFloodWindows(statePath, winPath, now)
|
|
72
|
+
const scopes = initial.map((w) => w.scopeKey).sort()
|
|
73
|
+
expect(scopes).toEqual(['chat:9', 'global'])
|
|
74
|
+
expect(initial.find((w) => w.scopeKey === 'global')?.untilTs).toBe(now + 300_000)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('gate opens a window on a 429 → persists → a NEW gate built from disk is still blocked', async () => {
|
|
78
|
+
const winPath = floodWindowsPath(dir)
|
|
79
|
+
const statePath = floodStatePath(dir)
|
|
80
|
+
const recorder = makeFloodWindowRecorder(winPath, () => 0)
|
|
81
|
+
|
|
82
|
+
// Gate 1: a critical send hits a 429 (FLOOD_WAIT_ACTIVE) which opens +
|
|
83
|
+
// persists scope windows via onWindowOpen.
|
|
84
|
+
const clock1: Clock = { now: () => 0, sleep: () => Promise.resolve() }
|
|
85
|
+
const gate1 = createSendGate({
|
|
86
|
+
enabled: true,
|
|
87
|
+
clock: clock1,
|
|
88
|
+
onWindowOpen: (scopeKey, untilTs) => recorder(scopeKey, untilTs),
|
|
89
|
+
})
|
|
90
|
+
const floodErr = Object.assign(new Error('FLOOD_WAIT_ACTIVE'), {
|
|
91
|
+
retryAfterSec: 600,
|
|
92
|
+
untilTs: 600_000,
|
|
93
|
+
error_code: 429 as const,
|
|
94
|
+
parameters: { retry_after: 600 },
|
|
95
|
+
original: null,
|
|
96
|
+
})
|
|
97
|
+
await expect(
|
|
98
|
+
gate1.gate(
|
|
99
|
+
async () => {
|
|
100
|
+
throw floodErr
|
|
101
|
+
},
|
|
102
|
+
{ chat_id: '7', priorityClass: 'critical' },
|
|
103
|
+
),
|
|
104
|
+
).rejects.toBe(floodErr)
|
|
105
|
+
|
|
106
|
+
// The window is on disk.
|
|
107
|
+
const persisted = readFloodWindows(winPath, 0)
|
|
108
|
+
expect(persisted.map((w) => w.scopeKey).sort()).toEqual(['chat:7', 'global'])
|
|
109
|
+
|
|
110
|
+
// Gate 2 (simulated restart): built BEFORE any outbound call from the
|
|
111
|
+
// persisted windows. A cosmetic send on chat:7 must still shed.
|
|
112
|
+
const clock2: Clock = { now: () => 0, sleep: () => Promise.resolve() }
|
|
113
|
+
const gate2 = createSendGate({
|
|
114
|
+
enabled: true,
|
|
115
|
+
clock: clock2,
|
|
116
|
+
initialWindows: loadInitialFloodWindows(statePath, winPath, 0),
|
|
117
|
+
})
|
|
118
|
+
const res = await gate2.gate(async () => 'should-not-run', {
|
|
119
|
+
chat_id: '7',
|
|
120
|
+
priorityClass: 'cosmetic',
|
|
121
|
+
})
|
|
122
|
+
expect(res).toBeUndefined()
|
|
123
|
+
expect(gate2.stats().global.shed).toBe(1)
|
|
124
|
+
|
|
125
|
+
// And a critical into the still-long window fails fast — restart did not
|
|
126
|
+
// reset the ban.
|
|
127
|
+
await expect(
|
|
128
|
+
gate2.gate(async () => 'nope', { chat_id: '7', priorityClass: 'critical' }),
|
|
129
|
+
).rejects.toThrow('FLOOD_WAIT_ACTIVE')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('H2: writes flood-windows.json world-READABLE (0o644), not 0o600', () => {
|
|
133
|
+
// A 0o600 marker is unreadable to a gateway running under a DIFFERENT uid
|
|
134
|
+
// (root vs agent uid share the state dir), which silently defeats the
|
|
135
|
+
// restart-proof windows — a restart under the other uid boots blind and
|
|
136
|
+
// resends into the ban. Must match `flood-wait.json` (FLOOD_STATE_MODE).
|
|
137
|
+
const path = floodWindowsPath(dir)
|
|
138
|
+
const now = 2_000_000
|
|
139
|
+
writeFloodWindow(path, { scopeKey: 'global', untilTs: now + 60_000, retryAfterSrc: '429', observedAt: now }, now)
|
|
140
|
+
expect(statSync(path).mode & 0o777).toBe(FLOOD_STATE_MODE)
|
|
141
|
+
expect(FLOOD_STATE_MODE).toBe(0o644)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('M1: a CORRUPT flood-windows.json fails SAFE (conservative global window), not open', () => {
|
|
145
|
+
const path = floodWindowsPath(dir)
|
|
146
|
+
const now = 3_000_000
|
|
147
|
+
writeFileSync(path, '{ this is not valid json ][', 'utf-8')
|
|
148
|
+
const logs: string[] = []
|
|
149
|
+
const windows = readFloodWindows(path, now, (l) => logs.push(l))
|
|
150
|
+
// NOT [] (fail-open) — a bounded conservative global window instead.
|
|
151
|
+
expect(windows).toHaveLength(1)
|
|
152
|
+
expect(windows[0]!.scopeKey).toBe('global')
|
|
153
|
+
expect(windows[0]!.untilTs).toBe(now + FLOOD_WINDOWS_CORRUPT_SUPPRESS_MS)
|
|
154
|
+
// Logged loudly.
|
|
155
|
+
expect(logs.join('')).toMatch(/failing SAFE/)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('M1: a non-array flood-windows.json fails SAFE', () => {
|
|
159
|
+
const path = floodWindowsPath(dir)
|
|
160
|
+
const now = 3_500_000
|
|
161
|
+
writeFileSync(path, '{"scopeKey":"global"}', 'utf-8') // object, not array
|
|
162
|
+
const windows = readFloodWindows(path, now, () => {})
|
|
163
|
+
expect(windows).toEqual([
|
|
164
|
+
{
|
|
165
|
+
scopeKey: 'global',
|
|
166
|
+
untilTs: now + FLOOD_WINDOWS_CORRUPT_SUPPRESS_MS,
|
|
167
|
+
retryAfterSrc: expect.stringContaining('failsafe'),
|
|
168
|
+
observedAt: now,
|
|
169
|
+
},
|
|
170
|
+
])
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('M1: an UNREADABLE flood-windows.json (read error, not ENOENT) fails SAFE', () => {
|
|
174
|
+
// Root bypasses file-mode EACCES, so simulate a non-ENOENT read failure
|
|
175
|
+
// deterministically by making the windows path a DIRECTORY — readFileSync
|
|
176
|
+
// throws EISDIR, the same "file exists but I can't read it" class as EACCES.
|
|
177
|
+
const path = floodWindowsPath(dir)
|
|
178
|
+
const now = 4_000_000
|
|
179
|
+
mkdirSync(path)
|
|
180
|
+
const logs: string[] = []
|
|
181
|
+
const windows = readFloodWindows(path, now, (l) => logs.push(l))
|
|
182
|
+
expect(windows).toHaveLength(1)
|
|
183
|
+
expect(windows[0]!.scopeKey).toBe('global')
|
|
184
|
+
expect(windows[0]!.untilTs).toBe(now + FLOOD_WINDOWS_CORRUPT_SUPPRESS_MS)
|
|
185
|
+
expect(logs.join('')).toMatch(/unreadable/)
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('M1: a genuinely ABSENT file is still "no windows" (ENOENT ≠ corrupt)', () => {
|
|
189
|
+
const path = floodWindowsPath(dir) // never written
|
|
190
|
+
expect(readFloodWindows(path, 5_000_000, () => {})).toEqual([])
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('M3: flag-OFF gate opens NO window and writes NO flood-windows.json', () => {
|
|
194
|
+
// The gateway wires onFloodWait → openFloodWindow('global', …)
|
|
195
|
+
// unconditionally; with the flag OFF that must be a pure no-op — no in-memory
|
|
196
|
+
// suppression, no fs side effect (a poison-perms file created before the
|
|
197
|
+
// feature is ever enabled).
|
|
198
|
+
const path = floodWindowsPath(dir)
|
|
199
|
+
const recorder = makeFloodWindowRecorder(path, () => 0)
|
|
200
|
+
const clock: Clock = { now: () => 0, sleep: () => Promise.resolve() }
|
|
201
|
+
const gate = createSendGate({
|
|
202
|
+
enabled: false,
|
|
203
|
+
clock,
|
|
204
|
+
onWindowOpen: (scopeKey, untilTs) => recorder(scopeKey, untilTs),
|
|
205
|
+
})
|
|
206
|
+
gate.openFloodWindow('global', 600_000)
|
|
207
|
+
expect(existsSync(path)).toBe(false)
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
it('a boot loading initial windows never shortens an on-disk window', async () => {
|
|
211
|
+
const clock: Clock = { now: () => 0, sleep: () => Promise.resolve() }
|
|
212
|
+
// Boot with a long window applied at construction.
|
|
213
|
+
const gate = createSendGate({
|
|
214
|
+
enabled: true,
|
|
215
|
+
clock,
|
|
216
|
+
initialWindows: [{ scopeKey: 'global', untilTs: 600_000 }],
|
|
217
|
+
})
|
|
218
|
+
// Re-opening the SAME scope with a shorter untilTs must not shorten it.
|
|
219
|
+
gate.openFloodWindow('global', 10_000)
|
|
220
|
+
// A critical still fails fast against the ORIGINAL long window.
|
|
221
|
+
const e = await gate.gate(async () => 'x', { priorityClass: 'critical' }).catch((err) => err)
|
|
222
|
+
expect((e as { untilTs: number }).untilTs).toBe(600_000)
|
|
223
|
+
})
|
|
224
|
+
})
|
|
@@ -43,7 +43,7 @@ describe('gateway boot — clear stale turn-active marker', () => {
|
|
|
43
43
|
// window to ~5KB so we don't pick up the unrelated
|
|
44
44
|
// `removeTurnActiveMarker` call in the onTurnComplete handler
|
|
45
45
|
// way down at the bottom of the file.
|
|
46
|
-
const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart +
|
|
46
|
+
const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 12000)
|
|
47
47
|
const clearMatches = setupWindow.match(/removeTurnActiveMarker\s*\(\s*STATE_DIR\s*\)/g) ?? []
|
|
48
48
|
expect(clearMatches.length).toBe(1)
|
|
49
49
|
})
|
|
@@ -53,7 +53,7 @@ describe('gateway boot — clear stale turn-active marker', () => {
|
|
|
53
53
|
// must not prevent the gateway from coming up. Pin the try-wrapping
|
|
54
54
|
// to make sure a future refactor doesn't drop it.
|
|
55
55
|
const setupBlockStart = GATEWAY_SRC.indexOf('if (!didOneTimeSetup)')
|
|
56
|
-
const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart +
|
|
56
|
+
const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 12000)
|
|
57
57
|
expect(setupWindow).toMatch(/try\s*\{\s*removeTurnActiveMarker\(STATE_DIR\)\s*\}\s*catch/)
|
|
58
58
|
})
|
|
59
59
|
|
|
@@ -62,7 +62,7 @@ describe('gateway boot — clear stale turn-active marker', () => {
|
|
|
62
62
|
// Clearing the marker first means the watchdog sees a clean slate
|
|
63
63
|
// immediately, even if pin sweep takes 30s+ to finish.
|
|
64
64
|
const setupBlockStart = GATEWAY_SRC.indexOf('if (!didOneTimeSetup)')
|
|
65
|
-
const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart +
|
|
65
|
+
const setupWindow = GATEWAY_SRC.slice(setupBlockStart, setupBlockStart + 12000)
|
|
66
66
|
const clearIdx = setupWindow.indexOf('removeTurnActiveMarker(STATE_DIR)')
|
|
67
67
|
const pinSweepIdx = setupWindow.indexOf('Boot-time pin sweep')
|
|
68
68
|
expect(clearIdx).toBeGreaterThan(0)
|
|
@@ -36,6 +36,7 @@ function makeDeps(agentName: string | null) {
|
|
|
36
36
|
const finalizeB = vi.fn(async () => {})
|
|
37
37
|
const clearActiveReactions = vi.fn()
|
|
38
38
|
const disposeProgressDriver = vi.fn()
|
|
39
|
+
const stopTurnTypingLoops = vi.fn()
|
|
39
40
|
const log = vi.fn()
|
|
40
41
|
|
|
41
42
|
const activeStatusReactions = new Map<string, FakeCtrl>([
|
|
@@ -72,7 +73,7 @@ function makeDeps(agentName: string | null) {
|
|
|
72
73
|
// carry or clear it.
|
|
73
74
|
|
|
74
75
|
return {
|
|
75
|
-
spies: { setDoneA, setDoneB, finalizeA, finalizeB, clearActiveReactions, disposeProgressDriver, log },
|
|
76
|
+
spies: { setDoneA, setDoneB, finalizeA, finalizeB, clearActiveReactions, disposeProgressDriver, stopTurnTypingLoops, log },
|
|
76
77
|
deps: {
|
|
77
78
|
agentName,
|
|
78
79
|
activeStatusReactions,
|
|
@@ -83,6 +84,7 @@ function makeDeps(agentName: string | null) {
|
|
|
83
84
|
activeDraftStreams,
|
|
84
85
|
clearActiveReactions,
|
|
85
86
|
disposeProgressDriver,
|
|
87
|
+
stopTurnTypingLoops,
|
|
86
88
|
log,
|
|
87
89
|
},
|
|
88
90
|
}
|
|
@@ -121,6 +123,16 @@ describe('flushOnAgentDisconnect — anonymous clients (the regression scenario)
|
|
|
121
123
|
expect(spies.setDoneA).toHaveBeenCalledTimes(0)
|
|
122
124
|
expect(spies.setDoneB).toHaveBeenCalledTimes(0)
|
|
123
125
|
})
|
|
126
|
+
|
|
127
|
+
it('#2650: does NOT sweep the turn-typing loops for anonymous disconnects', () => {
|
|
128
|
+
// An anonymous one-shot IPC client (recall.py, etc.) never owned a
|
|
129
|
+
// turn-typing loop. The sweep MUST be gated by the agentName-null
|
|
130
|
+
// early-return so a constant stream of anonymous disconnects can't
|
|
131
|
+
// kill a live agent's typing indicator.
|
|
132
|
+
const { spies, deps } = makeDeps(null)
|
|
133
|
+
flushOnAgentDisconnect(deps)
|
|
134
|
+
expect(spies.stopTurnTypingLoops).not.toHaveBeenCalled()
|
|
135
|
+
})
|
|
124
136
|
})
|
|
125
137
|
|
|
126
138
|
describe('flushOnAgentDisconnect — registered agent disconnects (existing behavior preserved)', () => {
|
|
@@ -144,6 +156,15 @@ describe('flushOnAgentDisconnect — registered agent disconnects (existing beha
|
|
|
144
156
|
expect(spies.clearActiveReactions).toHaveBeenCalledTimes(1)
|
|
145
157
|
})
|
|
146
158
|
|
|
159
|
+
it('#2650: sweeps the turn-typing loops on a real agent disconnect', () => {
|
|
160
|
+
// The bridge died mid-turn — the canonical turn-end that would stop the
|
|
161
|
+
// per-(chat,thread) `typing…` interval will never arrive, so the flush
|
|
162
|
+
// must sweep every live loop or a stale "typing…" shows until next turn.
|
|
163
|
+
const { spies, deps } = makeDeps('clerk')
|
|
164
|
+
flushOnAgentDisconnect(deps)
|
|
165
|
+
expect(spies.stopTurnTypingLoops).toHaveBeenCalledTimes(1)
|
|
166
|
+
})
|
|
167
|
+
|
|
147
168
|
it('finalizes only non-final draft streams and clears the maps', () => {
|
|
148
169
|
const { spies, deps } = makeDeps('clerk')
|
|
149
170
|
flushOnAgentDisconnect(deps)
|
|
@@ -172,6 +193,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
172
193
|
const onDanglingTurnsSwept = vi.fn()
|
|
173
194
|
const clearActiveReactions = vi.fn()
|
|
174
195
|
const disposeProgressDriver = vi.fn()
|
|
196
|
+
const stopTurnTypingLoops = vi.fn()
|
|
175
197
|
const log = vi.fn()
|
|
176
198
|
const deps = {
|
|
177
199
|
agentName: 'clerk',
|
|
@@ -185,6 +207,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
185
207
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
186
208
|
clearActiveReactions,
|
|
187
209
|
disposeProgressDriver,
|
|
210
|
+
stopTurnTypingLoops,
|
|
188
211
|
onDanglingTurnsSwept,
|
|
189
212
|
log,
|
|
190
213
|
}
|
|
@@ -243,6 +266,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
243
266
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
244
267
|
clearActiveReactions: vi.fn(),
|
|
245
268
|
disposeProgressDriver: vi.fn(),
|
|
269
|
+
stopTurnTypingLoops: vi.fn(),
|
|
246
270
|
onDanglingTurnsSwept,
|
|
247
271
|
log: vi.fn(),
|
|
248
272
|
}
|
|
@@ -278,6 +302,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
278
302
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
279
303
|
clearActiveReactions: vi.fn(),
|
|
280
304
|
disposeProgressDriver: vi.fn(),
|
|
305
|
+
stopTurnTypingLoops: vi.fn(),
|
|
281
306
|
onDanglingTurnsSwept,
|
|
282
307
|
log,
|
|
283
308
|
}
|
|
@@ -310,6 +335,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
310
335
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
311
336
|
clearActiveReactions: vi.fn(),
|
|
312
337
|
disposeProgressDriver: vi.fn(),
|
|
338
|
+
stopTurnTypingLoops: vi.fn(),
|
|
313
339
|
}
|
|
314
340
|
// Singular form.
|
|
315
341
|
flushOnAgentDisconnect({
|
|
@@ -348,6 +374,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
348
374
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
349
375
|
clearActiveReactions: vi.fn(),
|
|
350
376
|
disposeProgressDriver: vi.fn(),
|
|
377
|
+
stopTurnTypingLoops: vi.fn(),
|
|
351
378
|
log,
|
|
352
379
|
})
|
|
353
380
|
expect(log.mock.calls.some((c: unknown[]) =>
|
|
@@ -368,6 +395,7 @@ describe('flushOnAgentDisconnect — dangling-turn sweep (2026-05-23 wedge fix)'
|
|
|
368
395
|
activeDraftStreams: new Map<string, FakeStream>(),
|
|
369
396
|
clearActiveReactions: vi.fn(),
|
|
370
397
|
disposeProgressDriver: vi.fn(),
|
|
398
|
+
stopTurnTypingLoops: vi.fn(),
|
|
371
399
|
// onDanglingTurnsSwept intentionally omitted.
|
|
372
400
|
log: vi.fn(),
|
|
373
401
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Structural integration test for the loopback-paste fail-safe redaction in
|
|
6
|
+
* the gateway's inbound handler (security audit #3084, F2/F3).
|
|
7
|
+
*
|
|
8
|
+
* Why structural: gateway/gateway.ts does not export its inbound handler, so a
|
|
9
|
+
* pure-functional invocation would require end-to-end harnessing of
|
|
10
|
+
* Bot/Grammy/Context. The decision predicate (shouldConsumeLoopbackPaste, incl.
|
|
11
|
+
* the malformed-paste fail-safe) is exhaustively unit-tested in
|
|
12
|
+
* `auth-loopback-relay.test.ts`, and the redaction primitive
|
|
13
|
+
* (redactAuthCodeMessage) in `auth-code-redact.test.ts`. What's left to assert
|
|
14
|
+
* here is that the gateway actually WIRES a fail-safe so a code-bearing paste
|
|
15
|
+
* that is malformed, or arrives just after the intercept TTL, or has no active
|
|
16
|
+
* flow, is redacted + dropped rather than forwarded to the agent.
|
|
17
|
+
*
|
|
18
|
+
* The invariants:
|
|
19
|
+
* 1. The stale (TTL-expired) loopback branch does NOT let the paste fall
|
|
20
|
+
* through to the agent — it flows into the fail-safe below.
|
|
21
|
+
* 2. A fail-safe `if (shouldConsumeLoopbackPaste(text))` block exists that
|
|
22
|
+
* redacts (redactAuthCodeMessage) and returns, sitting AFTER the
|
|
23
|
+
* pending-flow branch but BEFORE the generic auth-code / agent-forward
|
|
24
|
+
* handlers.
|
|
25
|
+
*/
|
|
26
|
+
describe('gateway loopback-paste fail-safe redaction — structural wiring', () => {
|
|
27
|
+
const src = readFileSync(
|
|
28
|
+
new URL('../gateway/gateway.ts', import.meta.url),
|
|
29
|
+
'utf8',
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
it('imports the consume predicate + redaction helper', () => {
|
|
33
|
+
expect(src).toMatch(/shouldConsumeLoopbackPaste/)
|
|
34
|
+
expect(src).toMatch(/redactAuthCodeMessage/)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('has a fail-safe redact-and-return block guarded by shouldConsumeLoopbackPaste', () => {
|
|
38
|
+
// Isolate the fail-safe block: `if (shouldConsumeLoopbackPaste(text)) { ... }`
|
|
39
|
+
// that is NOT part of the `pendingLoop && ...` pending-flow branch.
|
|
40
|
+
const failSafe = src.match(
|
|
41
|
+
/if \(shouldConsumeLoopbackPaste\(text\)\) \{[\s\S]{0,600}?\n {2}\}/g,
|
|
42
|
+
)
|
|
43
|
+
expect(failSafe).not.toBeNull()
|
|
44
|
+
// The standalone fail-safe (the one NOT preceded by `pendingLoop &&`).
|
|
45
|
+
const standalone = (failSafe ?? []).find(
|
|
46
|
+
(b) => !b.startsWith('if (pendingLoop'),
|
|
47
|
+
)
|
|
48
|
+
expect(standalone).toBeDefined()
|
|
49
|
+
expect(standalone!).toMatch(/redactAuthCodeMessage/)
|
|
50
|
+
expect(standalone!).toMatch(/return/)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('the stale loopback branch falls through to the fail-safe (does not forward)', () => {
|
|
54
|
+
// The stale-path comment must reference the fail-safe, and the fail-safe
|
|
55
|
+
// block must appear AFTER the pending-loop branch and BEFORE the generic
|
|
56
|
+
// auth-code intercept (`pendingReauthFlows`).
|
|
57
|
+
const failSafeIdx = src.indexOf(
|
|
58
|
+
'Fail-safe redaction (security audit #3084, F2/F3)',
|
|
59
|
+
)
|
|
60
|
+
const pendingLoopIdx = src.indexOf('const pendingLoop = pendingLoopbackFlows.get')
|
|
61
|
+
const reauthIdx = src.indexOf('const pendingReauth = pendingReauthFlows.get')
|
|
62
|
+
expect(failSafeIdx).toBeGreaterThan(-1)
|
|
63
|
+
expect(failSafeIdx).toBeGreaterThan(pendingLoopIdx)
|
|
64
|
+
expect(reauthIdx).toBeGreaterThan(failSafeIdx)
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -77,6 +77,63 @@ describe('gateway outbound secret-scrub — structural wiring', () => {
|
|
|
77
77
|
expect(truncIdx).toBeGreaterThan(redactIdx) // mask BEFORE the slice
|
|
78
78
|
})
|
|
79
79
|
|
|
80
|
+
it('ask_user: redacts question + option labels at entry, BEFORE the send', () => {
|
|
81
|
+
// F1 — ask_user sent the question + button labels unredacted. The scrub
|
|
82
|
+
// must run at the top of executeAskUser (via redactAskUserFields), before
|
|
83
|
+
// the keyboard is built and the question is sent to Telegram.
|
|
84
|
+
const start = src.indexOf('async function executeAskUser(')
|
|
85
|
+
const redactIdx = src.indexOf('redactAskUserFields(args.question, args.options', start)
|
|
86
|
+
const keyboardIdx = src.indexOf('const keyboard = new InlineKeyboard()', start)
|
|
87
|
+
const sendIdx = src.indexOf('sendRichMessage(args.chatId, richMessage(args.question)', start)
|
|
88
|
+
expect(start).toBeGreaterThan(0)
|
|
89
|
+
expect(redactIdx).toBeGreaterThan(start)
|
|
90
|
+
expect(keyboardIdx).toBeGreaterThan(redactIdx) // mask BEFORE labels become buttons
|
|
91
|
+
expect(sendIdx).toBeGreaterThan(redactIdx) // mask BEFORE the question is sent
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('send_checklist: redacts title + task text BEFORE rawSendChecklist', () => {
|
|
95
|
+
// F2 — checklist title + task strings were sent unredacted.
|
|
96
|
+
const start = src.indexOf('async function executeSendChecklist(')
|
|
97
|
+
const redactIdx = src.indexOf('redactChecklistFields(', start)
|
|
98
|
+
const sendIdx = src.indexOf('rawSendChecklist({', start)
|
|
99
|
+
expect(start).toBeGreaterThan(0)
|
|
100
|
+
expect(redactIdx).toBeGreaterThan(start)
|
|
101
|
+
expect(sendIdx).toBeGreaterThan(redactIdx) // mask BEFORE the send
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('update_checklist: redacts title + task text BEFORE rawEditMessageChecklist', () => {
|
|
105
|
+
// F2 sibling — update_checklist shares the identical leak class.
|
|
106
|
+
const start = src.indexOf('async function executeUpdateChecklist(')
|
|
107
|
+
const redactIdx = src.indexOf('redactChecklistFields(', start)
|
|
108
|
+
const editIdx = src.indexOf('rawEditMessageChecklist({', start)
|
|
109
|
+
expect(start).toBeGreaterThan(0)
|
|
110
|
+
expect(redactIdx).toBeGreaterThan(start)
|
|
111
|
+
expect(editIdx).toBeGreaterThan(redactIdx) // mask BEFORE the edit
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('pty draft-preview: redacts at the gateway boundary BEFORE the stream', () => {
|
|
115
|
+
// F3 — the PTY-tail partial (assistant reply text extracted from the TUI)
|
|
116
|
+
// forwards straight to the draft-preview stream. Mask at the top of
|
|
117
|
+
// handlePtyPartial, before it hands off to handlePtyPartialPure.
|
|
118
|
+
const start = src.indexOf('function handlePtyPartial(text: string): void {')
|
|
119
|
+
const redactIdx = src.indexOf(`redactOutboundText(text, 'pty_preview')`, start)
|
|
120
|
+
const pureIdx = src.indexOf('handlePtyPartialPure(text, state', start)
|
|
121
|
+
expect(start).toBeGreaterThan(0)
|
|
122
|
+
expect(redactIdx).toBeGreaterThan(start)
|
|
123
|
+
expect(pureIdx).toBeGreaterThan(redactIdx) // mask BEFORE the stream push
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('pty activity: redacts at the gateway boundary BEFORE the stream', () => {
|
|
127
|
+
// F3 — the (currently-unwired) PTY-activity lane is masked too so the fix
|
|
128
|
+
// is durable if it is ever re-armed.
|
|
129
|
+
const start = src.indexOf('function handlePtyActivity(text: string): void {')
|
|
130
|
+
const redactIdx = src.indexOf(`redactOutboundText(text, 'pty_activity')`, start)
|
|
131
|
+
const streamIdx = src.indexOf('handleStreamReply(', start)
|
|
132
|
+
expect(start).toBeGreaterThan(0)
|
|
133
|
+
expect(redactIdx).toBeGreaterThan(start)
|
|
134
|
+
expect(streamIdx).toBeGreaterThan(redactIdx) // mask BEFORE the stream
|
|
135
|
+
})
|
|
136
|
+
|
|
80
137
|
it('does not log the secret value when a mask fires', () => {
|
|
81
138
|
const idx = src.indexOf('function redactOutboundText(')
|
|
82
139
|
const body = src.slice(idx, idx + 400)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pin the gateway trace verbosity gate (#3025): zero-signal per-poll
|
|
3
|
+
* heartbeats are suppressed by default and the full firehose is restored
|
|
4
|
+
* only when SWITCHROOM_GW_TRACE is truthy. Error lines and real
|
|
5
|
+
* (non-poll / non-idle-tick) activity ALWAYS emit regardless of the flag.
|
|
6
|
+
*
|
|
7
|
+
* DUAL-RUNNER NOTE (vitest + bun): this file runs under BOTH runners, and
|
|
8
|
+
* bun's vitest shim does not honor `vi.resetModules()` — a re-import
|
|
9
|
+
* returns the originally-cached module, so flag variants CANNOT be tested
|
|
10
|
+
* via env + fresh import. Instead the gate exposes pure paths: the env
|
|
11
|
+
* parse is `computeGwTraceVerbose(flag)` and both should-emit functions
|
|
12
|
+
* take an explicit `verbose` argument (defaulting to the module-init
|
|
13
|
+
* read). Tests pass verbosity explicitly.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describe, it, expect } from 'vitest'
|
|
17
|
+
import {
|
|
18
|
+
computeGwTraceVerbose,
|
|
19
|
+
shouldEmitTgPost,
|
|
20
|
+
shouldEmitShadowTrace,
|
|
21
|
+
} from '../shared/gw-trace-gate.js'
|
|
22
|
+
|
|
23
|
+
describe('computeGwTraceVerbose — env flag parsing', () => {
|
|
24
|
+
it('is OFF when unset', () => {
|
|
25
|
+
expect(computeGwTraceVerbose(undefined)).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('opts IN on the truthy set, case-insensitively (1/true/yes/on)', () => {
|
|
29
|
+
for (const v of ['1', 'true', 'TRUE', 'True', 'yes', 'Yes', 'YES', 'on', 'ON', 'On', ' 1', 'true ']) {
|
|
30
|
+
expect(computeGwTraceVerbose(v)).toBe(true)
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('stays OFF for falsey / unrecognized values', () => {
|
|
35
|
+
for (const v of ['', '0', 'false', 'FALSE', 'off', 'No', 'x', 'enabled']) {
|
|
36
|
+
expect(computeGwTraceVerbose(v)).toBe(false)
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('shouldEmitTgPost — default (verbose=false): suppress poll heartbeats', () => {
|
|
42
|
+
it('suppresses status=ok for getUpdates and getMe', () => {
|
|
43
|
+
expect(shouldEmitTgPost('getUpdates', 'ok', false)).toBe(false)
|
|
44
|
+
expect(shouldEmitTgPost('getMe', 'ok', false)).toBe(false)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('ALWAYS emits error lines, even for the poll methods', () => {
|
|
48
|
+
expect(shouldEmitTgPost('getUpdates', 'err', false)).toBe(true)
|
|
49
|
+
expect(shouldEmitTgPost('getMe', 'err', false)).toBe(true)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('keeps status=ok for real outbound methods', () => {
|
|
53
|
+
for (const m of ['sendMessage', 'editMessageText', 'sendChatAction', 'answerCallbackQuery']) {
|
|
54
|
+
expect(shouldEmitTgPost(m, 'ok', false)).toBe(true)
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe('shouldEmitShadowTrace — default (verbose=false): suppress idle no-op ticks', () => {
|
|
60
|
+
it('suppresses a tick with no effects in an idle global state', () => {
|
|
61
|
+
expect(shouldEmitShadowTrace('tick', 0, 'bridge_alive_idle', false)).toBe(false)
|
|
62
|
+
expect(shouldEmitShadowTrace('tick', 0, 'bridge_dead', false)).toBe(false)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('emits a tick that produced effects (e.g. a TTL turn expiry)', () => {
|
|
66
|
+
expect(shouldEmitShadowTrace('tick', 1, 'bridge_alive_idle', false)).toBe(true)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('emits a tick while a turn is in flight', () => {
|
|
70
|
+
expect(shouldEmitShadowTrace('tick', 0, 'bridge_alive_in_turn', false)).toBe(true)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('ALWAYS emits non-tick events (real inbound/turn/bridge activity)', () => {
|
|
74
|
+
for (const ev of ['inbound', 'turnStart', 'turnEnd', 'bridgeUp', 'bridgeDown', 'permVerdict']) {
|
|
75
|
+
expect(shouldEmitShadowTrace(ev, 0, 'bridge_alive_idle', false)).toBe(true)
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('verbose=true — full firehose restored', () => {
|
|
81
|
+
it('emits every line, including the two suppressed-by-default families', () => {
|
|
82
|
+
expect(shouldEmitTgPost('getUpdates', 'ok', true)).toBe(true)
|
|
83
|
+
expect(shouldEmitTgPost('getMe', 'ok', true)).toBe(true)
|
|
84
|
+
expect(shouldEmitShadowTrace('tick', 0, 'bridge_alive_idle', true)).toBe(true)
|
|
85
|
+
expect(shouldEmitShadowTrace('tick', 0, 'bridge_dead', true)).toBe(true)
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
describe('default verbose argument wiring', () => {
|
|
90
|
+
it('the defaulted call form works and matches the module-init env read', () => {
|
|
91
|
+
// Env-agnostic assertion: the defaulted form must agree with the
|
|
92
|
+
// explicit form using the parsed CURRENT env — proving the default
|
|
93
|
+
// parameter is wired to the module-init flag, whatever its value in
|
|
94
|
+
// the test process.
|
|
95
|
+
const envVerbose = computeGwTraceVerbose(process.env.SWITCHROOM_GW_TRACE)
|
|
96
|
+
expect(shouldEmitTgPost('getUpdates', 'ok')).toBe(
|
|
97
|
+
shouldEmitTgPost('getUpdates', 'ok', envVerbose),
|
|
98
|
+
)
|
|
99
|
+
expect(shouldEmitShadowTrace('tick', 0, 'bridge_alive_idle')).toBe(
|
|
100
|
+
shouldEmitShadowTrace('tick', 0, 'bridge_alive_idle', envVerbose),
|
|
101
|
+
)
|
|
102
|
+
// Regardless of the flag, an error line always emits via the default form.
|
|
103
|
+
expect(shouldEmitTgPost('getUpdates', 'err')).toBe(true)
|
|
104
|
+
})
|
|
105
|
+
})
|