switchroom 0.19.18 → 0.19.19

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 (38) hide show
  1. package/dist/agent-scheduler/index.js +2 -1
  2. package/dist/auth-broker/index.js +3 -1
  3. package/dist/cli/drive-write-pretool.mjs +48 -5
  4. package/dist/cli/ms-365-write-pretool.mjs +40 -2
  5. package/dist/cli/notion-write-pretool.mjs +2 -1
  6. package/dist/cli/switchroom.js +3392 -1569
  7. package/dist/host-control/main.js +12209 -11396
  8. package/dist/vault/approvals/kernel-server.js +60 -7
  9. package/dist/vault/broker/server.js +206 -76
  10. package/package.json +4 -3
  11. package/profiles/_base/start.sh.hbs +61 -1
  12. package/telegram-plugin/bridge/bridge.ts +14 -0
  13. package/telegram-plugin/dist/bridge/bridge.js +13 -0
  14. package/telegram-plugin/dist/gateway/gateway.js +1644 -1044
  15. package/telegram-plugin/dist/server.js +13 -0
  16. package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
  17. package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
  18. package/telegram-plugin/gateway/pending-card-store.ts +46 -16
  19. package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
  20. package/telegram-plugin/gateway/store-file.ts +244 -0
  21. package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
  22. package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
  23. package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
  24. package/telegram-plugin/tests/tool-activity-summary.test.ts +9 -2
  25. package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
  26. package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
  27. package/telegram-plugin/worker-activity-feed.ts +51 -1
  28. package/vendor/hindsight-memory/scripts/drain_pending.py +668 -56
  29. package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
  30. package/vendor/hindsight-memory/scripts/lib/pending.py +865 -33
  31. package/vendor/hindsight-memory/scripts/lib/retain_split.py +449 -0
  32. package/vendor/hindsight-memory/scripts/session_start.py +48 -0
  33. package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
  34. package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2121 -0
  35. package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +430 -0
  36. package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
  37. package/vendor/hindsight-memory/tests/test_drain_pending.py +102 -6
  38. package/vendor/hindsight-memory/tests/test_pending.py +32 -7
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Shared read side for the gateway's small JSON state stores
3
+ * (`pending-card-store.ts`, `scoped-grant-store.ts`,
4
+ * `missed-approvals-store.ts`, `always-allow-persist-queue.ts`).
5
+ *
6
+ * Problem: each of those stores read its backing file with a bare
7
+ * `try { JSON.parse(readFileSync(...)) } catch { return [] }`. Combined
8
+ * with a non-atomic `writeFileSync` straight over the destination (fixed
9
+ * separately — every writer now goes through `atomicWriteFileSync`), a
10
+ * crash between truncate and write left a TORN file. On the next boot the
11
+ * parse threw, the catch swallowed it, and the store came up EMPTY: every
12
+ * pending approval card and every live scoped grant silently forgotten,
13
+ * with nothing in the logs. The operator just saw dead buttons.
14
+ *
15
+ * Losing security state must be OBSERVABLE. So a parse failure here is
16
+ * never silent:
17
+ * - the corrupt bytes are preserved by renaming them aside to
18
+ * `<file>.corrupt-<epoch-ms>-<seq>-<rand>` (never deleted while they are the
19
+ * newest few — they're the forensic record of what was lost, and the
20
+ * only chance of manual recovery);
21
+ * - a loud `telegram gateway: <store> CORRUPT …` line goes to stderr,
22
+ * the same channel the surrounding stores already log write failures
23
+ * on (the gateway's stderr is captured in the agent's runtime log);
24
+ * - the caller gets a non-`ok` result and starts from an empty store —
25
+ * the process still boots, it just no longer does so in silence.
26
+ *
27
+ * Three read outcomes, deliberately distinguished:
28
+ * - `missing` — the normal cold start. Silent, no quarantine.
29
+ * - `corrupt` — unparseable JSON (or a shape the store rejects, via
30
+ * `quarantineCorruptStoreFile`). Quarantine + loud log.
31
+ * - `unreadable` — any other fs error (EACCES, EIO, EISDIR, …). Loud log,
32
+ * but NO quarantine: renaming a file we merely failed to
33
+ * read would destroy good state over a transient fault.
34
+ * Instead the caller FAILS CLOSED on its next write —
35
+ * see `preserveUnreadableStoreFile` below.
36
+ *
37
+ * ── Known hazard: two writers ──────────────────────────────────────────
38
+ * Quarantine is a destructive-looking move (it renames the destination
39
+ * away), and it is NOT safe against a second process writing the same
40
+ * file: process A can parse-fail on a torn file, process B can then
41
+ * atomically rename GOOD state into place, and A's quarantine would move
42
+ * B's good file aside. Pre-fix a parse failure was inert, so this hazard is
43
+ * introduced here. It is accepted because the gateway is (near-)singleton
44
+ * per agent — see the `withLock` note in `always-allow-persist-queue.ts`
45
+ * for why "near", and note the window is the few microseconds between the
46
+ * failed parse and the rename. If a genuine multi-writer arrangement ever
47
+ * appears, quarantine must become an flock-guarded compare-and-rename.
48
+ */
49
+
50
+ import { randomBytes } from 'node:crypto'
51
+ import { readFileSync, readdirSync, renameSync, rmSync, statSync } from 'node:fs'
52
+ import { basename, dirname, join } from 'node:path'
53
+
54
+ /** Default sink — matches the stores' existing `process.stderr.write` use. */
55
+ const defaultLog = (line: string): void => {
56
+ process.stderr.write(line)
57
+ }
58
+
59
+ /**
60
+ * How many `<file>.corrupt-*` forensic copies to keep per store file.
61
+ * They're tiny, but a store that corrupts on a loop must not fill the
62
+ * state dir — so the oldest are reaped past this cap. Newest wins.
63
+ */
64
+ export const MAX_QUARANTINED_COPIES = 5
65
+
66
+ /** Outcome of a store-file read. See the module docblock. */
67
+ export type StoreReadResult =
68
+ | { status: 'ok'; value: unknown }
69
+ | { status: 'missing' }
70
+ | { status: 'corrupt' }
71
+ | { status: 'unreadable' }
72
+
73
+ /**
74
+ * Monotonic within this process. Purely a name-uniqueness aid now — the
75
+ * reaper orders by mtime, NOT by name (see `reapOldQuarantines`), because a
76
+ * name-based order is only monotonic within one process: a restart resets
77
+ * this counter to 0, and a fresh copy written in the same epoch-ms as a
78
+ * prior process's burst would sort as the OLDEST and be reaped first.
79
+ */
80
+ let quarantineSeq = 0
81
+
82
+ /**
83
+ * `<file>.corrupt-<epoch-ms>-<seq>-<rand>`.
84
+ *
85
+ * All three tail components exist to make the name UNIQUE: two quarantines
86
+ * in the same millisecond, or from two processes, would otherwise collide
87
+ * and the second `renameSync` would silently clobber the first forensic copy
88
+ * — same reasoning as the tempfile naming in `src/util/atomic.ts`. The name
89
+ * is deliberately NOT the ordering key; mtime is.
90
+ */
91
+ function quarantinePath(filePath: string): string {
92
+ const seq = String(quarantineSeq++).padStart(6, '0')
93
+ return `${filePath}.corrupt-${Date.now()}-${seq}-${randomBytes(4).toString('hex')}`
94
+ }
95
+
96
+ /**
97
+ * Reap all but the newest {@link MAX_QUARANTINED_COPIES} forensic copies.
98
+ *
99
+ * Ordered by the filesystem's own mtime (nanosecond resolution via the
100
+ * bigint stat) — unlike the embedded `<epoch-ms>-<seq>`, whose sequence
101
+ * restarts at 0 on every boot and would make a fresh copy sort as the oldest
102
+ * whenever a restart lands in the same millisecond as the previous process's
103
+ * burst. The name is used only as a stable tie-break.
104
+ *
105
+ * Where the guarantee ends: this is exact only on filesystems that record
106
+ * mtime at (sub-)nanosecond granularity — ext4, xfs, apfs, btrfs all do. On a
107
+ * coarse-granularity filesystem (older ext3, some network/FUSE mounts, where
108
+ * mtime rounds to a whole second) a burst of more than MAX_QUARANTINED_COPIES
109
+ * quarantines inside one second all tie on `mtimeNs`, the sort falls through
110
+ * to the name, and the restart-reset ordering above comes back: the prior
111
+ * process burns seq 000001-000005 in second T, a restart quarantines at
112
+ * 000000, all six tie, and the freshest copy is reaped first. That costs a
113
+ * forensic copy on a rare filesystem and never live state, so it is not
114
+ * defended against here — but do not read this reaper as unconditionally
115
+ * restart-safe.
116
+ */
117
+ function reapOldQuarantines(filePath: string, log: (line: string) => void): void {
118
+ const dir = dirname(filePath)
119
+ const prefix = `${basename(filePath)}.corrupt-`
120
+ try {
121
+ const copies = readdirSync(dir)
122
+ .filter(f => f.startsWith(prefix))
123
+ .map(name => {
124
+ let mtimeNs = 0n
125
+ try {
126
+ mtimeNs = statSync(join(dir, name), { bigint: true }).mtimeNs
127
+ } catch {
128
+ // Vanished under us (a concurrent reap) — sorts oldest; the rmSync
129
+ // below is `force`, so a missing file is a no-op either way.
130
+ }
131
+ return { name, mtimeNs }
132
+ })
133
+ .sort((a, b) => (a.mtimeNs === b.mtimeNs ? a.name.localeCompare(b.name) : a.mtimeNs < b.mtimeNs ? -1 : 1))
134
+ for (const stale of copies.slice(0, Math.max(0, copies.length - MAX_QUARANTINED_COPIES))) {
135
+ rmSync(join(dir, stale.name), { force: true })
136
+ }
137
+ } catch (err) {
138
+ // Reaping is opportunistic — never let it mask the corruption itself.
139
+ log(`telegram gateway: quarantine reap failed dir=${dir}: ${(err as Error).message}\n`)
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Move a corrupt/unusable store file aside so its bytes survive, and log
145
+ * loudly. Exported for stores whose corruption is a SHAPE failure (valid
146
+ * JSON, wrong structure) that only the store itself can detect.
147
+ */
148
+ export function quarantineCorruptStoreFile(
149
+ filePath: string,
150
+ store: string,
151
+ reason: string,
152
+ log: (line: string) => void = defaultLog,
153
+ ): void {
154
+ const target = quarantinePath(filePath)
155
+ let preserved = target
156
+ try {
157
+ renameSync(filePath, target)
158
+ } catch (err) {
159
+ preserved = `NOT preserved (${(err as Error).message})`
160
+ }
161
+ log(
162
+ `telegram gateway: ${store} CORRUPT — ${reason}. ` +
163
+ `Persisted state was LOST and the store is starting EMPTY; ` +
164
+ `corrupt file ${preserved}\n`,
165
+ )
166
+ reapOldQuarantines(filePath, log)
167
+ }
168
+
169
+ /**
170
+ * FAIL-CLOSED guard for the `unreadable` read outcome.
171
+ *
172
+ * A read that failed for a non-ENOENT reason (a flaky mount returning EIO,
173
+ * a transient EACCES) leaves the store in memory EMPTY while the on-disk
174
+ * file may still hold perfectly good state. The next `save()` would then
175
+ * rename a fresh, near-empty file over it and destroy that state
176
+ * permanently — quietly, and for a fault that may have lasted one syscall.
177
+ *
178
+ * So the stores call this immediately BEFORE their first write following an
179
+ * unreadable read: the existing bytes are renamed aside (same
180
+ * `.corrupt-<ts>-<seq>-<rand>` forensic convention) so the overwrite can never be
181
+ * the thing that loses them. If the rename itself fails the write is
182
+ * ABORTED by throwing — better a loud failed write than a silent
183
+ * destruction of the only copy.
184
+ */
185
+ export function preserveUnreadableStoreFile(
186
+ filePath: string,
187
+ store: string,
188
+ log: (line: string) => void = defaultLog,
189
+ ): void {
190
+ const target = quarantinePath(filePath)
191
+ try {
192
+ renameSync(filePath, target)
193
+ } catch (err) {
194
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
195
+ // Nothing there to lose — the read failure wasn't about existing bytes.
196
+ return
197
+ }
198
+ log(
199
+ `telegram gateway: ${store} REFUSING to overwrite an unreadable file at ` +
200
+ `${filePath} — could not preserve it first: ${(err as Error).message}\n`,
201
+ )
202
+ throw err
203
+ }
204
+ log(
205
+ `telegram gateway: ${store} could not READ its file but is about to write it. ` +
206
+ `Preserved the previous (unreadable) bytes as ${target} rather than ` +
207
+ `overwriting them; the store continues from EMPTY\n`,
208
+ )
209
+ reapOldQuarantines(filePath, log)
210
+ }
211
+
212
+ /**
213
+ * Read + parse a store's backing JSON file. See {@link StoreReadResult} and
214
+ * the module docblock for what each outcome means and what it triggers.
215
+ */
216
+ export function readStoreJsonSync(
217
+ filePath: string,
218
+ store: string,
219
+ log: (line: string) => void = defaultLog,
220
+ ): StoreReadResult {
221
+ let raw: string
222
+ try {
223
+ raw = readFileSync(filePath, 'utf-8')
224
+ } catch (err) {
225
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { status: 'missing' }
226
+ log(
227
+ `telegram gateway: ${store} read FAILED path=${filePath}: ` +
228
+ `${(err as Error).message} — starting EMPTY for this read; the next write ` +
229
+ `will preserve the unreadable file rather than overwrite it\n`,
230
+ )
231
+ return { status: 'unreadable' }
232
+ }
233
+ try {
234
+ return { status: 'ok', value: JSON.parse(raw) }
235
+ } catch (err) {
236
+ quarantineCorruptStoreFile(
237
+ filePath,
238
+ store,
239
+ `unparseable JSON (${(err as Error).message}) — likely a torn write from a crash mid-persist`,
240
+ log,
241
+ )
242
+ return { status: 'corrupt' }
243
+ }
244
+ }
@@ -96,6 +96,85 @@ function urlHostPath(u) {
96
96
  }
97
97
  }
