switchroom 0.19.16 → 0.19.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/run-hook.sh +148 -0
- package/bin/workspace-dynamic-hook.sh +147 -38
- package/dist/agent-scheduler/index.js +11 -3
- package/dist/auth-broker/index.js +29 -4
- package/dist/cli/notion-write-pretool.mjs +11 -3
- package/dist/cli/switchroom.js +8307 -7620
- package/dist/host-control/main.js +626 -36
- package/dist/vault/approvals/kernel-server.js +30 -5
- package/dist/vault/broker/server.js +71 -18
- package/package.json +3 -2
- package/profiles/_base/start.sh.hbs +8 -4
- package/profiles/coding/CLAUDE.md.hbs +1 -1
- package/profiles/default/CLAUDE.md.hbs +3 -3
- package/profiles/executive-assistant/CLAUDE.md.hbs +1 -1
- package/profiles/health-coach/CLAUDE.md.hbs +1 -1
- package/skills/mental-model-curator/SKILL.md +8 -6
- package/telegram-plugin/bridge/bridge.ts +11 -19
- package/telegram-plugin/bridge/mcp-instructions.ts +87 -0
- package/telegram-plugin/dist/bridge/bridge.js +15 -20
- package/telegram-plugin/dist/gateway/gateway.js +763 -373
- package/telegram-plugin/dist/server.js +19 -20
- package/telegram-plugin/gateway/boot-card.ts +5 -1
- package/telegram-plugin/gateway/boot-probes.ts +113 -0
- package/telegram-plugin/gateway/config-approval-handler.test.ts +54 -0
- package/telegram-plugin/gateway/config-approval-handler.ts +16 -1
- package/telegram-plugin/gateway/disconnect-flush.ts +17 -0
- package/telegram-plugin/gateway/gateway.ts +43 -1
- package/telegram-plugin/gateway/handback-preturn-signal.ts +61 -7
- package/telegram-plugin/gateway/ipc-protocol.ts +5 -0
- package/telegram-plugin/gateway/ipc-server.ts +13 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +125 -5
- package/telegram-plugin/gateway/obligation-ledger.ts +84 -4
- package/telegram-plugin/gateway/resume-inbound-builder.ts +13 -4
- package/telegram-plugin/gateway/stream-render.ts +24 -5
- package/telegram-plugin/hooks/secret-guard-pretool.mjs +249 -76
- package/telegram-plugin/registry/turns-schema.test.ts +8 -3
- package/telegram-plugin/registry/turns-schema.ts +40 -12
- package/telegram-plugin/runtime-metrics.ts +14 -0
- package/telegram-plugin/silence-poke.ts +138 -0
- package/telegram-plugin/tests/boot-probe-drift.test.ts +152 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +32 -0
- package/telegram-plugin/tests/handback-preturn-signal.test.ts +62 -0
- package/telegram-plugin/tests/helpers/liveness-wiring-fixture.ts +178 -0
- package/telegram-plugin/tests/ipc-server-validate-config-approval.test.ts +95 -0
- package/telegram-plugin/tests/mcp-instructions-budget.test.ts +184 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +22 -2
- package/telegram-plugin/tests/obligation-determinism.test.ts +114 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +310 -0
- package/telegram-plugin/tests/registry-turns.test.ts +13 -0
- package/telegram-plugin/tests/resume-inbound-builder.test.ts +15 -0
- package/telegram-plugin/tests/secret-guard-pretool.test.ts +347 -16
- package/telegram-plugin/tests/silence-poke-orphan-reap.test.ts +392 -0
- package/telegram-plugin/tests/silence-poke-teardown-notice.test.ts +301 -0
- package/telegram-plugin/tests/stream-render-golden.test.ts +103 -1
- package/telegram-plugin/tests/tts-normalize.test.ts +43 -0
- package/telegram-plugin/tests/voice-normalize-text.test.ts +212 -3
- package/telegram-plugin/tts-normalize.ts +6 -4
- package/telegram-plugin/voice-normalize-text.ts +168 -11
- package/vendor/hindsight-memory/CHANGELOG.md +73 -0
- package/vendor/hindsight-memory/scripts/lib/config.py +8 -3
- package/vendor/hindsight-memory/scripts/lib/directives.py +62 -4
- package/vendor/hindsight-memory/scripts/recall.py +257 -12
- package/vendor/hindsight-memory/scripts/retain.py +12 -6
- package/vendor/hindsight-memory/scripts/tests/test_directives.py +80 -9
- package/vendor/hindsight-memory/scripts/tests/test_recall_integration.py +362 -18
- package/vendor/hindsight-memory/settings.json +1 -1
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
|
19
19
|
import { spawn, spawnSync } from 'node:child_process'
|
|
20
|
-
import { mkdtempSync, rmSync, unlinkSync, existsSync } from 'node:fs'
|
|
20
|
+
import { mkdtempSync, rmSync, unlinkSync, existsSync, writeFileSync, chmodSync } from 'node:fs'
|
|
21
21
|
import { tmpdir } from 'node:os'
|
|
22
22
|
import { join, resolve } from 'node:path'
|
|
23
23
|
import { createServer, type Server, type Socket } from 'node:net'
|
|
@@ -28,20 +28,55 @@ interface FakeBroker {
|
|
|
28
28
|
socketPath: string
|
|
29
29
|
stop: () => Promise<void>
|
|
30
30
|
connectionCount: number
|
|
31
|
+
/** High-water mark of `get` requests being serviced simultaneously. */
|
|
32
|
+
maxConcurrentGets: number
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
/**
|
|
34
36
|
* Stand up a minimal NDJSON broker. Responds to `list` with the supplied
|
|
35
37
|
* keys, and to `get` requests with the entry shape Telegram-plugin expects.
|
|
36
38
|
*/
|
|
37
|
-
function startFakeBroker(
|
|
39
|
+
function startFakeBroker(
|
|
40
|
+
values: Record<string, string>,
|
|
41
|
+
opts: {
|
|
42
|
+
/** Artificial per-request service delay, ms, implemented with
|
|
43
|
+
* setTimeout. This models NETWORK/IO latency only — it does NOT occupy
|
|
44
|
+
* the broker's event loop, so it makes client-side fan-out look like a
|
|
45
|
+
* pure win. Use `cpuMs` for the honest model of the real broker. */
|
|
46
|
+
delayMs?: number
|
|
47
|
+
/** Artificial per-request SYNCHRONOUS CPU cost, ms. This is what the
|
|
48
|
+
* real broker actually does: every tokened request awaits a
|
|
49
|
+
* `bcryptjs.compare` (src/vault/grants.ts, BCRYPT_COST=10), and
|
|
50
|
+
* bcryptjs is pure JS that blocks the event loop — measured ~57ms per
|
|
51
|
+
* compare, with 8 "concurrent" compares taking 459ms (fully
|
|
52
|
+
* serialized). A busy-wait reproduces that; a setTimeout does not. */
|
|
53
|
+
cpuMs?: number
|
|
54
|
+
/** Keys whose `get` should return DENIED instead of a value. */
|
|
55
|
+
denyKeys?: string[]
|
|
56
|
+
/** Keys whose `get` should never be answered at all (socket left open). */
|
|
57
|
+
hangKeys?: string[]
|
|
58
|
+
} = {},
|
|
59
|
+
): Promise<FakeBroker> {
|
|
38
60
|
return new Promise((resolveStart) => {
|
|
39
61
|
const dir = mkdtempSync(join(tmpdir(), 'fake-broker-'))
|
|
40
62
|
const socketPath = join(dir, 'broker.sock')
|
|
63
|
+
const delayMs = opts.delayMs ?? 0
|
|
64
|
+
const cpuMs = opts.cpuMs ?? 0
|
|
65
|
+
/** Block the event loop for `cpuMs`, the way bcryptjs.compare does. */
|
|
66
|
+
const burnCpu = () => {
|
|
67
|
+
if (cpuMs <= 0) return
|
|
68
|
+
const until = Date.now() + cpuMs
|
|
69
|
+
while (Date.now() < until) { /* deliberate busy-wait */ }
|
|
70
|
+
}
|
|
71
|
+
const denyKeys = new Set(opts.denyKeys ?? [])
|
|
72
|
+
const hangKeys = new Set(opts.hangKeys ?? [])
|
|
41
73
|
let connectionCount = 0
|
|
74
|
+
let inFlightGets = 0
|
|
75
|
+
let maxConcurrentGets = 0
|
|
42
76
|
const server: Server = createServer((sock: Socket) => {
|
|
43
77
|
connectionCount++
|
|
44
78
|
let buf = ''
|
|
79
|
+
sock.on('error', () => { /* client destroys sockets after one turn */ })
|
|
45
80
|
sock.on('data', (chunk) => {
|
|
46
81
|
buf += chunk.toString('utf8')
|
|
47
82
|
let idx
|
|
@@ -50,14 +85,32 @@ function startFakeBroker(values: Record<string, string>): Promise<FakeBroker> {
|
|
|
50
85
|
buf = buf.slice(idx + 1)
|
|
51
86
|
let req
|
|
52
87
|
try { req = JSON.parse(line) } catch { continue }
|
|
88
|
+
const isGet = req?.op === 'get'
|
|
89
|
+
if (isGet) {
|
|
90
|
+
inFlightGets++
|
|
91
|
+
if (inFlightGets > maxConcurrentGets) maxConcurrentGets = inFlightGets
|
|
92
|
+
}
|
|
93
|
+
// The real broker awaits validateGrant -> bcryptjs.compare BEFORE
|
|
94
|
+
// it can reply, and that call blocks its single event loop. Burn
|
|
95
|
+
// here, synchronously, for the same reason and at the same point.
|
|
96
|
+
burnCpu()
|
|
97
|
+
const reply = (obj: unknown) => {
|
|
98
|
+
setTimeout(() => {
|
|
99
|
+
if (isGet) inFlightGets--
|
|
100
|
+
try { sock.write(JSON.stringify(obj) + '\n') } catch { /* closed */ }
|
|
101
|
+
}, delayMs)
|
|
102
|
+
}
|
|
53
103
|
if (req?.op === 'list') {
|
|
54
|
-
|
|
55
|
-
} else if (
|
|
104
|
+
reply({ ok: true, keys: Object.keys(values) })
|
|
105
|
+
} else if (isGet && typeof req.key === 'string') {
|
|
106
|
+
if (hangKeys.has(req.key)) continue // never reply, never decrement
|
|
56
107
|
const v = values[req.key]
|
|
57
|
-
if (
|
|
58
|
-
|
|
108
|
+
if (denyKeys.has(req.key)) {
|
|
109
|
+
reply({ ok: false, code: 'DENIED', msg: req.key })
|
|
110
|
+
} else if (v !== undefined) {
|
|
111
|
+
reply({ ok: true, entry: { kind: 'string', value: v } })
|
|
59
112
|
} else {
|
|
60
|
-
|
|
113
|
+
reply({ ok: false, code: 'UNKNOWN_KEY', msg: req.key })
|
|
61
114
|
}
|
|
62
115
|
}
|
|
63
116
|
}
|
|
@@ -67,6 +120,7 @@ function startFakeBroker(values: Record<string, string>): Promise<FakeBroker> {
|
|
|
67
120
|
resolveStart({
|
|
68
121
|
socketPath,
|
|
69
122
|
get connectionCount() { return connectionCount },
|
|
123
|
+
get maxConcurrentGets() { return maxConcurrentGets },
|
|
70
124
|
stop: () => new Promise<void>((stopResolve) => {
|
|
71
125
|
server.close(() => {
|
|
72
126
|
try { rmSync(dir, { recursive: true, force: true }) } catch { /* best-effort */ }
|
|
@@ -87,9 +141,12 @@ function startFakeBroker(values: Record<string, string>): Promise<FakeBroker> {
|
|
|
87
141
|
function runHook(opts: {
|
|
88
142
|
toolInput: unknown
|
|
89
143
|
brokerSocket?: string | null
|
|
90
|
-
|
|
144
|
+
/** Prepended to the child's PATH — used to plant a `switchroom` shim. */
|
|
145
|
+
pathPrefix?: string
|
|
146
|
+
}): Promise<{ stdout: string; stderr: string; status: number; elapsedMs: number }> {
|
|
147
|
+
const basePath = process.env.PATH ?? ''
|
|
91
148
|
const env: Record<string, string> = {
|
|
92
|
-
PATH:
|
|
149
|
+
PATH: opts.pathPrefix ? `${opts.pathPrefix}:${basePath}` : basePath,
|
|
93
150
|
NODE_PATH: process.env.NODE_PATH ?? '',
|
|
94
151
|
HOME: process.env.HOME ?? '',
|
|
95
152
|
}
|
|
@@ -102,18 +159,60 @@ function runHook(opts: {
|
|
|
102
159
|
tool_input: opts.toolInput,
|
|
103
160
|
})
|
|
104
161
|
return new Promise((resolveRun) => {
|
|
162
|
+
const t0 = Date.now()
|
|
105
163
|
const child = spawn('node', [HOOK_PATH], { env })
|
|
106
164
|
let stdout = ''
|
|
107
165
|
let stderr = ''
|
|
108
166
|
child.stdout.on('data', (d) => { stdout += d.toString() })
|
|
109
167
|
child.stderr.on('data', (d) => { stderr += d.toString() })
|
|
110
168
|
child.on('close', (status) => {
|
|
111
|
-
resolveRun({ stdout, stderr, status: status ?? 1 })
|
|
169
|
+
resolveRun({ stdout, stderr, status: status ?? 1, elapsedMs: Date.now() - t0 })
|
|
112
170
|
})
|
|
113
171
|
child.stdin.end(stdinJson)
|
|
114
172
|
})
|
|
115
173
|
}
|
|
116
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Plant an executable `switchroom` on a fresh directory that records every
|
|
177
|
+
* invocation by touching a sentinel file, then returns a plausible-looking
|
|
178
|
+
* success so a forking implementation would appear to work (and therefore
|
|
179
|
+
* would NOT be caught by the block/allow assertions alone).
|
|
180
|
+
*
|
|
181
|
+
* Returns the directory to prepend to PATH and the sentinel path.
|
|
182
|
+
*/
|
|
183
|
+
function plantSwitchroomShim(): { dir: string; sentinel: string } {
|
|
184
|
+
// NOT under os.tmpdir(): /tmp is mounted `noexec` in the agent containers
|
|
185
|
+
// this suite runs in (verified 2026-07-25 — `findmnt -no OPTIONS /tmp` =>
|
|
186
|
+
// `rw,nosuid,nodev,noexec`). A shim planted there is silently unrunnable,
|
|
187
|
+
// which would make the no-fork assertion below vacuously true — the exact
|
|
188
|
+
// class of bug this test exists to close. Plant it beside the test file,
|
|
189
|
+
// on the repo filesystem, which is executable.
|
|
190
|
+
const dir = mkdtempSync(join(__dirname, '.shim-'))
|
|
191
|
+
const sentinel = join(dir, 'FORKED')
|
|
192
|
+
const shim = join(dir, 'switchroom')
|
|
193
|
+
writeFileSync(shim, `#!/bin/sh\nprintf '%s\\n' "$*" >> "${sentinel}"\nexit 0\n`)
|
|
194
|
+
chmodSync(shim, 0o755)
|
|
195
|
+
|
|
196
|
+
// Self-check: prove the shim is REACHABLE and RECORDS, so "sentinel
|
|
197
|
+
// absent" can only ever mean "the hook did not fork" — never "the shim
|
|
198
|
+
// could not run". Without this the test can go vacuous again the moment
|
|
199
|
+
// it runs on a noexec mount.
|
|
200
|
+
const probe = spawnSync('switchroom', ['self-check'], {
|
|
201
|
+
env: { ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}` },
|
|
202
|
+
})
|
|
203
|
+
if (probe.status !== 0 || !existsSync(sentinel)) {
|
|
204
|
+
rmSync(dir, { recursive: true, force: true })
|
|
205
|
+
throw new Error(
|
|
206
|
+
`no-fork shim is not executable (status=${probe.status}, `
|
|
207
|
+
+ `error=${probe.error?.message ?? 'none'}) — the no-fork assertion `
|
|
208
|
+
+ `would be vacuous. Fix the shim location, do not skip this test.`,
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
unlinkSync(sentinel)
|
|
212
|
+
|
|
213
|
+
return { dir, sentinel }
|
|
214
|
+
}
|
|
215
|
+
|
|
117
216
|
let broker: FakeBroker | null = null
|
|
118
217
|
|
|
119
218
|
afterEach(async () => {
|
|
@@ -160,20 +259,252 @@ describe('secret-guard-pretool.mjs (broker-direct)', () => {
|
|
|
160
259
|
expect(r.stdout).toBe('')
|
|
161
260
|
})
|
|
162
261
|
|
|
163
|
-
it('
|
|
262
|
+
it('issues the per-key gets concurrently, not one at a time (#3543)', async () => {
|
|
263
|
+
// The old implementation awaited each `get` before sending the next, so
|
|
264
|
+
// its cost was 1 + N round trips. The fan-out must have several gets in
|
|
265
|
+
// flight at the same time. This asserts the OUTCOME (overlapping
|
|
266
|
+
// in-flight requests observed by the broker), not that a particular
|
|
267
|
+
// function was called.
|
|
164
268
|
broker = await startFakeBroker({
|
|
165
269
|
'a': 'aaaaaaaa-secret-value-aaaaaaaa',
|
|
166
270
|
'b': 'bbbbbbbb-secret-value-bbbbbbbb',
|
|
167
271
|
'c': 'cccccccc-secret-value-cccccccc',
|
|
168
|
-
|
|
272
|
+
'd': 'dddddddd-secret-value-dddddddd',
|
|
273
|
+
}, { delayMs: 40 })
|
|
169
274
|
await runHook({
|
|
170
275
|
toolInput: { command: 'echo hi' },
|
|
171
276
|
brokerSocket: broker.socketPath,
|
|
172
277
|
})
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
278
|
+
expect(broker.maxConcurrentGets).toBe(4)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it('total cost is ~2 round trips, not 1 + N (#3543)', async () => {
|
|
282
|
+
// Deliberately NOT a wall-clock assertion. The earlier version of this
|
|
283
|
+
// test asserted elapsedMs < 250; measured node cold start for this hook
|
|
284
|
+
// is 104-127ms on an idle box and this suite runs under `bun test` on a
|
|
285
|
+
// 2-vCPU CI runner alongside the rest of telegram-plugin/tests, so a
|
|
286
|
+
// 250ms ceiling had ~65ms of headroom and would have flaked (#3574
|
|
287
|
+
// review F3).
|
|
288
|
+
//
|
|
289
|
+
// `maxConcurrentGets` is the timing-INDEPENDENT discriminator for the
|
|
290
|
+
// same property: the sequential 1 + N implementation could never exceed
|
|
291
|
+
// 1 in flight no matter how fast or slow the box is, while the fan-out
|
|
292
|
+
// has all 8 outstanding at once. Same guarantee, zero clock dependence.
|
|
293
|
+
const values: Record<string, string> = {}
|
|
294
|
+
for (let i = 0; i < 8; i++) values['k' + i] = `secret-value-number-${i}-xxxxxxxx`
|
|
295
|
+
broker = await startFakeBroker(values, { delayMs: 40 })
|
|
296
|
+
const r = await runHook({
|
|
297
|
+
toolInput: { command: 'echo hi' },
|
|
298
|
+
brokerSocket: broker.socketPath,
|
|
299
|
+
})
|
|
300
|
+
expect(r.status).toBe(0)
|
|
301
|
+
expect(broker.maxConcurrentGets).toBe(8)
|
|
302
|
+
// 1 list + 8 gets, one request per connection.
|
|
303
|
+
expect(broker.connectionCount).toBe(9)
|
|
304
|
+
})
|
|
305
|
+
|
|
306
|
+
it('client fan-out does NOT reduce wall time against a CPU-bound broker (#3574)', async () => {
|
|
307
|
+
// The honest counterpart to the test above. The `delayMs` fake sleeps,
|
|
308
|
+
// so it credits the fan-out with a speedup that CANNOT exist against
|
|
309
|
+
// the real broker, whose per-request cost is a synchronous, event-loop-
|
|
310
|
+
// blocking `bcryptjs.compare` inside validateGrant. `cpuMs` reproduces
|
|
311
|
+
// that.
|
|
312
|
+
//
|
|
313
|
+
// Asserted property: with a CPU-bound broker the client's concurrency
|
|
314
|
+
// is irrelevant to wall time — total cost is still ~(N+1) × cpuMs,
|
|
315
|
+
// i.e. STRICTLY MORE than the serialized floor. This is a lower bound
|
|
316
|
+
// (>=), so it cannot flake upward on a loaded runner; it fails only if
|
|
317
|
+
// someone re-introduces the false "concurrency makes it O(1)" model.
|
|
318
|
+
// This is a DIFFERENTIAL, not an absolute budget, for the reason F3
|
|
319
|
+
// flagged: an absolute wall-clock ceiling has to leave room for node
|
|
320
|
+
// cold start (measured 104-127ms for this hook on an idle box) and
|
|
321
|
+
// flakes on a loaded CI runner. Running the SAME hook against both
|
|
322
|
+
// broker models subtracts node startup and every other fixed cost
|
|
323
|
+
// automatically — the baseline is measured, not assumed.
|
|
324
|
+
//
|
|
325
|
+
// sleeping broker (delayMs): fan-out wins => ~startup + 2 × COST
|
|
326
|
+
// CPU-bound broker (cpuMs): fan-out cannot => ~startup + (N+1) × COST
|
|
327
|
+
//
|
|
328
|
+
// so the gap should be ~(N-1) × COST = 160ms. We assert only that it
|
|
329
|
+
// exceeds 2 × COST (80ms): if concurrency helped a CPU-bound broker the
|
|
330
|
+
// way it helps a sleeping one, the gap would be ~0. Load inflates both
|
|
331
|
+
// runs, so the LOWER bound on a difference is what stays stable.
|
|
332
|
+
const N = 5
|
|
333
|
+
const COST_MS = 40
|
|
334
|
+
const values: Record<string, string> = {}
|
|
335
|
+
for (let i = 0; i < N; i++) values['c' + i] = `cpu-secret-value-${i}-xxxxxxxx`
|
|
336
|
+
|
|
337
|
+
const sleepy = await startFakeBroker(values, { delayMs: COST_MS })
|
|
338
|
+
let sleepyMs: number
|
|
339
|
+
try {
|
|
340
|
+
const rs = await runHook({ toolInput: { command: 'echo hi' }, brokerSocket: sleepy.socketPath })
|
|
341
|
+
expect(rs.status).toBe(0)
|
|
342
|
+
sleepyMs = rs.elapsedMs
|
|
343
|
+
} finally {
|
|
344
|
+
await sleepy.stop()
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
broker = await startFakeBroker(values, { cpuMs: COST_MS })
|
|
348
|
+
const r = await runHook({
|
|
349
|
+
toolInput: { command: 'echo hi' },
|
|
350
|
+
brokerSocket: broker.socketPath,
|
|
351
|
+
})
|
|
352
|
+
expect(r.status).toBe(0)
|
|
353
|
+
expect(r.elapsedMs - sleepyMs).toBeGreaterThan(2 * COST_MS)
|
|
354
|
+
// And the guard must still be COMPLETE despite that cost: every key
|
|
355
|
+
// resolved within the deadline, including the last. Partial coverage is
|
|
356
|
+
// the security failure mode this whole area is about.
|
|
357
|
+
expect(broker.connectionCount).toBe(N + 1)
|
|
358
|
+
expect(r.stderr).not.toContain('DEGRADED')
|
|
359
|
+
}, 20_000)
|
|
360
|
+
|
|
361
|
+
it('caps the fan-out at MAX_CONCURRENT_GETS and still guards every key past the cap', async () => {
|
|
362
|
+
// Two properties the fan-out owes us on a vault larger than the cap
|
|
363
|
+
// (MAX_CONCURRENT_GETS = 32), both timing-independent:
|
|
364
|
+
// 1. the cap is real — 40 keys must never put more than 32 gets in
|
|
365
|
+
// flight (an unbounded fan-out would show 40);
|
|
366
|
+
// 2. the batch loop drains the remainder — key #40, which can only be
|
|
367
|
+
// fetched in the second batch, is still guarded.
|
|
368
|
+
const values: Record<string, string> = {}
|
|
369
|
+
for (let i = 0; i < 40; i++) values['k' + i] = `secret-value-number-${i}-xxxxxxxxxx`
|
|
370
|
+
broker = await startFakeBroker(values, { delayMs: 40 })
|
|
371
|
+
const r = await runHook({
|
|
372
|
+
// k39 is the 40th key — past the first batch of 32.
|
|
373
|
+
toolInput: { command: 'echo secret-value-number-39-xxxxxxxxxx' },
|
|
374
|
+
brokerSocket: broker.socketPath,
|
|
375
|
+
})
|
|
376
|
+
expect(r.status).toBe(0)
|
|
377
|
+
expect(r.stdout).toContain('"decision":"block"')
|
|
378
|
+
expect(r.stdout).toContain('k39')
|
|
379
|
+
expect(broker.maxConcurrentGets).toBeLessThanOrEqual(32)
|
|
380
|
+
expect(broker.maxConcurrentGets).toBeGreaterThan(1)
|
|
381
|
+
expect(broker.connectionCount).toBe(41)
|
|
382
|
+
}, 10_000)
|
|
383
|
+
|
|
384
|
+
it('does not fork a child process per key', async () => {
|
|
385
|
+
// The generation-1 shape forked `switchroom vault get` per key. Asserting
|
|
386
|
+
// a connection COUNT cannot prove that — a fork-per-key implementation
|
|
387
|
+
// produces the same 1 + N connections, because each forked CLI opens its
|
|
388
|
+
// own (#3574 review F2). So plant an executable `switchroom` on PATH that
|
|
389
|
+
// records every invocation and assert it was never invoked.
|
|
390
|
+
const { dir, sentinel } = plantSwitchroomShim()
|
|
391
|
+
try {
|
|
392
|
+
broker = await startFakeBroker({
|
|
393
|
+
'a': 'aaaaaaaa-secret-value-aaaaaaaa',
|
|
394
|
+
'b': 'bbbbbbbb-secret-value-bbbbbbbb',
|
|
395
|
+
'c': 'cccccccc-secret-value-cccccccc',
|
|
396
|
+
})
|
|
397
|
+
await runHook({
|
|
398
|
+
toolInput: { command: 'echo hi' },
|
|
399
|
+
brokerSocket: broker.socketPath,
|
|
400
|
+
pathPrefix: dir,
|
|
401
|
+
})
|
|
402
|
+
// The shim is genuinely reachable and would have fired — the hook
|
|
403
|
+
// simply never shells out. (It exits 0 with plausible output, so a
|
|
404
|
+
// forking implementation would still allow/block correctly and could
|
|
405
|
+
// only be caught here.)
|
|
406
|
+
expect(existsSync(sentinel)).toBe(false)
|
|
407
|
+
// Secondary: the hook speaks the socket itself, one request per
|
|
408
|
+
// connection turn (the shape protocol.ts:8-11 documents).
|
|
409
|
+
expect(broker.connectionCount).toBe(4)
|
|
410
|
+
} finally {
|
|
411
|
+
rmSync(dir, { recursive: true, force: true })
|
|
412
|
+
}
|
|
413
|
+
})
|
|
414
|
+
|
|
415
|
+
it('logs a DEGRADED line when a `get` gets no broker response (#3574)', async () => {
|
|
416
|
+
// The fan-out silently drops any key whose `get` never returns, and a
|
|
417
|
+
// dropped key is an unguarded secret. That is exactly what a broker
|
|
418
|
+
// connection cap would cause (this hook needs N+1 connections where the
|
|
419
|
+
// sequential version needed 1). Prose in a comment can't catch it, so
|
|
420
|
+
// the hook must leave a deterministic trace on stderr.
|
|
421
|
+
broker = await startFakeBroker({
|
|
422
|
+
'hung-key': 'hung-secret-value-aaaaaaaa',
|
|
423
|
+
'live-key': 'live-secret-value-bbbbbbbb',
|
|
424
|
+
}, { hangKeys: ['hung-key'] })
|
|
425
|
+
const r = await runHook({
|
|
426
|
+
toolInput: { command: 'echo hi' },
|
|
427
|
+
brokerSocket: broker.socketPath,
|
|
428
|
+
})
|
|
429
|
+
expect(r.status).toBe(0)
|
|
430
|
+
expect(r.stderr).toContain('DEGRADED')
|
|
431
|
+
expect(r.stderr).toContain('1/2')
|
|
432
|
+
// Decision channel stays clean — the warning must not leak to stdout,
|
|
433
|
+
// where Claude Code parses the block decision.
|
|
434
|
+
expect(r.stdout).toBe('')
|
|
435
|
+
}, 10_000)
|
|
436
|
+
|
|
437
|
+
it('does not log DEGRADED when every key resolves', async () => {
|
|
438
|
+
// Guards the inverse: a DENIED reply is a broker ANSWER, not an
|
|
439
|
+
// unreachable connection, so it must not raise the degradation signal
|
|
440
|
+
// (otherwise the line is noise and gets ignored).
|
|
441
|
+
broker = await startFakeBroker({
|
|
442
|
+
'denied-key': 'denied-secret-value-aaaaaaaa',
|
|
443
|
+
'live-key': 'live-secret-value-bbbbbbbb',
|
|
444
|
+
}, { denyKeys: ['denied-key'] })
|
|
445
|
+
const r = await runHook({
|
|
446
|
+
toolInput: { command: 'echo hi' },
|
|
447
|
+
brokerSocket: broker.socketPath,
|
|
448
|
+
})
|
|
449
|
+
expect(r.status).toBe(0)
|
|
450
|
+
expect(r.stderr).not.toContain('DEGRADED')
|
|
451
|
+
})
|
|
452
|
+
|
|
453
|
+
it('still blocks on the other keys when one key is DENIED', async () => {
|
|
454
|
+
// Security posture: a per-key failure must not disable the guard for
|
|
455
|
+
// the keys that DID resolve. This is the case the sequential version
|
|
456
|
+
// handled with `continue`; the fan-out must preserve it.
|
|
457
|
+
broker = await startFakeBroker({
|
|
458
|
+
'denied-key': 'denied-secret-value-aaaaaaaa',
|
|
459
|
+
'live-key': 'live-secret-value-bbbbbbbb',
|
|
460
|
+
}, { denyKeys: ['denied-key'] })
|
|
461
|
+
const r = await runHook({
|
|
462
|
+
toolInput: { command: 'echo live-secret-value-bbbbbbbb' },
|
|
463
|
+
brokerSocket: broker.socketPath,
|
|
464
|
+
})
|
|
465
|
+
expect(r.status).toBe(0)
|
|
466
|
+
expect(r.stdout).toContain('"decision":"block"')
|
|
467
|
+
expect(r.stdout).toContain('live-key')
|
|
468
|
+
})
|
|
469
|
+
|
|
470
|
+
it('still blocks on resolved keys when another key never responds', async () => {
|
|
471
|
+
// A hung `get` used to stall the whole sequential loop until the 1500ms
|
|
472
|
+
// deadline and then discard EVERY value (fail open). Now the other keys
|
|
473
|
+
// resolve independently and are still guarded.
|
|
474
|
+
broker = await startFakeBroker({
|
|
475
|
+
'hung-key': 'hung-secret-value-aaaaaaaa',
|
|
476
|
+
'live-key': 'live-secret-value-bbbbbbbb',
|
|
477
|
+
}, { hangKeys: ['hung-key'] })
|
|
478
|
+
const r = await runHook({
|
|
479
|
+
toolInput: { command: 'echo live-secret-value-bbbbbbbb' },
|
|
480
|
+
brokerSocket: broker.socketPath,
|
|
481
|
+
})
|
|
482
|
+
expect(r.status).toBe(0)
|
|
483
|
+
expect(r.stdout).toContain('"decision":"block"')
|
|
484
|
+
expect(r.stdout).toContain('live-key')
|
|
485
|
+
}, 10_000)
|
|
486
|
+
|
|
487
|
+
it('fails open when the broker denies `list`', async () => {
|
|
488
|
+
// No key set is knowable => nothing to guard against => allow. Same as
|
|
489
|
+
// the sequential version; asserted so a refactor cannot silently turn
|
|
490
|
+
// this into a hard block that wedges every session.
|
|
491
|
+
const dir = mkdtempSync(join(tmpdir(), 'deny-broker-'))
|
|
492
|
+
const socketPath = join(dir, 'broker.sock')
|
|
493
|
+
const server = createServer((sock: Socket) => {
|
|
494
|
+
sock.on('error', () => {})
|
|
495
|
+
sock.on('data', () => {
|
|
496
|
+
sock.write(JSON.stringify({ ok: false, code: 'LOCKED', msg: 'locked' }) + '\n')
|
|
497
|
+
})
|
|
498
|
+
})
|
|
499
|
+
await new Promise<void>((r) => server.listen(socketPath, () => r()))
|
|
500
|
+
try {
|
|
501
|
+
const res = await runHook({ toolInput: { command: 'echo hi' }, brokerSocket: socketPath })
|
|
502
|
+
expect(res.status).toBe(0)
|
|
503
|
+
expect(res.stdout).toBe('')
|
|
504
|
+
} finally {
|
|
505
|
+
await new Promise<void>((r) => server.close(() => r()))
|
|
506
|
+
rmSync(dir, { recursive: true, force: true })
|
|
507
|
+
}
|
|
177
508
|
})
|
|
178
509
|
|
|
179
510
|
it('skips values shorter than the minimum guard length', async () => {
|