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.
Files changed (150) hide show
  1. package/dist/agent-scheduler/index.js +37 -5
  2. package/dist/auth-broker/index.js +116 -78
  3. package/dist/cli/hindsight-mental-model-pretool.mjs +39 -0
  4. package/dist/cli/ms-365-write-pretool.mjs +31 -8
  5. package/dist/cli/notion-write-pretool.mjs +38 -6
  6. package/dist/cli/skill-validate-pretool.mjs +144 -2847
  7. package/dist/cli/switchroom.js +3529 -4543
  8. package/dist/cli/ui/index.html +163 -17
  9. package/dist/host-control/main.js +605 -2847
  10. package/dist/vault/approvals/kernel-server.js +120 -13
  11. package/dist/vault/broker/server.js +259 -157
  12. package/package.json +3 -4
  13. package/profiles/_base/start.sh.hbs +65 -0
  14. package/profiles/_shared/vault-protocol.md.hbs +3 -1
  15. package/profiles/coding/CLAUDE.md.hbs +1 -1
  16. package/profiles/default/CLAUDE.md.hbs +2 -2
  17. package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
  18. package/profiles/health-coach/CLAUDE.md.hbs +1 -1
  19. package/telegram-plugin/bridge/bridge.ts +37 -0
  20. package/telegram-plugin/bridge/inbound-dedup.ts +101 -0
  21. package/telegram-plugin/dist/bridge/bridge.js +122 -4
  22. package/telegram-plugin/dist/gateway/gateway.js +4213 -3288
  23. package/telegram-plugin/dist/server.js +139 -5
  24. package/telegram-plugin/flood-circuit-breaker.ts +493 -21
  25. package/telegram-plugin/format.ts +19 -0
  26. package/telegram-plugin/gateway/approval-hold.ts +602 -0
  27. package/telegram-plugin/gateway/auth-command.ts +92 -2
  28. package/telegram-plugin/gateway/auth-loopback-relay.ts +670 -0
  29. package/telegram-plugin/gateway/boot-card.ts +12 -5
  30. package/telegram-plugin/gateway/callback-query-handlers.ts +88 -1
  31. package/telegram-plugin/gateway/config-approval-handler.ts +6 -1
  32. package/telegram-plugin/gateway/disconnect-flush.ts +19 -0
  33. package/telegram-plugin/gateway/dm-pin-sweep.test.ts +251 -0
  34. package/telegram-plugin/gateway/dm-pin-sweep.ts +178 -0
  35. package/telegram-plugin/gateway/gateway.ts +1695 -230
  36. package/telegram-plugin/gateway/hostd-dispatch.ts +23 -0
  37. package/telegram-plugin/gateway/idle-clear.ts +90 -6
  38. package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +26 -5
  39. package/telegram-plugin/gateway/inject-handler.ts +8 -0
  40. package/telegram-plugin/gateway/ipc-protocol.ts +46 -3
  41. package/telegram-plugin/gateway/ipc-server.ts +43 -0
  42. package/telegram-plugin/gateway/mental-model-propose-resolve.ts +145 -37
  43. package/telegram-plugin/gateway/model-command.ts +9 -3
  44. package/telegram-plugin/gateway/pending-session-command.ts +13 -1
  45. package/telegram-plugin/gateway/permission-ttl-sweep.ts +66 -0
  46. package/telegram-plugin/gateway/pre-approval-check.ts +74 -0
  47. package/telegram-plugin/gateway/queued-card-store.ts +217 -0
  48. package/telegram-plugin/gateway/session-model-file.ts +26 -1
  49. package/telegram-plugin/gateway/turn-end-gate-backstop.ts +59 -0
  50. package/telegram-plugin/gateway/turn-end-gate.ts +95 -0
  51. package/telegram-plugin/gateway/turn-typing-loop.ts +10 -2
  52. package/telegram-plugin/gateway/unhandled-rejection-policy.ts +13 -0
  53. package/telegram-plugin/history.ts +51 -0
  54. package/telegram-plugin/hooks/dispatch-claim-scan.mjs +259 -0
  55. package/telegram-plugin/hooks/dispatch-claim-stop.mjs +129 -0
  56. package/telegram-plugin/hooks/hooks.json +9 -0
  57. package/telegram-plugin/inline-keyboard-callbacks.ts +303 -2
  58. package/telegram-plugin/model-unavailable.ts +41 -11
  59. package/telegram-plugin/operator-events.ts +23 -0
  60. package/telegram-plugin/outbound-field-redact.ts +69 -0
  61. package/telegram-plugin/package.json +0 -1
  62. package/telegram-plugin/permission-rule.ts +1 -0
  63. package/telegram-plugin/permission-title.ts +1 -0
  64. package/telegram-plugin/render/render.ts +32 -14
  65. package/telegram-plugin/retry-api-call.ts +212 -2
  66. package/telegram-plugin/scoped-approval.ts +11 -2
  67. package/telegram-plugin/secret-detect/chunker.ts +18 -4
  68. package/telegram-plugin/secret-detect/index.ts +12 -56
  69. package/telegram-plugin/send-gate-degraded.test.ts +574 -0
  70. package/telegram-plugin/send-gate-observability.test.ts +470 -0
  71. package/telegram-plugin/send-gate-observability.ts +355 -0
  72. package/telegram-plugin/send-gate.test.ts +717 -0
  73. package/telegram-plugin/send-gate.ts +1056 -0
  74. package/telegram-plugin/session-tail.ts +82 -7
  75. package/telegram-plugin/shared/bot-runtime.ts +17 -5
  76. package/telegram-plugin/shared/gw-trace-gate.ts +105 -0
  77. package/telegram-plugin/status-pin-driver.ts +52 -7
  78. package/telegram-plugin/status-pin.ts +81 -0
  79. package/telegram-plugin/subagent-watcher.ts +173 -18
  80. package/telegram-plugin/tests/activity-card-wiring.test.ts +18 -5
  81. package/telegram-plugin/tests/approval-hold-harness.ts +425 -0
  82. package/telegram-plugin/tests/approval-hold-outcome.test.ts +327 -0
  83. package/telegram-plugin/tests/approval-hold-record.test.ts +531 -0
  84. package/telegram-plugin/tests/approval-hold-redeliver.test.ts +602 -0
  85. package/telegram-plugin/tests/auth-loopback-relay.test.ts +533 -0
  86. package/telegram-plugin/tests/boot-card-flood-suppress.test.ts +53 -7
  87. package/telegram-plugin/tests/busy-key-reaper.test.ts +1 -0
  88. package/telegram-plugin/tests/callback-query-handlers.test.ts +65 -0
  89. package/telegram-plugin/tests/dispatch-claim-scan.test.ts +250 -0
  90. package/telegram-plugin/tests/flood-breaker-blindness.test.ts +213 -0
  91. package/telegram-plugin/tests/flood-windows-persistence.test.ts +224 -0
  92. package/telegram-plugin/tests/gateway-boot-marker-clear.test.ts +3 -3
  93. package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +29 -1
  94. package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +66 -0
  95. package/telegram-plugin/tests/gateway-outbound-redact.test.ts +57 -0
  96. package/telegram-plugin/tests/gw-trace-gate.test.ts +105 -0
  97. package/telegram-plugin/tests/history.test.ts +115 -0
  98. package/telegram-plugin/tests/idle-clear.test.ts +233 -3
  99. package/telegram-plugin/tests/inbound-dedup.test.ts +93 -0
  100. package/telegram-plugin/tests/inbound-message-types.test.ts +5 -1
  101. package/telegram-plugin/tests/inline-keyboard-callbacks.test.ts +448 -0
  102. package/telegram-plugin/tests/ipc-server-check-pre-approved.test.ts +194 -0
  103. package/telegram-plugin/tests/mental-model-propose-resolve.test.ts +123 -0
  104. package/telegram-plugin/tests/missed-approvals-wiring.test.ts +1 -1
  105. package/telegram-plugin/tests/model-command.test.ts +14 -0
  106. package/telegram-plugin/tests/operator-events-session-tail.test.ts +74 -0
  107. package/telegram-plugin/tests/outbound-field-redact.test.ts +107 -0
  108. package/telegram-plugin/tests/pending-session-command.test.ts +21 -0
  109. package/telegram-plugin/tests/permission-card-routing.test.ts +30 -5
  110. package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +8 -7
  111. package/telegram-plugin/tests/permission-rearm-wiring.test.ts +1 -1
  112. package/telegram-plugin/tests/pre-approval-check.test.ts +148 -0
  113. package/telegram-plugin/tests/queued-card-store.test.ts +232 -0
  114. package/telegram-plugin/tests/reaction-flush-turn-gated.test.ts +100 -0
  115. package/telegram-plugin/tests/reaction-gate-routing.test.ts +173 -0
  116. package/telegram-plugin/tests/render/render.test.ts +88 -0
  117. package/telegram-plugin/tests/retry-api-call.test.ts +398 -0
  118. package/telegram-plugin/tests/scoped-approval.test.ts +27 -0
  119. package/telegram-plugin/tests/secret-detect-chunk-overlap.test.ts +65 -0
  120. package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +5 -4
  121. package/telegram-plugin/tests/session-model-file.test.ts +50 -0
  122. package/telegram-plugin/tests/session-tail-sidecar-reap.test.ts +268 -0
  123. package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +35 -14
  124. package/telegram-plugin/tests/status-pin.test.ts +275 -1
  125. package/telegram-plugin/tests/subagent-watcher-deferral-log-ratelimit.test.ts +316 -0
  126. package/telegram-plugin/tests/subagent-watcher-fd-leak.test.ts +275 -0
  127. package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +92 -0
  128. package/telegram-plugin/tests/turn-end-gate.test.ts +137 -0
  129. package/telegram-plugin/tests/typing-emitter.test.ts +586 -0
  130. package/telegram-plugin/tests/unhandled-rejection-policy.test.ts +20 -0
  131. package/telegram-plugin/tests/worktree-watch-cwds.test.ts +215 -1
  132. package/telegram-plugin/typing-emitter.ts +224 -0
  133. package/telegram-plugin/uat/scenarios/jtbd-feel-like-a-colleague-dm.test.ts +136 -0
  134. package/telegram-plugin/welcome-text.ts +42 -0
  135. package/telegram-plugin/worktree-watch-cwds.ts +194 -5
  136. package/vendor/hindsight-memory/scripts/drain_pending.py +22 -6
  137. package/vendor/hindsight-memory/scripts/lib/client.py +12 -5
  138. package/vendor/hindsight-memory/scripts/lib/directives.py +38 -3
  139. package/vendor/hindsight-memory/scripts/lib/pending.py +36 -9
  140. package/vendor/hindsight-memory/scripts/session_end.py +14 -3
  141. package/vendor/hindsight-memory/scripts/session_start.py +21 -0
  142. package/vendor/hindsight-memory/scripts/tests/test_directives.py +38 -0
  143. package/vendor/hindsight-memory/tests/test_drain_pending.py +68 -0
  144. package/vendor/hindsight-memory/tests/test_pending.py +44 -0
  145. package/vendor/hindsight-memory/tests/test_session_end_pending.py +38 -0
  146. package/vendor/hindsight-memory/tests/test_session_start_drain.py +155 -0
  147. package/telegram-plugin/channel-envelope-safety.test.ts +0 -56
  148. package/telegram-plugin/channel-envelope-safety.ts +0 -56
  149. package/telegram-plugin/secret-detect/secretlint-source.ts +0 -95
  150. package/telegram-plugin/tests/secret-detect-secretlint.test.ts +0 -105
