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,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3084 follow-up — THE OUTCOME TEST. The leash holds.
|
|
3
|
+
*
|
|
4
|
+
* On 2026-07-11 Telegram flood-banned the `overlord` bot token for 4.6 hours. A
|
|
5
|
+
* Claude Code permission prompt fired during the ban. The gateway raised the
|
|
6
|
+
* card, the send failed, the error was discarded — and the operator never saw
|
|
7
|
+
* it. Sixty minutes later (PERMISSION_TTL_DEFAULT_MS = 60 * 60_000) the TTL
|
|
8
|
+
* sweep would have AUTO-DENIED an approval no human had ever been shown, told
|
|
9
|
+
* the agent the operator declined, and skipped the #2862 missed-approval
|
|
10
|
+
* re-offer (which is anchored on `cards[0]` — empty, because nothing landed).
|
|
11
|
+
*
|
|
12
|
+
* We escaped it only because a human answered in tmux at ~50 minutes.
|
|
13
|
+
*
|
|
14
|
+
* An undeliverable approval must never become a verdict. Ken's call, explicit:
|
|
15
|
+
* HOLD, and make the block visible. Never auto-approve. Never auto-deny — not
|
|
16
|
+
* even on a deadline. `reference/invariants.md` § no-self-escalation, § on-leash.
|
|
17
|
+
*
|
|
18
|
+
* The scenario below is the incident: a 4h flood window, one permission
|
|
19
|
+
* request, and a clock advanced past the TTL.
|
|
20
|
+
*
|
|
21
|
+
* gateway.ts is not unit-importable, so this drives `approval-hold-harness.ts`,
|
|
22
|
+
* which composes the REAL retry policy, the REAL on-disk flood window, the REAL
|
|
23
|
+
* hold decisions and the REAL TTL. Only the Telegram transport and the IPC
|
|
24
|
+
* verdict dispatch are spies — and the verdict spy is the whole point: it is
|
|
25
|
+
* what proves no `deny` was ever fabricated.
|
|
26
|
+
*
|
|
27
|
+
* No fake timers — bun-compatible (see #3097).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { describe, it, expect, afterEach } from 'vitest'
|
|
31
|
+
import { rmSync, statSync, readFileSync } from 'node:fs'
|
|
32
|
+
import { createHarness } from './approval-hold-harness.js'
|
|
33
|
+
import { PERMISSION_TTL_DEFAULT_MS } from '../gateway/permission-timeout.js'
|
|
34
|
+
import { shouldExpirePermission } from '../gateway/approval-hold.js'
|
|
35
|
+
|
|
36
|
+
const MINUTE = 60_000
|
|
37
|
+
const FOUR_HOURS = 4 * 60 * 60_000
|
|
38
|
+
|
|
39
|
+
const dirs: string[] = []
|
|
40
|
+
afterEach(() => {
|
|
41
|
+
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true })
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
function harness(opts: { targets?: string[] } = {}) {
|
|
45
|
+
const h = createHarness(opts)
|
|
46
|
+
dirs.push(h.stateDir)
|
|
47
|
+
return h
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The incident: a 4h ban is open, and the agent asks to run a command. */
|
|
51
|
+
async function incident(opts: { targets?: string[] } = {}) {
|
|
52
|
+
const h = harness(opts)
|
|
53
|
+
h.banFor(FOUR_HOURS)
|
|
54
|
+
await h.raise('req-overlord-1', 'Bash', '{"command":"rm -rf ./build"}')
|
|
55
|
+
await h.settle()
|
|
56
|
+
return h
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('an undeliverable approval is HELD, never denied', () => {
|
|
60
|
+
it('(a) no card is sent while the flood window is open', async () => {
|
|
61
|
+
const h = await incident()
|
|
62
|
+
|
|
63
|
+
expect(h.sends.length).toBe(0)
|
|
64
|
+
expect(h.floodRemainingMs()).toBeGreaterThan(0)
|
|
65
|
+
// The agent is still blocked — which is correct. It asked; nobody answered.
|
|
66
|
+
expect(h.pending.has('req-overlord-1')).toBe(true)
|
|
67
|
+
expect(h.verdicts).toEqual([])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('(b) the blocked record is on disk immediately — not on a 60s sweep', async () => {
|
|
71
|
+
const h = await incident()
|
|
72
|
+
|
|
73
|
+
// Written synchronously in the failure handler. The operator is locked out
|
|
74
|
+
// of Telegram; this record is the ONLY way they learn an agent is blocked,
|
|
75
|
+
// so it must be live in under a second, not up to a minute.
|
|
76
|
+
const rec = h.blockedStore.read()
|
|
77
|
+
expect(rec).not.toBeNull()
|
|
78
|
+
expect(rec!.agent).toBe('overlord')
|
|
79
|
+
expect(rec!.requestId).toBe('req-overlord-1')
|
|
80
|
+
expect(rec!.toolName).toBe('Bash')
|
|
81
|
+
expect(rec!.reason).toBe('flood_wait')
|
|
82
|
+
expect(rec!.action).toBeTruthy()
|
|
83
|
+
expect(rec!.blockedSince).toBeGreaterThan(0)
|
|
84
|
+
expect(rec!.undeliverableSince).toBeGreaterThan(0)
|
|
85
|
+
// The window end, so the surface can say WHEN it comes back.
|
|
86
|
+
expect(rec!.retryableAt - rec!.undeliverableSince).toBeGreaterThan(3 * 60 * 60_000)
|
|
87
|
+
|
|
88
|
+
// Readable by a non-owner uid (switchroom-web runs as uid 1000 and cannot
|
|
89
|
+
// read the agent's 0600 state files — a 0600 record = an empty dashboard).
|
|
90
|
+
expect(statSync(h.blockedStore.path).mode & 0o004).toBe(0o004)
|
|
91
|
+
|
|
92
|
+
// …and it carries no raw tool input. The file is world-readable; the tool
|
|
93
|
+
// input here is `rm -rf ./build`, and in the general case it is an
|
|
94
|
+
// arbitrary command that may contain a credential.
|
|
95
|
+
const raw = readFileSync(h.blockedStore.path, 'utf-8')
|
|
96
|
+
expect(raw).not.toContain('rm -rf')
|
|
97
|
+
expect(raw).not.toContain('inputPreview')
|
|
98
|
+
expect(raw).not.toContain('input_preview')
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
102
|
+
// (c) THE LEASH. This is the assertion the incident owes.
|
|
103
|
+
// ─────────────────────────────────────────────────────────────────────────
|
|
104
|
+
it('(c) at T+61min — PAST the 60-minute TTL — NO deny verdict is ever dispatched', async () => {
|
|
105
|
+
const h = await incident()
|
|
106
|
+
expect(PERMISSION_TTL_DEFAULT_MS).toBe(60 * 60_000) // the TTL we must outlive
|
|
107
|
+
|
|
108
|
+
// Tick the reaper across the whole TTL and out the other side. On
|
|
109
|
+
// origin/main the sweep fires at exactly this point and auto-denies.
|
|
110
|
+
for (let elapsed = 0; elapsed <= 61 * MINUTE; elapsed += MINUTE) {
|
|
111
|
+
await h.tickSettled()
|
|
112
|
+
h.clock.advance(MINUTE)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The agent asked. No human ever saw the ask. So no human ever answered —
|
|
116
|
+
// and the gateway must NOT answer on their behalf.
|
|
117
|
+
expect(h.verdicts).toEqual([])
|
|
118
|
+
expect(h.verdicts.some(v => v.behavior === 'deny')).toBe(false)
|
|
119
|
+
|
|
120
|
+
// The request is still pending: the agent stays blocked. An indefinite
|
|
121
|
+
// block is BY DESIGN — /pending and tmux remain the escape hatches.
|
|
122
|
+
expect(h.pending.has('req-overlord-1')).toBe(true)
|
|
123
|
+
expect(h.pending.get('req-overlord-1')?.undeliverable?.reason).toBe('flood_wait')
|
|
124
|
+
|
|
125
|
+
// And the operator can still see the block from outside Telegram.
|
|
126
|
+
expect(h.blockedStore.read()?.requestId).toBe('req-overlord-1')
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('(c-proof) the entry IS past its TTL — only the leash saves it', () => {
|
|
130
|
+
// Documents origin/main's behaviour WITHOUT re-implementing it. The pending
|
|
131
|
+
// entry is genuinely past the 60-minute TTL; the ONLY reason it survives is the
|
|
132
|
+
// held check inside `shouldExpirePermission`. Delete that check and (c) above
|
|
133
|
+
// goes RED — there is exactly one implementation, and both the gateway sweep
|
|
134
|
+
// and this harness call it.
|
|
135
|
+
const held = {
|
|
136
|
+
startedAt: 0,
|
|
137
|
+
undeliverable: { since: 0, retryableAt: 9e15, reason: 'flood_wait' as const },
|
|
138
|
+
}
|
|
139
|
+
const seen = { startedAt: 0 }
|
|
140
|
+
const past = PERMISSION_TTL_DEFAULT_MS + 1
|
|
141
|
+
|
|
142
|
+
// A card the operator COULD see, left unanswered past the TTL → expires. That is
|
|
143
|
+
// the normal, correct timeout, and it is untouched.
|
|
144
|
+
expect(shouldExpirePermission(seen, past, PERMISSION_TTL_DEFAULT_MS)).toBe(true)
|
|
145
|
+
// The SAME clock, on a card that never landed → never expires.
|
|
146
|
+
expect(shouldExpirePermission(held, past, PERMISSION_TTL_DEFAULT_MS)).toBe(false)
|
|
147
|
+
expect(shouldExpirePermission(held, 9e15, PERMISSION_TTL_DEFAULT_MS)).toBe(false)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('(d) past the ban, the card lands EXACTLY once — no loss, no duplicate', async () => {
|
|
151
|
+
const h = await incident()
|
|
152
|
+
|
|
153
|
+
// Ride out the full 4h ban, ticking every minute as the reaper does.
|
|
154
|
+
for (let elapsed = 0; elapsed < FOUR_HOURS; elapsed += MINUTE) {
|
|
155
|
+
await h.tickSettled()
|
|
156
|
+
h.clock.advance(MINUTE)
|
|
157
|
+
}
|
|
158
|
+
expect(h.sends.length).toBe(0) // nothing escaped during the ban
|
|
159
|
+
|
|
160
|
+
// Window closes.
|
|
161
|
+
h.clock.advance(2 * MINUTE)
|
|
162
|
+
await h.tickSettled()
|
|
163
|
+
|
|
164
|
+
expect(h.sends.length).toBe(1)
|
|
165
|
+
expect(h.sends[0]!.requestId).toBe('req-overlord-1')
|
|
166
|
+
|
|
167
|
+
// Keep ticking — the card must not be re-posted, and must not now expire
|
|
168
|
+
// instantly against a TTL that ran while the operator could not answer.
|
|
169
|
+
for (let i = 0; i < 10; i++) {
|
|
170
|
+
h.clock.advance(MINUTE)
|
|
171
|
+
await h.tickSettled()
|
|
172
|
+
}
|
|
173
|
+
expect(h.sends.length).toBe(1)
|
|
174
|
+
expect(h.verdicts).toEqual([])
|
|
175
|
+
expect(h.pending.has('req-overlord-1')).toBe(true)
|
|
176
|
+
|
|
177
|
+
// We never issued a request into a window we already knew was open — the
|
|
178
|
+
// #3094 amplifier guard held for the whole run.
|
|
179
|
+
expect(h.sentIntoOpenWindow).toBe(false)
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('(d2) the TTL clock RESTARTS on delivery — the operator gets a full window', async () => {
|
|
183
|
+
const h = await incident()
|
|
184
|
+
|
|
185
|
+
h.clock.advance(FOUR_HOURS + MINUTE)
|
|
186
|
+
await h.tickSettled() // re-delivers
|
|
187
|
+
expect(h.sends.length).toBe(1)
|
|
188
|
+
|
|
189
|
+
// Without the startedAt reset, the card lands already 4h old against a
|
|
190
|
+
// 60-min TTL and the very next tick auto-denies it — we would have MOVED
|
|
191
|
+
// the silent denial, not removed it.
|
|
192
|
+
h.clock.advance(59 * MINUTE)
|
|
193
|
+
await h.tickSettled()
|
|
194
|
+
expect(h.verdicts).toEqual([])
|
|
195
|
+
expect(h.pending.has('req-overlord-1')).toBe(true)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('(e) a tap on the re-delivered card resolves the ORIGINAL requestId', async () => {
|
|
199
|
+
const h = await incident()
|
|
200
|
+
|
|
201
|
+
h.clock.advance(FOUR_HOURS + MINUTE)
|
|
202
|
+
await h.tickSettled()
|
|
203
|
+
expect(h.sends.length).toBe(1)
|
|
204
|
+
|
|
205
|
+
// The operator finally sees the card and taps Allow.
|
|
206
|
+
h.tap('req-overlord-1', 'allow')
|
|
207
|
+
|
|
208
|
+
// The verdict carries the ORIGINAL request id — the id the suspended claude
|
|
209
|
+
// turn is blocked on. A new id would dispatch into the void and the agent
|
|
210
|
+
// would stay wedged forever.
|
|
211
|
+
expect(h.verdicts).toEqual([{ requestId: 'req-overlord-1', behavior: 'allow' }])
|
|
212
|
+
expect(h.pending.has('req-overlord-1')).toBe(false)
|
|
213
|
+
// The block is over — the surface goes quiet.
|
|
214
|
+
expect(h.blockedStore.read()).toBeNull()
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
// F6 — the leash hole review found. Only LONG flood-waits (>120s) raise
|
|
218
|
+
// FLOOD_WAIT_ACTIVE. A short-but-persistent 429, a network partition, a Telegram
|
|
219
|
+
// 5xx, or a full disk all produce a plain GIVE-UP — and every one of them was
|
|
220
|
+
// still auto-denying a card the operator never saw. Same breach as the incident,
|
|
221
|
+
// different first cause. The rule is about whether the operator got to answer,
|
|
222
|
+
// not about which fault shut the channel.
|
|
223
|
+
it('(c2) a NETWORK give-up is held too — the leash is not flood-specific', async () => {
|
|
224
|
+
const h = harness()
|
|
225
|
+
dirs.push(h.stateDir)
|
|
226
|
+
h.breakTarget('-100200', 'econnreset') // network partition, not a flood ban
|
|
227
|
+
await h.raise('req-net', 'Bash', '{"command":"rm -rf ./build"}')
|
|
228
|
+
await h.settle()
|
|
229
|
+
|
|
230
|
+
expect(h.sends.length).toBe(0)
|
|
231
|
+
expect(h.pending.get('req-net')?.undeliverable?.reason).toBe('transient')
|
|
232
|
+
expect(h.blockedStore.read()?.requestId).toBe('req-net')
|
|
233
|
+
|
|
234
|
+
// Past the 60-minute TTL — still no fabricated verdict.
|
|
235
|
+
for (let elapsed = 0; elapsed <= 61 * MINUTE; elapsed += MINUTE) {
|
|
236
|
+
await h.tickSettled()
|
|
237
|
+
h.clock.advance(MINUTE)
|
|
238
|
+
}
|
|
239
|
+
expect(h.verdicts).toEqual([])
|
|
240
|
+
expect(h.pending.has('req-net')).toBe(true)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('the whole run dispatches exactly ONE verdict, and it is the operator’s', async () => {
|
|
244
|
+
const h = await incident()
|
|
245
|
+
|
|
246
|
+
for (let elapsed = 0; elapsed < FOUR_HOURS + 5 * MINUTE; elapsed += MINUTE) {
|
|
247
|
+
await h.tickSettled()
|
|
248
|
+
h.clock.advance(MINUTE)
|
|
249
|
+
}
|
|
250
|
+
h.tap('req-overlord-1', 'allow')
|
|
251
|
+
|
|
252
|
+
expect(h.sends.length).toBe(1)
|
|
253
|
+
expect(h.verdicts).toEqual([{ requestId: 'req-overlord-1', behavior: 'allow' }])
|
|
254
|
+
})
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
/** Source-text pins for the three gateway changes (gateway.ts isn't importable). */
|
|
258
|
+
describe('gateway wiring — the leash', () => {
|
|
259
|
+
const GATEWAY_SRC = readFileSync(
|
|
260
|
+
new URL('../gateway/gateway.ts', import.meta.url),
|
|
261
|
+
'utf8',
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* One anchor + one span for the TTL sweep, shared by every pin below.
|
|
266
|
+
*
|
|
267
|
+
* They drifted apart before: one pin still anchored on the inline `for` loop the
|
|
268
|
+
* shared-sweep extraction had deleted, so it silently matched nothing. A single
|
|
269
|
+
* definition means a future move breaks every pin at once — loudly — instead of
|
|
270
|
+
* quietly disarming one of them.
|
|
271
|
+
*
|
|
272
|
+
* The span must cover the whole `onExpire` body (~4.2k chars today).
|
|
273
|
+
*/
|
|
274
|
+
const SWEEP_ANCHOR = 'sweepPermissionTtl({'
|
|
275
|
+
const SWEEP_SPAN = 6000
|
|
276
|
+
|
|
277
|
+
it('the TTL sweep routes through the SHARED shouldExpirePermission()', () => {
|
|
278
|
+
// This pin is no longer the safety net — the behavioural tests above are, and
|
|
279
|
+
// they now drive the same `shouldExpirePermission` the gateway does, so deleting
|
|
280
|
+
// the leash turns them RED. This only guards the WIRING: that the sweep can't
|
|
281
|
+
// regrow a private inline check that drifts from what the test exercises.
|
|
282
|
+
const sweepStart = GATEWAY_SRC.indexOf(SWEEP_ANCHOR)
|
|
283
|
+
expect(sweepStart).toBeGreaterThan(-1)
|
|
284
|
+
const sweep = GATEWAY_SRC.slice(sweepStart, sweepStart + SWEEP_SPAN)
|
|
285
|
+
expect(GATEWAY_SRC).toContain('sweepPermissionTtl({')
|
|
286
|
+
// …and must NOT carry its own copy of the leash or the raw TTL comparison.
|
|
287
|
+
// …and the gateway must not regrow a private inline leash check.
|
|
288
|
+
expect(sweep).not.toContain('if (isHeldUndeliverable(v)) continue')
|
|
289
|
+
expect(sweep).not.toContain('if (now - v.startedAt > ttl)')
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
it('successful (re)delivery resets startedAt', () => {
|
|
293
|
+
expect(GATEWAY_SRC).toContain('live.startedAt = Date.now()')
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
it('the missed-approvals re-offer no longer drops a card that never landed', () => {
|
|
297
|
+
// The cards[0] hole: `origin === undefined` meant the auto-deny ALSO erased
|
|
298
|
+
// the request from the only record that would have re-offered it.
|
|
299
|
+
expect(GATEWAY_SRC).toContain('v.cards[0] ?? resolvePermissionCardTargets()[0]')
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
it('nothing in the permission path auto-approves', () => {
|
|
303
|
+
// Never, under any circumstance. A held approval that "times out" into an
|
|
304
|
+
// ALLOW would be the worst possible reading of Ken's call — the other half of
|
|
305
|
+
// no-self-escalation.
|
|
306
|
+
//
|
|
307
|
+
// This pin was DEAD. It anchored on `for (const [k, v] of pendingPermissions) {`,
|
|
308
|
+
// the inline loop that the shared-sweep extraction deleted. `indexOf` returned
|
|
309
|
+
// -1, `slice(-1, 2499)` returned the EMPTY STRING, and `not.toContain` passes on
|
|
310
|
+
// "" — so the sweep could be changed to dispatch `behavior: 'allow'` and all 13
|
|
311
|
+
// tests stayed green. Exactly the failure class this file exists to prevent,
|
|
312
|
+
// inside the file that exists to prevent it.
|
|
313
|
+
//
|
|
314
|
+
// Two rules now, and they apply to every grep pin here:
|
|
315
|
+
// 1. ASSERT THE ANCHOR RESOLVES. A silent -1 is what turns a pin into a lie.
|
|
316
|
+
// 2. Assert something POSITIVE inside the window, so a window that is too
|
|
317
|
+
// small (or empty) fails loudly instead of vacuously passing a `not`.
|
|
318
|
+
const sweepStart = GATEWAY_SRC.indexOf(SWEEP_ANCHOR)
|
|
319
|
+
expect(sweepStart).toBeGreaterThan(-1)
|
|
320
|
+
const sweep = GATEWAY_SRC.slice(sweepStart, sweepStart + SWEEP_SPAN)
|
|
321
|
+
|
|
322
|
+
// POSITIVE: proves the window actually covers the verdict dispatch.
|
|
323
|
+
expect(sweep).toContain("behavior: 'deny',")
|
|
324
|
+
// …and the sweep may only ever deny. Never allow.
|
|
325
|
+
expect(sweep).not.toContain("behavior: 'allow'")
|
|
326
|
+
})
|
|
327
|
+
})
|