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.
- package/dist/agent-scheduler/index.js +2 -1
- package/dist/auth-broker/index.js +3 -1
- package/dist/cli/drive-write-pretool.mjs +48 -5
- package/dist/cli/ms-365-write-pretool.mjs +40 -2
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +3392 -1569
- package/dist/host-control/main.js +12209 -11396
- package/dist/vault/approvals/kernel-server.js +60 -7
- package/dist/vault/broker/server.js +206 -76
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +61 -1
- package/telegram-plugin/bridge/bridge.ts +14 -0
- package/telegram-plugin/dist/bridge/bridge.js +13 -0
- package/telegram-plugin/dist/gateway/gateway.js +1644 -1044
- package/telegram-plugin/dist/server.js +13 -0
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
- package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
- package/telegram-plugin/gateway/pending-card-store.ts +46 -16
- package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
- package/telegram-plugin/gateway/store-file.ts +244 -0
- package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
- package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
- package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
- package/telegram-plugin/tests/tool-activity-summary.test.ts +9 -2
- package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
- package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
- package/telegram-plugin/worker-activity-feed.ts +51 -1
- package/vendor/hindsight-memory/scripts/drain_pending.py +668 -56
- package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +865 -33
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +449 -0
- package/vendor/hindsight-memory/scripts/session_start.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2121 -0
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +430 -0
- package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +102 -6
- 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
|
-
|
|
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
|
+
})
|