@@ -138,6 +138,129 @@ describe("resolveMentalModelProposal — APPROVE", () => {
138
138
  });
139
139
  });
140
140
 
141
+ describe("resolveMentalModelProposal — #2975 Stage 1 rate-limit backstop", () => {
142
+ const NOW = 1_700_000_000_000;
143
+ const RETRY_AT = NOW + 55 * 60_000; // hostd's window-open time
144
+
145
+ /**
146
+ * Build deps whose dispatchConfigEdit yields `results` in order (one per
147
+ * attempt), plus a manual single-slot scheduler the test fires by hand — a
148
+ * mocked clock, no real timers.
149
+ */
150
+ function rateDeps(results: Array<Awaited<ReturnType<ResolveDeps["dispatchConfigEdit"]>>>) {
151
+ let call = 0;
152
+ let scheduled: { delayMs: number; fn: () => void | Promise<void> } | null = null;
153
+ const spies = {
154
+ registerPreApproval: vi.fn(),
155
+ clearPreApproval: vi.fn(),
156
+ dispatchConfigEdit: vi.fn(async () => results[Math.min(call++, results.length - 1)]!),
157
+ ensureModel: vi.fn(async () => {}),
158
+ injectInbound: vi.fn(),
159
+ scheduleRetry: vi.fn((delayMs: number, fn: () => void | Promise<void>) => {
160
+ scheduled = { delayMs, fn };
161
+ }),
162
+ editProposalCardRateWindow: vi.fn(),
163
+ notifyPersistFailed: vi.fn(),
164
+ };
165
+ const deps: ResolveDeps = {
166
+ readConfigText: () => CONFIG_NO_MODELS,
167
+ registerPreApproval: spies.registerPreApproval,
168
+ clearPreApproval: spies.clearPreApproval,
169
+ dispatchConfigEdit: spies.dispatchConfigEdit,
170
+ ensureModel: spies.ensureModel,
171
+ injectInbound: spies.injectInbound,
172
+ scheduleRetry: spies.scheduleRetry,
173
+ editProposalCardRateWindow: spies.editProposalCardRateWindow,
174
+ notifyPersistFailed: spies.notifyPersistFailed,
175
+ now: () => NOW,
176
+ };
177
+ return { deps, spies, fireScheduled: () => scheduled!.fn(), getScheduled: () => scheduled };
178
+ }
179
+
180
+ it("schedules EXACTLY ONE retry at retry_after, which persists with a byte-identical diff", async () => {
181
+ const { deps, spies, fireScheduled, getScheduled } = rateDeps([
182
+ { state: "rate_limited", reason: "config_propose_edit rate limit exceeded", retryAtMs: RETRY_AT },
183
+ { state: "applied" },
184
+ ]);
185
+
186
+ const out = await resolveMentalModelProposal("approve", pending(), "stage1", "op-42", deps);
187
+
188
+ // The turn is deferred, not lost: a single retry is scheduled at the window.
189
+ expect(out).toEqual({ outcome: "scheduled_retry", retryAtMs: RETRY_AT });
190
+ expect(spies.scheduleRetry).toHaveBeenCalledTimes(1);
191
+ expect(getScheduled()!.delayMs).toBe(RETRY_AT - NOW);
192
+ // Card flipped to the "applying at HH:MM" state so the operator sees it.
193
+ expect(spies.editProposalCardRateWindow).toHaveBeenCalledWith(RETRY_AT);
194
+ // First (rate-limited) attempt already registered+cleared its correlation.
195
+ expect(spies.dispatchConfigEdit).toHaveBeenCalledTimes(1);
196
+ const firstDiff = spies.dispatchConfigEdit.mock.calls[0][0].diff;
197
+ expect(spies.registerPreApproval).toHaveBeenCalledWith("coach", firstDiff);
198
+ // Nothing applied yet.
199
+ expect(spies.ensureModel).not.toHaveBeenCalled();
200
+ expect(spies.injectInbound).not.toHaveBeenCalled();
201
+
202
+ // Fire the ONE scheduled retry (mocked clock).
203
+ await fireScheduled();
204
+
205
+ // Persisted on the retry.
206
+ expect(spies.dispatchConfigEdit).toHaveBeenCalledTimes(2);
207
+ const retryDiff = spies.dispatchConfigEdit.mock.calls[1][0].diff;
208
+ // Forge-resistance contract: register + dispatch use IDENTICAL diff bytes,
209
+ // and the retry re-registers the SAME bytes as the first attempt.
210
+ expect(retryDiff).toBe(firstDiff);
211
+ expect(spies.registerPreApproval).toHaveBeenCalledTimes(2);
212
+ expect(spies.registerPreApproval.mock.calls[1]).toEqual(["coach", retryDiff]);
213
+ expect(spies.clearPreApproval).toHaveBeenCalledTimes(2);
214
+ // Applied: ensured + agent woken; NO loud failure.
215
+ expect(spies.ensureModel).toHaveBeenCalledWith(pending().spec);
216
+ expect(spies.injectInbound).toHaveBeenCalledTimes(1);
217
+ expect(spies.injectInbound.mock.calls[0][0].meta.source).toBe(
218
+ "mental_model_proposal_applied",
219
+ );
220
+ expect(spies.notifyPersistFailed).not.toHaveBeenCalled();
221
+ });
222
+
223
+ it("emits a loud operator failure when the single retry ALSO fails — and does NOT re-schedule", async () => {
224
+ const { deps, spies, fireScheduled } = rateDeps([
225
+ { state: "rate_limited", reason: "rate limit exceeded", retryAtMs: RETRY_AT },
226
+ { state: "error", reason: "hostd down" },
227
+ ]);
228
+
229
+ const out = await resolveMentalModelProposal("approve", pending(), "s", "op", deps);
230
+ expect(out.outcome).toBe("scheduled_retry");
231
+ expect(spies.scheduleRetry).toHaveBeenCalledTimes(1);
232
+
233
+ await fireScheduled();
234
+
235
+ // The retry failed: loud operator-events notification, exactly once.
236
+ expect(spies.notifyPersistFailed).toHaveBeenCalledTimes(1);
237
+ expect(spies.notifyPersistFailed.mock.calls[0][0]).toContain("hostd down");
238
+ // No second retry — bounded to exactly one.
239
+ expect(spies.scheduleRetry).toHaveBeenCalledTimes(1);
240
+ // Agent woken with the failed inbound; nothing ensured.
241
+ expect(spies.ensureModel).not.toHaveBeenCalled();
242
+ expect(spies.injectInbound).toHaveBeenCalledTimes(1);
243
+ expect(spies.injectInbound.mock.calls[0][0].meta.source).toBe(
244
+ "mental_model_proposal_failed",
245
+ );
246
+ });
247
+
248
+ it("with no scheduler wired, a rate-limited persist fails loudly instead of silently dropping", async () => {
249
+ const { deps, spies } = rateDeps([
250
+ { state: "rate_limited", reason: "rate limit exceeded", retryAtMs: RETRY_AT },
251
+ ]);
252
+ // Simulate a build without the Stage-1 scheduler dep.
253
+ delete (deps as { scheduleRetry?: unknown }).scheduleRetry;
254
+
255
+ const out = await resolveMentalModelProposal("approve", pending(), "s", "op", deps);
256
+ expect(out).toEqual({ outcome: "failed", reason: "rate limit exceeded" });
257
+ expect(spies.notifyPersistFailed).toHaveBeenCalledTimes(1);
258
+ expect(spies.injectInbound.mock.calls[0][0].meta.source).toBe(
259
+ "mental_model_proposal_failed",
260
+ );
261
+ });
262
+ });
263
+
141
264
  describe("resolveMentalModelProposal — DENY writes NOTHING", () => {
142
265
  it("never reads config, builds a diff, dispatches an edit, or ensures", async () => {
143
266
  const { deps, spies } = makeDeps(CONFIG_NO_MODELS);
@@ -37,7 +37,7 @@ describe('missed-approvals re-offer wiring (#2862)', () => {
37
37
 
38
38
  it('appends the miss at TTL auto-deny, anchored to the card origin, gated by the kill switch', () => {
39
39
  // Within the pending-permission TTL sweep block.
40
- const sweep = slice(GATEWAY, 'for (const [k, v] of pendingPermissions)', 3600)
40
+ const sweep = slice(GATEWAY, 'sweepPermissionTtl({', 5400)
41
41
  expect(sweep).toContain('if (MISSED_APPROVAL_REOFFER_ENABLED) {')
42
42
  expect(sweep).toContain('missedApprovalsStore.add({')
43
43
  expect(sweep).toContain('const origin = v.cards[0]')
@@ -1359,4 +1359,18 @@ describe("isOfflineTrustedModelToken (#3042 blocker 2a)", () => {
1359
1359
  expect(isOfflineTrustedModelToken("sr-made-up/model")).toBe(false);
1360
1360
  expect(isOfflineTrustedModelToken("")).toBe(false);
1361
1361
  });
1362
+
1363
+ it("#3043 item 1: accepts case-variant aliases the live path already normalizes (OPUS, Sonnet)", () => {
1364
+ // The live accept path lowercases (isClaudeModel / expandSrAlias) so a
1365
+ // queued `/model OPUS` is accepted live — the persist gate must not then
1366
+ // refuse it with the over-conservative "couldn't verify" card.
1367
+ for (const a of MODEL_ALIASES) {
1368
+ expect(isOfflineTrustedModelToken(a.toUpperCase())).toBe(true);
1369
+ }
1370
+ expect(isOfflineTrustedModelToken("OPUS")).toBe(true);
1371
+ expect(isOfflineTrustedModelToken("Sonnet")).toBe(true);
1372
+ // Curated sr-* alias, upper-cased, still resolves.
1373
+ const [alias] = Object.entries(SR_MODEL_ALIASES)[0];
1374
+ expect(isOfflineTrustedModelToken(alias.toUpperCase())).toBe(true);
1375
+ });
1362
1376
  });
@@ -9,6 +9,7 @@ import { tmpdir } from 'os'
9
9
  import { join } from 'path'
10
10
  import { detectErrorInTranscriptLine, startSessionTail } from '../session-tail.js'
11
11
  import { resetAllCooldowns } from '../operator-events.js'
12
+ import { resolveModelUnavailableFromOperatorEvent } from '../model-unavailable.js'
12
13
 
13
14
  // ─── detectErrorInTranscriptLine unit tests ───────────────────────────────────
14
15
 
@@ -155,6 +156,79 @@ describe('detectErrorInTranscriptLine — error detection', () => {
155
156
  expect(result!.detail).toContain('hit your limit')
156
157
  })
157
158
 
159
+ // Regression — the carrie incident (2026-07-12). Anthropic also emits a
160
+ // 429 for a TRANSIENT per-account burst / RPM throttle whose wording
161
+ // explicitly negates the account-quota reading ("would exceed your
162
+ // account's rate limit … not your usage limit"). That is a self-healing
163
+ // few-second throttle Claude Code retries internally — it must NOT be
164
+ // labeled quota-exhausted (which always shows the scary "model
165
+ // unavailable" card + drives failover). It must take the calm
166
+ // rate-limited path.
167
+ it('classifies a TRANSIENT burst 429 (explicit negation) as rate-limited, NOT quota-exhausted', () => {
168
+ const line = JSON.stringify({
169
+ type: 'assistant',
170
+ message: {
171
+ role: 'assistant',
172
+ model: '<synthetic>',
173
+ content: [
174
+ {
175
+ type: 'text',
176
+ text:
177
+ 'API Error: 429 rate_limit_error This request would exceed ' +
178
+ "your account's rate limit. Please try again later. This is a " +
179
+ 'short-term burst limit, not your usage limit.',
180
+ },
181
+ ],
182
+ },
183
+ error: 'rate_limit',
184
+ isApiErrorMessage: true,
185
+ apiErrorStatus: 429,
186
+ })
187
+ const result = detectErrorInTranscriptLine(line)
188
+ expect(result).not.toBeNull()
189
+ // Wording-based classification: explicit transient negation → calm path.
190
+ expect(result!.kind).toBe('rate-limited')
191
+ expect(result!.transient).toBe(true)
192
+ // End-to-end: the resolver must NOT produce a model-unavailable card for
193
+ // this kind (the calm rate-limited branch returns null on a bare burst).
194
+ const detection = resolveModelUnavailableFromOperatorEvent({
195
+ kind: result!.kind,
196
+ detail: result!.detail,
197
+ })
198
+ expect(detection).toBeNull()
199
+ })
200
+
201
+ // Guard against over-correcting: a GENUINE quota wall (no transient marker)
202
+ // must STILL be quota-exhausted AND still resolve to a card.
203
+ it('a genuine quota-wall 429 still produces the quota-exhausted card', () => {
204
+ const line = JSON.stringify({
205
+ type: 'assistant',
206
+ message: {
207
+ role: 'assistant',
208
+ model: '<synthetic>',
209
+ content: [
210
+ {
211
+ type: 'text',
212
+ text: "You've hit your limit · resets 8:50am (Australia/Melbourne)",
213
+ },
214
+ ],
215
+ },
216
+ error: 'rate_limit',
217
+ isApiErrorMessage: true,
218
+ apiErrorStatus: 429,
219
+ })
220
+ const result = detectErrorInTranscriptLine(line)
221
+ expect(result).not.toBeNull()
222
+ expect(result!.kind).toBe('quota-exhausted')
223
+ // The quota-exhausted branch ALWAYS returns a detection (the card).
224
+ const detection = resolveModelUnavailableFromOperatorEvent({
225
+ kind: result!.kind,
226
+ detail: result!.detail,
227
+ })
228
+ expect(detection).not.toBeNull()
229
+ expect(detection!.kind).toBe('quota_exhausted')
230
+ })
231
+
158
232
  it('still returns null for a normal (non-error) assistant message', () => {
159
233
  // No isApiErrorMessage flag → must NOT be treated as an error.
160
234
  const line = JSON.stringify({
@@ -0,0 +1,107 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ redactAskUserFields,
4
+ redactChecklistFields,
5
+ } from '../outbound-field-redact.js'
6
+ import { redact } from '../secret-detect/redact.js'
7
+
8
+ /**
9
+ * OUTCOME tests for the #2044 structured-payload outbound scrub (F1/F2).
10
+ *
11
+ * These exercise the exact helpers the gateway calls (redactAskUserFields
12
+ * for ask_user, redactChecklistFields for send_checklist / update_checklist),
13
+ * injecting the SAME real `redact()` the gateway's redactOutboundText wraps.
14
+ * The assertion is on the SENT PAYLOAD the helper returns — a secret echoed
15
+ * into a question, an option/button label, a checklist title, or a task's
16
+ * text must be masked (marker present, raw token absent) — not merely that a
17
+ * redactor was invoked.
18
+ *
19
+ * Secrets are assembled at runtime (per CLAUDE.md "Secrets in tests") so the
20
+ * source file never contains a contiguous token that trips push-protection.
21
+ */
22
+
23
+ // A realistic GitHub classic PAT shape (ghp_ + 36 chars) — high-confidence,
24
+ // masked by redact() to a [REDACTED:github_pat_classic] marker.
25
+ const SECRET = 'ghp' + '_' + 'AbCdEfGhIj0123456789KlMnOpQrStUvWxYz'
26
+
27
+ describe('redactAskUserFields — ask_user outbound scrub (F1)', () => {
28
+ it('masks a secret echoed into the QUESTION text', () => {
29
+ const out = redactAskUserFields(
30
+ `Deploy with token ${SECRET}?`,
31
+ ['Yes', 'No'],
32
+ redact,
33
+ )
34
+ expect(out.question).not.toContain(SECRET)
35
+ expect(out.question).toContain('[REDACTED')
36
+ // Non-secret prose is preserved.
37
+ expect(out.question).toContain('Deploy with token')
38
+ })
39
+
40
+ it('masks a secret echoed into an OPTION / button label', () => {
41
+ const out = redactAskUserFields(
42
+ 'Which key?',
43
+ ['Cancel', `Use ${SECRET}`],
44
+ redact,
45
+ )
46
+ expect(out.options[0]).toBe('Cancel')
47
+ expect(out.options[1]).not.toContain(SECRET)
48
+ expect(out.options[1]).toContain('[REDACTED')
49
+ })
50
+
51
+ it('leaves clean question + options untouched', () => {
52
+ const out = redactAskUserFields('Proceed?', ['Yes', 'No'], redact)
53
+ expect(out.question).toBe('Proceed?')
54
+ expect(out.options).toEqual(['Yes', 'No'])
55
+ })
56
+
57
+ it('does not mutate the caller-supplied options array', () => {
58
+ const options = ['Cancel', `Use ${SECRET}`]
59
+ redactAskUserFields('q', options, redact)
60
+ expect(options[1]).toBe(`Use ${SECRET}`) // original array untouched
61
+ })
62
+ })
63
+
64
+ describe('redactChecklistFields — checklist outbound scrub (F2)', () => {
65
+ it('masks a secret in the checklist TITLE', () => {
66
+ const out = redactChecklistFields(
67
+ `Rotate ${SECRET}`,
68
+ [{ text: 'step one' }],
69
+ redact,
70
+ )
71
+ expect(out.title).not.toContain(SECRET)
72
+ expect(out.title).toContain('[REDACTED')
73
+ })
74
+
75
+ it('masks a secret in a TASK text and preserves other task fields', () => {
76
+ const out = redactChecklistFields(
77
+ 'Onboarding',
78
+ [
79
+ { text: 'read the docs', done: true },
80
+ { text: `save ${SECRET} to vault`, id: '7' },
81
+ ],
82
+ redact,
83
+ )
84
+ expect(out.tasks![0]).toEqual({ text: 'read the docs', done: true })
85
+ expect(out.tasks![1]!.text).not.toContain(SECRET)
86
+ expect(out.tasks![1]!.text).toContain('[REDACTED')
87
+ // Sibling fields on the redacted task are carried through untouched.
88
+ expect(out.tasks![1]!.id).toBe('7')
89
+ })
90
+
91
+ it('passes undefined title / tasks through (update_checklist partial patch)', () => {
92
+ const out = redactChecklistFields(undefined, undefined, redact)
93
+ expect(out.title).toBeUndefined()
94
+ expect(out.tasks).toBeUndefined()
95
+ })
96
+
97
+ it('leaves an id-only task (no text) untouched', () => {
98
+ const out = redactChecklistFields(undefined, [{ id: '3', done: true }], redact)
99
+ expect(out.tasks![0]).toEqual({ id: '3', done: true })
100
+ })
101
+
102
+ it('does not mutate the caller-supplied task objects', () => {
103
+ const tasks = [{ text: `save ${SECRET}` }]
104
+ redactChecklistFields('t', tasks, redact)
105
+ expect(tasks[0]!.text).toBe(`save ${SECRET}`) // original untouched
106
+ })
107
+ })
@@ -319,4 +319,25 @@ describe('drainTakenCommands (#3042 blocker 1: an early stop must not drop the r
319
319
  await drainTakenCommands(batch(), io)
320
320
  expect(events).toEqual(['edit:model:persisted:model', 'edit:effort:persisted:effort'])
321
321
  })
322
+
323
+ it('#3021: restart-pending is snapshotted at entry → sync loop emptying pendingRestarts mid-drain still defers the SECOND command', async () => {
324
+ // Reproduces the turn-end idle-gate race: the drain is void-dispatched while
325
+ // a synchronous pendingRestarts loop empties the map right after. The first
326
+ // read sees the entry; by the second command the map has been emptied. A
327
+ // naive per-command re-check would let the second command apply live into a
328
+ // session ~100ms from triggerSelfRestart and falsely confirm.
329
+ let restartPresent = true
330
+ const { io, events } = makeIo({
331
+ // takeAll() first read latches true; the sync loop then empties the map,
332
+ // so every subsequent live read returns false.
333
+ restartPending: () => {
334
+ const was = restartPresent
335
+ restartPresent = false // the sync loop deleted the entry after dispatch
336
+ return was
337
+ },
338
+ })
339
+ await drainTakenCommands(batch(), io)
340
+ // BOTH commands must ride the carriers — the second must NOT apply live.
341
+ expect(events).toEqual(['edit:model:persisted:model', 'edit:effort:persisted:effort'])
342
+ })
322
343
  })
@@ -46,6 +46,24 @@ function onPermissionRequestBody(): string {
46
46
  return rest.slice(0, end)
47
47
  }
48
48
 
49
+ /**
50
+ * Slice the body of `postPermissionCard` — THE card emitter.
51
+ *
52
+ * The send used to be inlined in `onPermissionRequest`. The #3084 follow-up
53
+ * extracted it here so it has two callers (the initial delivery, and the
54
+ * reaper's re-delivery of a card held through a flood ban). The routing
55
+ * contract these tests pin is unchanged; it just lives one function over, and
56
+ * now BOTH delivery paths inherit it — which is the point of the extraction.
57
+ */
58
+ function postPermissionCardBody(): string {
59
+ const start = GATEWAY_SRC.indexOf('function postPermissionCard(')
60
+ expect(start).toBeGreaterThan(-1)
61
+ const rest = GATEWAY_SRC.slice(start)
62
+ const end = rest.indexOf('\nfunction ', 1)
63
+ expect(end).toBeGreaterThan(-1)
64
+ return rest.slice(0, end)
65
+ }
66
+
49
67
  describe('permission card routing', () => {
50
68
  it('the shared target helper exists', () => {
51
69
  expect(
@@ -53,19 +71,26 @@ describe('permission card routing', () => {
53
71
  ).toBe(true)
54
72
  })
55
73
 
56
- it('the initial card emitter routes via resolvePermissionCardTargets()', () => {
57
- expect(onPermissionRequestBody()).toContain('resolvePermissionCardTargets()')
74
+ it('the card emitter routes via resolvePermissionCardTargets()', () => {
75
+ expect(postPermissionCardBody()).toContain('resolvePermissionCardTargets()')
58
76
  })
59
77
 
60
- it('the initial card emitter no longer iterates access.allowFrom directly (the bug shape)', () => {
78
+ it('the card emitter no longer iterates access.allowFrom directly (the bug shape)', () => {
61
79
  // The raw fan-out loop is what sent supergroup cards to operator DMs.
62
- expect(onPermissionRequestBody()).not.toMatch(
80
+ expect(postPermissionCardBody()).not.toMatch(
63
81
  /for\s*\(\s*const\s+chat_id\s+of\s+access\.allowFrom\s*\)/,
64
82
  )
65
83
  })
66
84
 
67
85
  it('the card send is wrapped in retryWithThreadFallback (stale-topic → thread-less, not a silent drop)', () => {
68
- expect(onPermissionRequestBody()).toContain('retryWithThreadFallback')
86
+ expect(postPermissionCardBody()).toContain('retryWithThreadFallback')
87
+ })
88
+
89
+ it('the initial permission request still delivers through that one emitter', () => {
90
+ // Guards the extraction itself: onPermissionRequest must not regrow its own
91
+ // send path and drift from the re-delivery path.
92
+ expect(onPermissionRequestBody()).toContain('postPermissionCard(requestId, pendEntry)')
93
+ expect(onPermissionRequestBody()).not.toContain('retryWithThreadFallback')
69
94
  })
70
95
 
71
96
  it('the resume message uses the SAME helper, so card + resume cannot drift', () => {
@@ -45,15 +45,15 @@ describe('no-repeat-on-timeout wiring', () => {
45
45
  // when Bug 2 added per-tool TTL + the timed-out keyboard-strip to this
46
46
  // block — the signature-record line now sits further down but the wiring
47
47
  // is intact.
48
- const sweep = slice(GATEWAY, 'for (const [k, v] of pendingPermissions)', 3200)
48
+ const sweep = slice(GATEWAY, 'sweepPermissionTtl({', 5000)
49
49
  expect(sweep).toContain('timeoutDenyMessage(')
50
50
  expect(sweep).toContain('permissionTimeoutSignatures.set(')
51
51
  })
52
52
 
53
53
  it('the TTL auto-deny strips the timed-out card keyboard (Bug 2)', () => {
54
- const sweep = slice(GATEWAY, 'for (const [k, v] of pendingPermissions)', 3200)
54
+ const sweep = slice(GATEWAY, 'sweepPermissionTtl({', 5000)
55
55
  // Per-tool TTL + keyboard-strip are both wired into the sweep.
56
- expect(sweep).toContain('ttlForTool(')
56
+ expect(sweep).toContain('ttlForTool')
57
57
  expect(sweep).toContain('stripTimedOutPermissionCards(')
58
58
  })
59
59
 
@@ -91,14 +91,15 @@ describe('inbound gate holds while approval card is outstanding (#2841)', () =>
91
91
  // first interim reply. Without this, a new inbound delivered in that window
92
92
  // displaces the approval context and orphans the pending MCP call.
93
93
  it('turnInFlightForGate includes pendingPermissions.size > 0 on the legacy path', () => {
94
- // span 1500: the function has a ~800-char comment block before the code lines.
95
- const fn = slice(GATEWAY, 'function turnInFlightForGate()', 1500)
94
+ // span 2600: the function gained a ~1100-char note (#3084 follow-up) documenting
95
+ // the one case with no timeout valve — a HELD approval.
96
+ const fn = slice(GATEWAY, 'function turnInFlightForGate()', 2600)
96
97
  // Both paths (legacy claudeBusyKeys + machine-authoritative) must include the check.
97
98
  expect(fn).toMatch(/claudeBusyKeys\.size\s*>\s*0\s*\|\|\s*hasPendingApproval/)
98
99
  })
99
100
 
100
101
  it('turnInFlightForGate includes pendingPermissions.size > 0 on the machine path', () => {
101
- const fn = slice(GATEWAY, 'function turnInFlightForGate()', 1600)
102
+ const fn = slice(GATEWAY, 'function turnInFlightForGate()', 2700)
102
103
  // probeGateParity(...) || hasPendingApproval — the inner arg contains its own
103
104
  // parens (isMachineInTurn()), so match the two tokens independently.
104
105
  expect(fn).toContain('probeGateParity(')
@@ -106,7 +107,7 @@ describe('inbound gate holds while approval card is outstanding (#2841)', () =>
106
107
  })
107
108
 
108
109
  it('hasPendingApproval reads pendingPermissions.size', () => {
109
- const fn = slice(GATEWAY, 'function turnInFlightForGate()', 1500)
110
+ const fn = slice(GATEWAY, 'function turnInFlightForGate()', 2600)
110
111
  expect(fn).toMatch(/pendingPermissions\.size\s*>\s*0/)
111
112
  })
112
113
  })
@@ -163,7 +163,7 @@ describe('gateway boot-sweep grace period', () => {
163
163
 
164
164
  describe('re-armed pending card re-holds the inbound gate (#2840)', () => {
165
165
  it('turnInFlightForGate counts pending approvals', () => {
166
- const gate = slice(GATEWAY, 'function turnInFlightForGate()', 1200)
166
+ const gate = slice(GATEWAY, 'function turnInFlightForGate()', 2400)
167
167
  expect(gate).toMatch(/pendingPermissions\.size > 0/)
168
168
  })
169
169
  it('re-arm restores a pendingPermissions entry → gate held automatically', () => {
@@ -0,0 +1,148 @@
1
+ /**
2
+ * #2975 Stage 2 — gateway-side read-only pre-approval predicate.
3
+ *
4
+ * `isDiffPreApproved` is what the gateway answers a hostd `check_pre_approved`
5
+ * query with. It MUST:
6
+ * - return true ONLY for a byte-exact registered (agent, diff) pair
7
+ * (mental-model proposal OR "🔁 Always allow" correlation);
8
+ * - be forge-resistant: a diff differing by ONE byte from the registered one
9
+ * is NOT pre-approved;
10
+ * - NEVER mutate the correlation store (the single-use auto-resolve delete
11
+ * stays on the real request_config_approval — a peek can't consume consent).
12
+ */
13
+
14
+ import { describe, it, expect, vi } from "vitest";
15
+ import { createHash } from "node:crypto";
16
+ import {
17
+ isDiffPreApproved,
18
+ type PreApprovalCheckDeps,
19
+ } from "../gateway/pre-approval-check.js";
20
+
21
+ const AGENT = "klanker";
22
+ const MM_DIFF =
23
+ "--- a/switchroom.yaml\n+++ b/switchroom.yaml\n@@ -1 +1,2 @@\n+ mental_models: [x]\n";
24
+ const ALLOW_RULE = "Bash";
25
+ const ALLOW_DIFF =
26
+ "--- a/switchroom.yaml\n+++ b/switchroom.yaml\n@@ -1 +1,2 @@\n+ - Bash\n";
27
+
28
+ function mmKey(agent: string, diff: string): string {
29
+ return `${agent}::${createHash("sha256").update(diff).digest("hex")}`;
30
+ }
31
+
32
+ /** A Map-backed store exposing only sweep()+get(), plus spies to prove no
33
+ * mutation happened (the ReadonlyCorrelationStore type has no delete/set, so
34
+ * a mutating edit wouldn't even typecheck — these spies double-check sweep). */
35
+ function makeStore(entries: Record<string, { unifiedDiff: string }>) {
36
+ const map = new Map(Object.entries(entries));
37
+ const sweep = vi.fn((_now: number) => {
38
+ /* no expiry in these tests */
39
+ });
40
+ return {
41
+ store: {
42
+ sweep,
43
+ get: (k: string) => map.get(k),
44
+ },
45
+ map,
46
+ sweep,
47
+ };
48
+ }
49
+
50
+ function makeDeps(overrides: {
51
+ alwaysAllow?: Record<string, { unifiedDiff: string }>;
52
+ mentalModel?: Record<string, { unifiedDiff: string }>;
53
+ }): {
54
+ deps: PreApprovalCheckDeps;
55
+ aaMap: Map<string, { unifiedDiff: string }>;
56
+ mmMap: Map<string, { unifiedDiff: string }>;
57
+ } {
58
+ const aa = makeStore(overrides.alwaysAllow ?? {});
59
+ const mm = makeStore(overrides.mentalModel ?? {});
60
+ const deps: PreApprovalCheckDeps = {
61
+ alwaysAllow: aa.store,
62
+ mentalModel: mm.store,
63
+ // Only recognise the ALLOW_RULE token when it appears as an added line.
64
+ extractAddedAllowRule: (diff) =>
65
+ diff.includes(`+ - ${ALLOW_RULE}`) ? ALLOW_RULE : null,
66
+ mentalModelCorrelationKey: mmKey,
67
+ };
68
+ return { deps, aaMap: aa.map, mmMap: mm.map };
69
+ }
70
+
71
+ describe("isDiffPreApproved — mental-model correlation", () => {
72
+ it("true for a byte-exact registered (agent, diff) pair", () => {
73
+ const { deps } = makeDeps({
74
+ mentalModel: { [mmKey(AGENT, MM_DIFF)]: { unifiedDiff: MM_DIFF } },
75
+ });
76
+ expect(isDiffPreApproved(AGENT, MM_DIFF, deps)).toBe(true);
77
+ });
78
+
79
+ it("false (forge-resistant) for a diff differing by one byte", () => {
80
+ const { deps } = makeDeps({
81
+ mentalModel: { [mmKey(AGENT, MM_DIFF)]: { unifiedDiff: MM_DIFF } },
82
+ });
83
+ // The key is derived from the tampered diff → no entry at that key; and even
84
+ // if an attacker could guess the key, the stored unifiedDiff wouldn't match.
85
+ expect(isDiffPreApproved(AGENT, MM_DIFF + " ", deps)).toBe(false);
86
+ });
87
+
88
+ it("false for a matching diff under a DIFFERENT agent name", () => {
89
+ const { deps } = makeDeps({
90
+ mentalModel: { [mmKey(AGENT, MM_DIFF)]: { unifiedDiff: MM_DIFF } },
91
+ });
92
+ expect(isDiffPreApproved("someone-else", MM_DIFF, deps)).toBe(false);
93
+ });
94
+
95
+ it("false when nothing is registered", () => {
96
+ const { deps } = makeDeps({});
97
+ expect(isDiffPreApproved(AGENT, MM_DIFF, deps)).toBe(false);
98
+ });
99
+ });
100
+
101
+ describe("isDiffPreApproved — always-allow correlation", () => {
102
+ it("true for a byte-exact registered always-allow diff", () => {
103
+ const { deps } = makeDeps({
104
+ alwaysAllow: {
105
+ [`${AGENT}::${ALLOW_RULE}`]: { unifiedDiff: ALLOW_DIFF },
106
+ },
107
+ });
108
+ expect(isDiffPreApproved(AGENT, ALLOW_DIFF, deps)).toBe(true);
109
+ });
110
+
111
+ it("false when the added rule token matches but the diff bytes differ (forge)", () => {
112
+ const { deps } = makeDeps({
113
+ alwaysAllow: {
114
+ [`${AGENT}::${ALLOW_RULE}`]: { unifiedDiff: ALLOW_DIFF },
115
+ },
116
+ });
117
+ // Same rule token, but the diff smuggles an extra byte → byte-match fails.
118
+ expect(isDiffPreApproved(AGENT, ALLOW_DIFF + "\n", deps)).toBe(false);
119
+ });
120
+ });
121
+
122
+ describe("isDiffPreApproved — read-only invariant", () => {
123
+ it("never mutates either correlation store on a match", () => {
124
+ const { deps, mmMap } = makeDeps({
125
+ mentalModel: { [mmKey(AGENT, MM_DIFF)]: { unifiedDiff: MM_DIFF } },
126
+ });
127
+ const sizeBefore = mmMap.size;
128
+ expect(isDiffPreApproved(AGENT, MM_DIFF, deps)).toBe(true);
129
+ // The matched correlation is STILL present — a peek cannot consume the
130
+ // single-use consent the real request_config_approval will spend later.
131
+ expect(mmMap.size).toBe(sizeBefore);
132
+ expect(mmMap.has(mmKey(AGENT, MM_DIFF))).toBe(true);
133
+ // A second identical query still answers true (idempotent, no consumption).
134
+ expect(isDiffPreApproved(AGENT, MM_DIFF, deps)).toBe(true);
135
+ });
136
+
137
+ it("never mutates the store on a miss", () => {
138
+ const { deps, aaMap, mmMap } = makeDeps({
139
+ alwaysAllow: {
140
+ [`${AGENT}::${ALLOW_RULE}`]: { unifiedDiff: ALLOW_DIFF },
141
+ },
142
+ mentalModel: { [mmKey(AGENT, MM_DIFF)]: { unifiedDiff: MM_DIFF } },
143
+ });
144
+ expect(isDiffPreApproved(AGENT, "totally-unrelated-diff", deps)).toBe(false);
145
+ expect(aaMap.size).toBe(1);
146
+ expect(mmMap.size).toBe(1);
147
+ });
148
+ });