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
|
@@ -41,6 +41,43 @@ import { createSweepableStore } from '../gateway/pending-state-stores.js'
|
|
|
41
41
|
import { StagingMap } from '../secret-detect/staging.js'
|
|
42
42
|
import { InlineKeyboard } from 'grammy'
|
|
43
43
|
|
|
44
|
+
// Mock the auth-broker client so the `auth:use:` swap path is observable
|
|
45
|
+
// (spy on setActive) without a live UDS broker. Only handleAuthDashboardCallback
|
|
46
|
+
// touches getAuthBrokerClient, so this is inert for every other family here.
|
|
47
|
+
//
|
|
48
|
+
// The spy is created *inside* the factory and re-exported as a test-only
|
|
49
|
+
// handle (`__setActiveSpy`) rather than closed over from a `vi.hoisted`
|
|
50
|
+
// binding: bun's vitest-compat layer implements `vi.mock`/`vi.fn` but NOT
|
|
51
|
+
// `vi.hoisted`, so a hoisted-closure mock throws `vi.hoisted is not a
|
|
52
|
+
// function` under `bun test` (CI's bun-test-run shard runs this whole dir).
|
|
53
|
+
// A single `setActive` instance is closed over and returned by every
|
|
54
|
+
// `getAuthBrokerClient()` call, so the handler and the tests observe the
|
|
55
|
+
// same spy.
|
|
56
|
+
vi.mock('../gateway/auth-broker-client.js', () => {
|
|
57
|
+
const setActive = vi.fn(async () => ({ active: 'acct-b', fanned: ['a1'] }))
|
|
58
|
+
return {
|
|
59
|
+
getAuthBrokerClient: vi.fn(async () => ({ setActive })),
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
import { getAuthBrokerClient } from '../gateway/auth-broker-client.js'
|
|
63
|
+
// The mocked getAuthBrokerClient closes over one shared `setActive` spy and
|
|
64
|
+
// returns it on every call, so resolving the client here yields the SAME spy
|
|
65
|
+
// the handler observes. A getter defers the read to test-run time (after the
|
|
66
|
+
// mock is live under both runners); a beforeEach caches the resolved spy.
|
|
67
|
+
let resolvedSetActiveSpy: ReturnType<typeof vi.fn> | undefined
|
|
68
|
+
const brokerMock = {
|
|
69
|
+
get setActive(): ReturnType<typeof vi.fn> {
|
|
70
|
+
if (!resolvedSetActiveSpy) throw new Error('setActive spy not resolved yet')
|
|
71
|
+
return resolvedSetActiveSpy
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
beforeEach(async () => {
|
|
75
|
+
const client = (await getAuthBrokerClient('test-agent')) as {
|
|
76
|
+
setActive: ReturnType<typeof vi.fn>
|
|
77
|
+
}
|
|
78
|
+
resolvedSetActiveSpy = client.setActive
|
|
79
|
+
})
|
|
80
|
+
|
|
44
81
|
// ── Fakes ────────────────────────────────────────────────────────────────
|
|
45
82
|
|
|
46
83
|
interface FakeCtxOpts {
|
|
@@ -568,6 +605,34 @@ describe('handleAuthDashboardCallback', () => {
|
|
|
568
605
|
show_alert: false,
|
|
569
606
|
})
|
|
570
607
|
})
|
|
608
|
+
|
|
609
|
+
// Security gate (found in an adversarial security review): the auth: family
|
|
610
|
+
// is a mutating callback family (auth:use:<label> → broker.setActive, a
|
|
611
|
+
// fleet-wide OAuth account swap) and MUST enforce the same allowFrom gate as
|
|
612
|
+
// every sibling family. Without the gate, any tapper on an admin forum/
|
|
613
|
+
// supergroup with an empty group allowFrom could swap the active account.
|
|
614
|
+
it('rejects an auth:use swap from a sender not on allowFrom (never reaches setActive)', async () => {
|
|
615
|
+
const { deps } = makeDeps() // allowFrom = ['111','222']
|
|
616
|
+
const h = createCallbackQueryHandlers(deps)
|
|
617
|
+
brokerMock.setActive.mockClear()
|
|
618
|
+
const { ctx, raw } = makeCtx({ senderId: '999', data: 'auth:use:acct-b' })
|
|
619
|
+
await h.handleAuthDashboardCallback(ctx)
|
|
620
|
+
expect(raw.answerCallbackQuery).toHaveBeenCalledWith({ text: 'Not authorized.' })
|
|
621
|
+
// The load-bearing assertion: the swap must not fire for an unauthorized tap.
|
|
622
|
+
expect(brokerMock.setActive).not.toHaveBeenCalled()
|
|
623
|
+
})
|
|
624
|
+
|
|
625
|
+
it('lets an allowFrom sender through to the setActive swap path', async () => {
|
|
626
|
+
const { deps } = makeDeps()
|
|
627
|
+
const h = createCallbackQueryHandlers(deps)
|
|
628
|
+
brokerMock.setActive.mockClear()
|
|
629
|
+
const { ctx, raw } = makeCtx({ senderId: '111', data: 'auth:use:acct-b' })
|
|
630
|
+
await h.handleAuthDashboardCallback(ctx)
|
|
631
|
+
expect(brokerMock.setActive).toHaveBeenCalledWith('acct-b')
|
|
632
|
+
expect(raw.answerCallbackQuery).toHaveBeenCalledWith(
|
|
633
|
+
expect.objectContaining({ text: expect.stringContaining('Switched fleet → acct-b') }),
|
|
634
|
+
)
|
|
635
|
+
})
|
|
571
636
|
})
|
|
572
637
|
|
|
573
638
|
// ── vg:* — grant wizard + management ────────────────────────────────────
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the #396 dispatch-claim Stop-hook scan logic — the pure
|
|
3
|
+
* `dispatch-claim-scan.mjs` helpers (mirrors the silent-end-scan test
|
|
4
|
+
* style: synthetic JSONL fixtures against a pure function rather than
|
|
5
|
+
* spawning the .mjs subprocess).
|
|
6
|
+
*
|
|
7
|
+
* The defect: a reply that TELLS the user work was dispatched
|
|
8
|
+
* ("Dispatching a worker now") while the turn contains no Agent/Task call
|
|
9
|
+
* and no backgrounded Bash. These assert OUTCOMES (block vs allow), not
|
|
10
|
+
* code paths.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, it, expect } from 'vitest'
|
|
14
|
+
import {
|
|
15
|
+
scanTurnForDispatchClaim,
|
|
16
|
+
replyClaimsDispatch,
|
|
17
|
+
applyRetryBudget,
|
|
18
|
+
turnSignature,
|
|
19
|
+
} from '../hooks/dispatch-claim-scan.mjs'
|
|
20
|
+
|
|
21
|
+
// ── Fixture builders ────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
const REPLY = 'mcp__switchroom-telegram__reply'
|
|
24
|
+
|
|
25
|
+
const ENQUEUE = JSON.stringify({
|
|
26
|
+
type: 'queue-operation',
|
|
27
|
+
operation: 'enqueue',
|
|
28
|
+
content: '<channel source="switchroom-telegram" chat_id="111" message_id="42">fix the bug</channel>',
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
function assistantToolUse(
|
|
32
|
+
name: string,
|
|
33
|
+
input: Record<string, unknown>,
|
|
34
|
+
opts: { isSidechain?: boolean } = {},
|
|
35
|
+
) {
|
|
36
|
+
const base: Record<string, unknown> = {
|
|
37
|
+
type: 'assistant',
|
|
38
|
+
message: { content: [{ type: 'tool_use', name, input }] },
|
|
39
|
+
}
|
|
40
|
+
if (opts.isSidechain) base.isSidechain = true
|
|
41
|
+
return JSON.stringify(base)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function reply(text: string, opts: { isSidechain?: boolean } = {}) {
|
|
45
|
+
return assistantToolUse(REPLY, { text, chat_id: '111' }, opts)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function jsonl(...lines: string[]) {
|
|
49
|
+
return lines.join('\n')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ── (a) claim + no dispatch tool_use → block ────────────────────────
|
|
53
|
+
|
|
54
|
+
describe('scanTurnForDispatchClaim — narrate-without-execute', () => {
|
|
55
|
+
it('(a) blocks a dispatch claim with no Agent/Task call in the turn', () => {
|
|
56
|
+
const t = jsonl(ENQUEUE, reply('Dispatching a worker now to fix this bug.'))
|
|
57
|
+
const d = scanTurnForDispatchClaim(t)
|
|
58
|
+
expect(d.decided).toBe('block')
|
|
59
|
+
expect(d.reason).toBe('claim-without-dispatch')
|
|
60
|
+
expect(d.turnSig).toBeTruthy()
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
// ── (b) claim + Agent tool_use in turn → allow ────────────────────
|
|
64
|
+
it('(b) allows when the turn actually dispatched an Agent', () => {
|
|
65
|
+
const t = jsonl(
|
|
66
|
+
ENQUEUE,
|
|
67
|
+
reply('Dispatching a worker now to fix this bug.'),
|
|
68
|
+
assistantToolUse('Agent', { subagent_type: 'worker', description: 'fix bug' }),
|
|
69
|
+
)
|
|
70
|
+
const d = scanTurnForDispatchClaim(t)
|
|
71
|
+
expect(d.decided).toBe('allow')
|
|
72
|
+
expect(d.reason).toBe('dispatched')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('(b2) allows when the dispatch tool is the newer Task name', () => {
|
|
76
|
+
const t = jsonl(
|
|
77
|
+
ENQUEUE,
|
|
78
|
+
reply('Spinning up a researcher to dig into this.'),
|
|
79
|
+
assistantToolUse('Task', { subagent_type: 'researcher' }),
|
|
80
|
+
)
|
|
81
|
+
expect(scanTurnForDispatchClaim(t).decided).toBe('allow')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
// ── (c) claim + backgrounded Bash → allow ─────────────────────────
|
|
85
|
+
it('(c) allows when the turn launched a backgrounded Bash', () => {
|
|
86
|
+
const t = jsonl(
|
|
87
|
+
ENQUEUE,
|
|
88
|
+
reply('Kicking off the build agent in the background.'),
|
|
89
|
+
assistantToolUse('Bash', { command: 'npm run build', run_in_background: true }),
|
|
90
|
+
)
|
|
91
|
+
const d = scanTurnForDispatchClaim(t)
|
|
92
|
+
expect(d.decided).toBe('allow')
|
|
93
|
+
expect(d.reason).toBe('dispatched')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('(c2) a FOREGROUND Bash does not count as a dispatch', () => {
|
|
97
|
+
const t = jsonl(
|
|
98
|
+
ENQUEUE,
|
|
99
|
+
reply('Dispatching a worker to handle this.'),
|
|
100
|
+
assistantToolUse('Bash', { command: 'ls', run_in_background: false }),
|
|
101
|
+
)
|
|
102
|
+
expect(scanTurnForDispatchClaim(t).decided).toBe('block')
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
// ── (d) conditional offer → allow ─────────────────────────────────
|
|
106
|
+
it('(d) allows a conditional offer to dispatch (question)', () => {
|
|
107
|
+
const t = jsonl(ENQUEUE, reply('Want me to dispatch a worker to fix this?'))
|
|
108
|
+
const d = scanTurnForDispatchClaim(t)
|
|
109
|
+
expect(d.decided).toBe('allow')
|
|
110
|
+
expect(d.reason).toBe('no-claim')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('(d2) allows a conditional "I could spin up a reviewer if you like"', () => {
|
|
114
|
+
const t = jsonl(ENQUEUE, reply('I could spin up a reviewer if you want me to.'))
|
|
115
|
+
expect(scanTurnForDispatchClaim(t).decided).toBe('allow')
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
// ── (e) non-dispatch reply → allow ────────────────────────────────
|
|
119
|
+
it('(e) allows an ordinary non-dispatch reply', () => {
|
|
120
|
+
const t = jsonl(ENQUEUE, reply('Here is the answer: the bug is a null deref on line 12.'))
|
|
121
|
+
const d = scanTurnForDispatchClaim(t)
|
|
122
|
+
expect(d.decided).toBe('allow')
|
|
123
|
+
expect(d.reason).toBe('no-claim')
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
// ── (f) NO_REPLY-only turn → allow ────────────────────────────────
|
|
127
|
+
it('(f) allows a NO_REPLY-only turn', () => {
|
|
128
|
+
const t = jsonl(ENQUEUE, reply('NO_REPLY'))
|
|
129
|
+
expect(scanTurnForDispatchClaim(t).decided).toBe('allow')
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
// ── (g) sidechain lines ───────────────────────────────────────────
|
|
133
|
+
it('(g) sidechain Agent lines do NOT count as the parent dispatching', () => {
|
|
134
|
+
// A sub-agent's own Agent tool_use leaks in with isSidechain — it must
|
|
135
|
+
// not satisfy the PARENT's dispatch obligation. Parent reply claims a
|
|
136
|
+
// dispatch, parent itself dispatched nothing → block.
|
|
137
|
+
const t = jsonl(
|
|
138
|
+
ENQUEUE,
|
|
139
|
+
reply('Dispatching a worker now.'),
|
|
140
|
+
assistantToolUse('Agent', { subagent_type: 'worker' }, { isSidechain: true }),
|
|
141
|
+
)
|
|
142
|
+
expect(scanTurnForDispatchClaim(t).decided).toBe('block')
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('(g2) sidechain replies do NOT count as the parent making a claim', () => {
|
|
146
|
+
// A sub-agent's own reply narrating a dispatch leaks in with
|
|
147
|
+
// isSidechain; the parent made no claim → allow.
|
|
148
|
+
const t = jsonl(
|
|
149
|
+
ENQUEUE,
|
|
150
|
+
reply('Working on the fix directly.'),
|
|
151
|
+
reply('Dispatching a worker to handle the subtask.', { isSidechain: true }),
|
|
152
|
+
)
|
|
153
|
+
const d = scanTurnForDispatchClaim(t)
|
|
154
|
+
expect(d.decided).toBe('allow')
|
|
155
|
+
expect(d.reason).toBe('no-claim')
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
// ── (i) malformed / missing transcript → allow (fail-open) ────────
|
|
159
|
+
it('(i) fails open on an empty transcript', () => {
|
|
160
|
+
expect(scanTurnForDispatchClaim('').decided).toBe('unknown')
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('(i2) fails open when no turn-start anchor is present', () => {
|
|
164
|
+
const t = jsonl(reply('Dispatching a worker now.'))
|
|
165
|
+
expect(scanTurnForDispatchClaim(t).decided).toBe('unknown')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('(i3) tolerates malformed JSONL lines mixed in', () => {
|
|
169
|
+
const t = jsonl(ENQUEUE, '{ not valid json', reply('Dispatching a worker now.'))
|
|
170
|
+
const d = scanTurnForDispatchClaim(t)
|
|
171
|
+
expect(d.decided).toBe('block')
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
// ── past-tense reference (registry-check substitute) ──────────────
|
|
175
|
+
it('does NOT block a bare past-tense reference to an earlier dispatch', () => {
|
|
176
|
+
const t = jsonl(
|
|
177
|
+
ENQUEUE,
|
|
178
|
+
reply('The worker I dispatched earlier finished; here are its results.'),
|
|
179
|
+
)
|
|
180
|
+
const d = scanTurnForDispatchClaim(t)
|
|
181
|
+
expect(d.decided).toBe('allow')
|
|
182
|
+
expect(d.reason).toBe('no-claim')
|
|
183
|
+
})
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
// ── replyClaimsDispatch unit coverage ───────────────────────────────
|
|
187
|
+
|
|
188
|
+
describe('replyClaimsDispatch', () => {
|
|
189
|
+
it('matches present/progressive commitments', () => {
|
|
190
|
+
expect(replyClaimsDispatch('Dispatching a worker now.')).toBe(true)
|
|
191
|
+
expect(replyClaimsDispatch("I'll dispatch a reviewer to check this.")).toBe(true)
|
|
192
|
+
expect(replyClaimsDispatch('Spinning up a sub-agent for the research.')).toBe(true)
|
|
193
|
+
expect(replyClaimsDispatch('Kicking off a worker on that task.')).toBe(true)
|
|
194
|
+
expect(replyClaimsDispatch('Delegating this to a researcher.')).toBe(true)
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
it('excludes interrogative / conditional sentences', () => {
|
|
198
|
+
expect(replyClaimsDispatch('Want me to dispatch a worker?')).toBe(false)
|
|
199
|
+
expect(replyClaimsDispatch('I could dispatch a reviewer if you like.')).toBe(false)
|
|
200
|
+
expect(replyClaimsDispatch('Should I spin up a worker for this?')).toBe(false)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it('excludes pure past-tense references', () => {
|
|
204
|
+
expect(replyClaimsDispatch('The worker I dispatched earlier is done.')).toBe(false)
|
|
205
|
+
expect(replyClaimsDispatch('I launched a reviewer last turn.')).toBe(false)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
it('ignores unrelated prose and bare silent markers', () => {
|
|
209
|
+
expect(replyClaimsDispatch('The fix is a null-deref guard on line 12.')).toBe(false)
|
|
210
|
+
expect(replyClaimsDispatch('NO_REPLY')).toBe(false)
|
|
211
|
+
expect(replyClaimsDispatch('')).toBe(false)
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
it('scopes conditional exclusion to the sentence carrying the verb', () => {
|
|
215
|
+
// First sentence is a real commitment; a later unrelated question must
|
|
216
|
+
// not amnesty it.
|
|
217
|
+
expect(
|
|
218
|
+
replyClaimsDispatch('Dispatching a worker now. Anything else you need?'),
|
|
219
|
+
).toBe(true)
|
|
220
|
+
})
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
// ── (h) retry budget: exactly one re-prompt per turn ────────────────
|
|
224
|
+
|
|
225
|
+
describe('applyRetryBudget — exactly one re-prompt per turn', () => {
|
|
226
|
+
const sig = turnSignature(ENQUEUE)
|
|
227
|
+
|
|
228
|
+
it('(h) blocks on the first Stop, then fails open on the second same-turn Stop', () => {
|
|
229
|
+
const first = applyRetryBudget({}, sig, 1)
|
|
230
|
+
expect(first.block).toBe(true)
|
|
231
|
+
expect(first.nextState?.retryCount).toBe(1)
|
|
232
|
+
expect(first.nextState?.turnSig).toBe(sig)
|
|
233
|
+
|
|
234
|
+
// Second Stop fire on the SAME turn reads back the persisted state.
|
|
235
|
+
const second = applyRetryBudget(first.nextState, sig, 1)
|
|
236
|
+
expect(second.block).toBe(false)
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
it('resets the budget for a genuinely new turn (different signature)', () => {
|
|
240
|
+
const spent = { turnSig: sig, retryCount: 1 }
|
|
241
|
+
const newTurn = applyRetryBudget(spent, turnSignature('a different inbound'), 1)
|
|
242
|
+
expect(newTurn.block).toBe(true)
|
|
243
|
+
expect(newTurn.nextState?.retryCount).toBe(1)
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('tolerates a null / corrupt prior state', () => {
|
|
247
|
+
expect(applyRetryBudget(null, sig, 1).block).toBe(true)
|
|
248
|
+
expect(applyRetryBudget(undefined, sig, 1).block).toBe(true)
|
|
249
|
+
})
|
|
250
|
+
})
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3106 — a permissions error must not silently blind the flood breaker.
|
|
3
|
+
*
|
|
4
|
+
* The bug: `readFloodState` swallowed EVERY error and returned `null`, so an
|
|
5
|
+
* unreadable marker (EACCES) was indistinguishable from "no marker" — the
|
|
6
|
+
* probe reported "no flood window" forever and every flood protection we ship
|
|
7
|
+
* (#2923 boot-card suppression, #3084/#3094 pre-call short-circuit, #3097
|
|
8
|
+
* typing suppression) failed open SILENTLY. A restart would then post a boot
|
|
9
|
+
* card straight into an open ban.
|
|
10
|
+
*
|
|
11
|
+
* Two behaviours pull in OPPOSITE directions and both are pinned here:
|
|
12
|
+
*
|
|
13
|
+
* - ABSENT / CORRUPT marker → still fails OPEN everywhere. A junk state file
|
|
14
|
+
* must never become a silent gag (#3094's deliberate posture).
|
|
15
|
+
* - UNREADABLE marker → the breaker is BLIND. Essential sends still proceed
|
|
16
|
+
* (fail-open, so a chmod accident can never mute the agent), but it says so
|
|
17
|
+
* LOUDLY, and non-essential sends (boot card, typing) are held back rather
|
|
18
|
+
* than fired into a ban we cannot rule out.
|
|
19
|
+
*
|
|
20
|
+
* All paths are under `mkdtempSync(join(tmpdir(), …))` — no production state.
|
|
21
|
+
*/
|
|
22
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
23
|
+
import {
|
|
24
|
+
mkdtempSync,
|
|
25
|
+
rmSync,
|
|
26
|
+
writeFileSync,
|
|
27
|
+
readFileSync,
|
|
28
|
+
chmodSync,
|
|
29
|
+
mkdirSync,
|
|
30
|
+
statSync,
|
|
31
|
+
} from 'node:fs'
|
|
32
|
+
import { tmpdir } from 'node:os'
|
|
33
|
+
import { join } from 'node:path'
|
|
34
|
+
import {
|
|
35
|
+
computeFloodWait,
|
|
36
|
+
writeFloodState,
|
|
37
|
+
readFloodStateResult,
|
|
38
|
+
makeFloodWaitProbe,
|
|
39
|
+
makeFloodWaitRecorder,
|
|
40
|
+
suppressNonEssentialSendMs,
|
|
41
|
+
nonEssentialSendSuppression,
|
|
42
|
+
resetFloodBlindLogThrottle,
|
|
43
|
+
floodStatePath,
|
|
44
|
+
FLOOD_STATE_MODE,
|
|
45
|
+
} from '../flood-circuit-breaker.js'
|
|
46
|
+
|
|
47
|
+
const NOW = 1_800_000_000_000
|
|
48
|
+
const isRoot = typeof process.getuid === 'function' && process.getuid() === 0
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Make `path` genuinely unreadable to THIS process and prove it. Returns false
|
|
52
|
+
* when the process is root (root bypasses DAC — EACCES is unreachable), so the
|
|
53
|
+
* caller can skip rather than assert a lie.
|
|
54
|
+
*/
|
|
55
|
+
function makeUnreadable(path: string): boolean {
|
|
56
|
+
writeFileSync(path, JSON.stringify({ untilTs: NOW + 60_000, retryAfterSec: 60, recordedTs: NOW }))
|
|
57
|
+
chmodSync(path, 0o000)
|
|
58
|
+
if (isRoot) return false
|
|
59
|
+
try {
|
|
60
|
+
readFileSync(path, 'utf-8')
|
|
61
|
+
return false // somehow still readable — don't pretend we tested EACCES
|
|
62
|
+
} catch {
|
|
63
|
+
return true
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('#3106 flood breaker — blind is not clear', () => {
|
|
68
|
+
let dir: string
|
|
69
|
+
let path: string
|
|
70
|
+
let logs: string[]
|
|
71
|
+
const log = (l: string) => {
|
|
72
|
+
logs.push(l)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
beforeEach(() => {
|
|
76
|
+
dir = mkdtempSync(join(tmpdir(), 'flood-blind-'))
|
|
77
|
+
path = floodStatePath(dir)
|
|
78
|
+
logs = []
|
|
79
|
+
resetFloodBlindLogThrottle()
|
|
80
|
+
})
|
|
81
|
+
afterEach(() => {
|
|
82
|
+
try {
|
|
83
|
+
chmodSync(path, 0o644)
|
|
84
|
+
} catch {
|
|
85
|
+
/* may not exist */
|
|
86
|
+
}
|
|
87
|
+
rmSync(dir, { recursive: true, force: true })
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------- classify
|
|
91
|
+
|
|
92
|
+
it('classifies absent / corrupt / unreadable as DIFFERENT things', () => {
|
|
93
|
+
expect(readFloodStateResult(path).status).toBe('absent')
|
|
94
|
+
|
|
95
|
+
writeFileSync(path, 'not json at all{{{')
|
|
96
|
+
expect(readFloodStateResult(path).status).toBe('corrupt')
|
|
97
|
+
|
|
98
|
+
writeFileSync(path, JSON.stringify({ untilTs: 'soon' }))
|
|
99
|
+
expect(readFloodStateResult(path).status).toBe('corrupt')
|
|
100
|
+
|
|
101
|
+
// A directory where the marker should be: readable-check fails with EISDIR,
|
|
102
|
+
// not ENOENT. uid-independent (works even under root) — "cannot read".
|
|
103
|
+
const d = join(dir, 'as-a-dir.json')
|
|
104
|
+
mkdirSync(d)
|
|
105
|
+
expect(readFloodStateResult(d).status).toBe('unreadable')
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
// ------------------------------------------------- UNREADABLE → not silent
|
|
109
|
+
|
|
110
|
+
it.skipIf(isRoot)(
|
|
111
|
+
'REGRESSION: an unreadable marker does NOT silently report "no flood window"',
|
|
112
|
+
() => {
|
|
113
|
+
expect(makeUnreadable(path)).toBe(true)
|
|
114
|
+
|
|
115
|
+
const res = readFloodStateResult(path)
|
|
116
|
+
// The bug was: status indistinguishable from absent, state null, nobody told.
|
|
117
|
+
expect(res.status).toBe('unreadable')
|
|
118
|
+
expect(res.error).toMatch(/EACCES/)
|
|
119
|
+
|
|
120
|
+
// The probe still FAILS OPEN (0 = proceed) — an essential reply to the
|
|
121
|
+
// user must never be gagged by a permissions error...
|
|
122
|
+
const probe = makeFloodWaitProbe(path, () => NOW, log)
|
|
123
|
+
expect(probe()).toBe(0)
|
|
124
|
+
|
|
125
|
+
// ...but it is no longer SILENT. That is the whole fix.
|
|
126
|
+
expect(logs.join('')).toMatch(/BLIND/)
|
|
127
|
+
expect(logs.join('')).toContain(path)
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
it.skipIf(isRoot)('an unreadable marker SUPPRESSES non-essential sends (boot card, typing)', () => {
|
|
132
|
+
expect(makeUnreadable(path)).toBe(true)
|
|
133
|
+
|
|
134
|
+
// The #2923/#3097 surfaces: we cannot rule out an open ban, so we do not
|
|
135
|
+
// post a restart card / typing ping into one.
|
|
136
|
+
expect(suppressNonEssentialSendMs(path, NOW, log)).toBeGreaterThan(0)
|
|
137
|
+
expect(logs.join('')).toMatch(/BLIND/)
|
|
138
|
+
|
|
139
|
+
const s = nonEssentialSendSuppression(path, NOW)
|
|
140
|
+
expect(s.suppress).toBe(true)
|
|
141
|
+
expect(s.suppress && s.reason).toBe('blind')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it.skipIf(isRoot)('the BLIND warning is throttled — a per-call probe cannot spam the log', () => {
|
|
145
|
+
expect(makeUnreadable(path)).toBe(true)
|
|
146
|
+
let t = NOW
|
|
147
|
+
const probe = makeFloodWaitProbe(path, () => t, log)
|
|
148
|
+
for (let i = 0; i < 50; i++) probe()
|
|
149
|
+
expect(logs.length).toBe(1)
|
|
150
|
+
t = NOW + 61_000
|
|
151
|
+
probe()
|
|
152
|
+
expect(logs.length).toBe(2)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
// --------------------------------------------- ABSENT / CORRUPT → fail OPEN
|
|
156
|
+
|
|
157
|
+
it('an ABSENT marker fails OPEN and stays silent (no ban, nothing to say)', () => {
|
|
158
|
+
const probe = makeFloodWaitProbe(path, () => NOW, log)
|
|
159
|
+
expect(probe()).toBe(0)
|
|
160
|
+
expect(suppressNonEssentialSendMs(path, NOW, log)).toBe(0)
|
|
161
|
+
expect(nonEssentialSendSuppression(path, NOW).suppress).toBe(false)
|
|
162
|
+
expect(logs.join('')).not.toMatch(/BLIND/)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('a CORRUPT marker still fails OPEN — a junk file must never gag the bot', () => {
|
|
166
|
+
for (const junk of ['', 'null', '{}', 'not json{{', JSON.stringify({ untilTs: 'x' })]) {
|
|
167
|
+
writeFileSync(path, junk)
|
|
168
|
+
const probe = makeFloodWaitProbe(path, () => NOW, log)
|
|
169
|
+
expect(probe()).toBe(0)
|
|
170
|
+
// Non-essential sends proceed too: corrupt is a CONTENT problem, and we
|
|
171
|
+
// deliberately do NOT let it suppress. (Contrast: unreadable, above.)
|
|
172
|
+
expect(suppressNonEssentialSendMs(path, NOW, log)).toBe(0)
|
|
173
|
+
expect(nonEssentialSendSuppression(path, NOW).suppress).toBe(false)
|
|
174
|
+
}
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('a REAL open window still suppresses (the #2923 behaviour is intact)', () => {
|
|
178
|
+
writeFloodState(path, computeFloodWait(null, 68 * 60, NOW), log)
|
|
179
|
+
const probe = makeFloodWaitProbe(path, () => NOW, log)
|
|
180
|
+
expect(probe()).toBe(68 * 60 * 1000)
|
|
181
|
+
const s = nonEssentialSendSuppression(path, NOW)
|
|
182
|
+
expect(s.suppress && s.reason).toBe('flood_wait')
|
|
183
|
+
// ...and lifts on its own.
|
|
184
|
+
expect(makeFloodWaitProbe(path, () => NOW + 68 * 60_000 + 1, log)()).toBe(0)
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
// ------------------------------------------------------- root cause: 0600
|
|
188
|
+
|
|
189
|
+
it('ROOT CAUSE: the marker is written 0644, readable by a sibling uid', () => {
|
|
190
|
+
writeFloodState(path, computeFloodWait(null, 60, NOW), log)
|
|
191
|
+
expect(statSync(path).mode & 0o777).toBe(FLOOD_STATE_MODE)
|
|
192
|
+
// Group/other read bits set — the whole point. At 0600 a marker created by
|
|
193
|
+
// a root gateway (`compose.ts:1967`, `root: true` agents) is unreadable to
|
|
194
|
+
// the same agent's non-root gateway forever.
|
|
195
|
+
expect(statSync(path).mode & 0o044).toBe(0o044)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
it('SELF-HEAL: a pre-existing 0600 marker is chmodded back to 0644 on write', () => {
|
|
199
|
+
// Simulate the live overlord marker: created 0600 by an earlier build.
|
|
200
|
+
writeFileSync(path, JSON.stringify({ untilTs: NOW, retryAfterSec: 1, recordedTs: NOW }), {
|
|
201
|
+
mode: 0o600,
|
|
202
|
+
})
|
|
203
|
+
chmodSync(path, 0o600)
|
|
204
|
+
expect(statSync(path).mode & 0o777).toBe(0o600)
|
|
205
|
+
|
|
206
|
+
// The next observed 429 rewrites it — and heals the mode. (writeFileSync's
|
|
207
|
+
// `mode` option is ignored for an EXISTING file, so the explicit chmod is
|
|
208
|
+
// load-bearing.)
|
|
209
|
+
makeFloodWaitRecorder(path, () => NOW, log)(120)
|
|
210
|
+
expect(statSync(path).mode & 0o777).toBe(FLOOD_STATE_MODE)
|
|
211
|
+
expect(readFloodStateResult(path).status).toBe('ok')
|
|
212
|
+
})
|
|
213
|
+
})
|