98
98
 
99
+ /**
100
+ * ALLOWLIST-style summary of a Bash command, for when the model omitted the
101
+ * optional `description`.
102
+ *
103
+ * The failure this closes: `description` is optional, so a sub-agent that never
104
+ * writes one produced the constant label "Running a command" for EVERY Bash
105
+ * call. The worker feed's narrative dedup then dropped every repeat and the
106
+ * card froze on one line for the whole job — the user could not tell a running
107
+ * worker from a wedged one. The card must never depend on a model volunteering
108
+ * an optional field.
109
+ *
110
+ * SECURITY: a command line routinely carries tokens, passwords, URLs with
111
+ * credentials, and private paths, and this string is rendered into a Telegram
112
+ * message. So this is NOT a clip of the command — nothing is echoed unless it
113
+ * passes a strict allowlist:
114
+ * - only the PROGRAM name (basename, no directory), and
115
+ * - for a known multiplexer (git/docker/npm/…), at most one bare subcommand
116
+ * of pure lowercase letters/hyphens.
117
+ * Anything with a slash, `=`, digit-mixed shape, quote, or any other character
118
+ * is refused and we fall back to the generic label. Arguments, flag VALUES,
119
+ * env assignments, redirections and heredocs are never considered at all.
120
+ *
121
+ * Returns '' when nothing safe can be derived (caller uses the generic label).
122
+ */
123
+ export function summariseBashCommand(cmd) {
124
+ if (typeof cmd !== 'string') return ''
125
+ // First line only — a heredoc/multiline body must never be inspected.
126
+ const firstLine = cmd.split('\n', 1)[0]
127
+ if (!firstLine) return ''
128
+
129
+ // Split on shell separators and prefer the first segment that actually runs
130
+ // something (`cd /x && git status` should read as "git status", not "cd").
131
+ const NAV = new Set(['cd', 'pushd', 'popd', 'export', 'set', 'source', '.', 'unset'])
132
+ const PREFIXES = new Set(['sudo', 'env', 'nohup', 'time', 'exec', 'command', 'doas', 'runuser', 'nice', 'xargs'])
133
+ const segments = firstLine.split(/&&|\|\||[;|]/).map((s) => s.trim()).filter(Boolean)
134
+ if (segments.length === 0) return ''
135
+
136
+ const PROGRAM_RE = /^[A-Za-z][A-Za-z0-9._+-]{0,19}$/
137
+ const SUBCOMMAND_RE = /^[a-z][a-z-]{1,15}$/
138
+ const MULTIPLEXERS = new Set([
139
+ 'git', 'docker', 'npm', 'npx', 'bun', 'yarn', 'pnpm', 'cargo', 'go', 'gh',
140
+ 'kubectl', 'systemctl', 'apt', 'apt-get', 'brew', 'pip', 'pip3', 'poetry',
141
+ 'uv', 'terraform', 'aws', 'gcloud', 'helm', 'switchroom', 'make', 'openssl',
142
+ ])
143
+
144
+ /** Program + optional subcommand for one segment, or null. */
145
+ function fromSegment(seg) {
146
+ let tokens = seg.split(/\s+/).filter(Boolean)
147
+ // Drop leading env assignments (FOO=bar) and wrapper prefixes.
148
+ while (tokens.length > 0 && (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0]) || PREFIXES.has(tokens[0]))) {
149
+ tokens = tokens.slice(1)
150
+ }
151
+ if (tokens.length === 0) return null
152
+ // Basename only: never surface a directory (paths leak layout, usernames).
153
+ const head = safeBasename(tokens[0])
154
+ if (!PROGRAM_RE.test(head)) return null
155
+ if (NAV.has(head)) return { program: head, nav: true }
156
+ if (!MULTIPLEXERS.has(head)) return { program: head, nav: false }
157
+ // ONLY the immediately-following token may be a subcommand. Scanning past
158
+ // flags would surface a flag VALUE (`docker --context prod-internal ps` →
159
+ // "prod-internal"), which is exactly the kind of private name this
160
+ // function exists to keep out of a chat message. `git -C /x status` losing
161
+ // its "status" is the correct trade.
162
+ const next = tokens[1]
163
+ return next != null && SUBCOMMAND_RE.test(next)
164
+ ? { program: `${head} ${next}`, nav: false }
165
+ : { program: head, nav: false }
166
+ }
167
+
168
+ let firstAny = null
169
+ for (const seg of segments) {
170
+ const r = fromSegment(seg)
171
+ if (r == null) continue
172
+ if (firstAny == null) firstAny = r
173
+ if (!r.nav) return r.program
174
+ }
175
+ return firstAny != null ? firstAny.program : ''
176
+ }
177
+
99
178
  /**
100
179
  * Compute a label for a (toolName, input) pair. Returns null when the
101
180
  * tool should NOT be labeled (suppress / fall through to existing
@@ -111,8 +190,15 @@ export function computeLabel(toolName, input) {
111
190
  // never reaches the live draft. Uses the model-authored `description`
112
191
  // for Bash/Task, matching the gateway's describeToolUse rendering.
113
192
  switch (toolName) {
114
- case 'Bash':
115
- return clip(asText(i.description), 70).trim() || 'Running a command'
193
+ case 'Bash': {
194
+ const described = clip(asText(i.description), 70).trim()
195
+ if (described) return described
196
+ // No model-authored description — derive a sanitised one from the
197
+ // command itself rather than emitting the constant "Running a command"
198
+ // that freezes the step feed. See summariseBashCommand.
199
+ const derived = summariseBashCommand(asText(i.command))
200
+ return derived ? `Running ${derived}` : 'Running a command'
201
+ }
116
202
  case 'Task':
117
203
  case 'Agent': {
118
204
  const d = clip(asText(i.description), 60).trim()
@@ -0,0 +1,95 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+ import { buildEffectiveToolSchemas } from '../bridge/tool-filter.js'
5
+
6
+ /**
7
+ * Half-wiring guard: every tool the gateway is willing to EXECUTE must be
8
+ * ADVERTISED by the bridge's MCP surface.
9
+ *
10
+ * The failure this pins (observed live 2026-07-25): `progress_update` was in
11
+ * the gateway's `ALLOWED_TOOLS` allowlist and had a full `executeProgressUpdate`
12
+ * implementation, but it was never carried over into `bridge/bridge.ts`'s
13
+ * `TOOL_SCHEMAS` when the legacy `server.ts` monolith was deleted (5496f792,
14
+ * Wave 3 F4). The monolith had registered it (7206817a); the bridge never did.
15
+ * Result: the tool was documented in every sub-agent's system prompt, executable
16
+ * by the gateway, and completely unreachable — a sub-agent calling it got
17
+ * "No such tool available". Nothing failed loudly; the capability just silently
18
+ * did not exist for ~14 months.
19
+ *
20
+ * Why structural (source text, not imports): `gateway.ts` has heavy import-time
21
+ * side effects (socket bind, bot construction) and `bridge.ts` exits the process
22
+ * at import when no gateway socket is present, so neither can be imported into a
23
+ * unit test. Both lists are plain string literals in source, so parsing them is
24
+ * exact and cheap. If either declaration is refactored out of the shape these
25
+ * regexes expect, the test FAILS (it never silently degrades to a no-op) —
26
+ * update the parser, don't delete the assertion.
27
+ */
28
+
29
+ const PLUGIN_DIR = join(import.meta.dirname, '..')
30
+
31
+ function readSource(rel: string): string {
32
+ return readFileSync(join(PLUGIN_DIR, rel), 'utf8')
33
+ }
34
+
35
+ /** Tool names in the gateway's IPC execution allowlist (`ALLOWED_TOOLS`). */
36
+ function gatewayAllowedTools(): string[] {
37
+ const src = readSource('gateway/gateway.ts')
38
+ const m = src.match(/const ALLOWED_TOOLS = new Set\(\[([\s\S]*?)\]\)/)
39
+ if (m == null) throw new Error('could not locate ALLOWED_TOOLS in gateway/gateway.ts')
40
+ return [...m[1].matchAll(/'([a-z0-9_]+)'/g)].map((x) => x[1])
41
+ }
42
+
43
+ /** Tool names advertised by the bridge's MCP `tools/list` (`TOOL_SCHEMAS`). */
44
+ function bridgeToolSchemaNames(): string[] {
45
+ const src = readSource('bridge/bridge.ts')
46
+ const m = src.match(/const TOOL_SCHEMAS = \[([\s\S]*?)\n\]\n/)
47
+ if (m == null) throw new Error('could not locate TOOL_SCHEMAS in bridge/bridge.ts')
48
+ return [...m[1].matchAll(/^ {4}name: '([a-z0-9_]+)',$/gm)].map((x) => x[1])
49
+ }
50
+
51
+ /**
52
+ * Tools deliberately executable-but-unadvertised. EMPTY on purpose.
53
+ *
54
+ * Adding a name here is a decision to make a capability unreachable by the
55
+ * model — it needs a comment naming the reason, not just an entry. The whole
56
+ * point of this file is that the omission is explicit and reviewed rather than
57
+ * an accident of a refactor.
58
+ */
59
+ const INTENTIONALLY_UNADVERTISED: ReadonlySet<string> = new Set([])
60
+
61
+ describe('bridge MCP tool surface ↔ gateway ALLOWED_TOOLS parity', () => {
62
+ it('parses both declarations (guards against a silently no-op test)', () => {
63
+ expect(gatewayAllowedTools().length).toBeGreaterThan(15)
64
+ expect(bridgeToolSchemaNames().length).toBeGreaterThan(15)
65
+ })
66
+
67
+ it('advertises every gateway-executable tool', () => {
68
+ const advertised = new Set(bridgeToolSchemaNames())
69
+ const missing = gatewayAllowedTools().filter(
70
+ (t) => !advertised.has(t) && !INTENTIONALLY_UNADVERTISED.has(t),
71
+ )
72
+ expect(missing).toEqual([])
73
+ })
74
+
75
+ it('advertises no tool the gateway would refuse to execute', () => {
76
+ const allowed = new Set(gatewayAllowedTools())
77
+ const orphans = bridgeToolSchemaNames().filter((t) => !allowed.has(t))
78
+ expect(orphans).toEqual([])
79
+ })
80
+
81
+ it('registers progress_update — the tool this guard was written for', () => {
82
+ expect(bridgeToolSchemaNames()).toContain('progress_update')
83
+ expect(gatewayAllowedTools()).toContain('progress_update')
84
+ })
85
+
86
+ it('keeps progress_update reachable through the tool-surface filter', () => {
87
+ // The Linear gate (tool-filter.ts) must not strip it, with or without a
88
+ // Linear connection: an unregistered-in-effect tool is the same outage.
89
+ const schemas = bridgeToolSchemaNames().map((name) => ({ name }))
90
+ for (const linearEnabled of [false, true]) {
91
+ const names = buildEffectiveToolSchemas(schemas, { linearEnabled }).map((t) => t.name)
92
+ expect(names).toContain('progress_update')
93
+ }
94
+ })
95
+ })