ghostbus 0.5.4 → 0.5.6

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/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.6 — 2026-10-11
4
+
5
+ Security hardening, round 5 — load & abuse resilience: what happens when many agents share one process for a long time. This round built the first repeatable load bench before changing anything, and the honest headline is that load broke no correctness property — zero errors, zero lost writes, exact reconciliation at every size. What it (and the audit around it) found instead: an unbounded subscription surface, missing server timeouts whose platform defaults do not do what their names suggest, and a measured, structural scaling limit that is now documented instead of discovered by operators.
6
+
7
+ - **Load bench (`scripts/load-bench.mjs`, new)** — boots the real hosted server with per-agent tokens enforced and drives N agents through mixed operations (register, heartbeat, send, inbox, task create→claim→complete, file put), reporting ops, errors, and p50/p95 latency per op class, plus a hard reconciliation: messages sent == messages stored, tasks created == tasks completed, files put == files stored. Baseline on the development machine (Node 24): **25 agents × 30 s → 2,506 ops, ~83 ops/s, 0 errors**, p50 ~280–360 ms / p95 ~390–595 ms per op class, reconciled exactly (650 messages, 300/300 tasks, 300 files); **25 agents × 60 s → 3,438 ops, ~57 ops/s, 0 errors**, also exact. Repeat 30 s runs on this shared VM varied between ~55 and ~112 ops/s with machine load; the invariants across every run were 0 errors and exact reconciliation. A reduced smoke (6 agents × 3 s) runs in the suite. One bench disclosure: at the shipped 300 req/min per-IP limiter, a single-IP bench measures the limiter, not the bus, so the bench raises that one knob for itself (printed in its output); every other limit is at its shipped default.
8
+ - **What the load actually showed** — throughput declines as a workspace's state grows (~83 → ~57 ops/s across the captured 30 s/60 s pair as the state file grew ~0.9 → ~1.2 MB, p95 latency roughly doubling) because every operation loads and re-saves the whole state file (O(state) per op) and all operations serialize on the per-workspace lock: with 25 agents queued, most of the p50 is queue wait. Memory plateaued (RSS oscillated in a ~140–260 MB band under sustained load — garbage collection keeping up with per-op state copies), it did not grow with op count. No fix is claimed for the scaling shape: it is the single-file store's documented trade-off, now with numbers, in `docs/hosted.md` ("Capacity & limits"). Trims (10k messages / 5k events / per-file history) held throughout.
9
+ - **SSE subscriptions were unbounded (fixed)** — before this round, any number of `/api/stream` connections could be held per workspace, each one a permanent bus listener plus a 25 s ping timer, reaped only on socket close. There are now caps, checked before the stream opens: **100 per workspace** (`GHOSTBUS_MAX_STREAMS_PER_WORKSPACE`) and **1000 per server** (`GHOSTBUS_MAX_STREAMS_TOTAL`), with a clean `429` JSON refusal over the cap. Cleanup is now idempotent across close/error/write-failure paths, and the suite proves the cap, that a freed slot reopens, and that bus listener counts return to exactly zero after stream churn — no listener leaks.
10
+ - **Explicit server timeouts on both servers** — slow-loris room is closed. Both HTTP servers now take commented deadlines (defaults: headers 10 s, request body 30 s, keep-alive 5 s; overridable via `GHOSTBUS_HEADERS_TIMEOUT_MS` / `GHOSTBUS_REQUEST_TIMEOUT_MS`). The implementation notes are in the code because the platform defaults mislead: Node's own header enforcement was probed to leave a partial-headers socket alive past 15 s in this configuration, and its `requestTimeout` semantics proved unsafe to reason about for long-lived responses, so that property is pinned to 0 and both deadlines are enforced by a small connection-level guard that only ever destroys a socket whose request is genuinely incomplete. The guard's first cut armed its body timer on `req.complete` — which stays false for a bodyless GET nobody reads, exactly the `/api/stream` shape — and killed live streams in testing; the shipped guard arms only on a *declared* body (Content-Length / Transfer-Encoding). The suite proves all of it at tightened deadlines (400 ms / 800 ms): a partial-headers socket dies at the header deadline, a slow body drip dies at the body deadline, and an SSE stream plus a 2 s long-poll outlive both.
11
+ - **Recovery drill (new suite proof)** — while three agents write against a hosted FileStore workspace, the server is SIGKILLed mid-load and restarted on the same data directory. Assertions: the state file parses (temp-file + atomic rename means a kill strands at most an inert `.tmp`), every operation already answered HTTP 200 is present after restart, each agent's stored messages form a contiguous prefix of its attempts (nothing half-applied), acked files are present, and the restarted server's counts agree with the file. This is the durability claim for the JSON store, stated at its true scope: single file, one writer process, last-writer semantics per op.
12
+ - **Long-poll pile-up (new suite proof)** — 25 concurrent `/api/wait` holders all wake with the event on a single broadcast (none hang, none miss it), and 10 idle waits all resolve on their own timeout with `waited: true` and no events, with no unhandled rejections in the server log.
13
+ - **Limiter hygiene** — the per-IP/workspace rate limiter is now tunable (`GHOSTBUS_RATE_LIMIT_PER_MIN`, default unchanged at 300) and its over-limit message reports the live value; the limiter map and the hosted failed-auth map prune stale buckets opportunistically instead of only ever growing under address churn.
14
+ - **Tests** — five new checks (69 in the main suite, was 64): bench smoke with exact reconciliation, server timeouts (loris + drip + SSE/long-poll survival), SSE caps + reaping, wait pile-up, and the SIGKILL recovery drill. The suite now takes ~22 s because three of the new checks are real-time proofs (a 3 s bench window, a 2 s long-poll, a 1.2 s kill drill) — that time is the evidence, not overhead. Serverless stays 6/6 and both demos pass.
15
+ - **Scope boundary, honestly** — what did NOT change: the storage format and every API shape are untouched; the local trust model is untouched; the bench figures are one machine's evidence, not a hosted SLA; and the networked multi-writer store remains future work — this round measured the single-file store's limits rather than relitigating them.
16
+
17
+ ## 0.5.5 — 2026-10-11
18
+
19
+ Security hardening, round 4 — the client side of the wire. Rounds 2–3 hardened what the server stores and how it authenticates; this round fuzzed what a client can *send*: MCP tool arguments over stdio JSON-RPC, board payloads end-to-end, and raw HTTP parameters. One real crash, a class of silent state corruption, and a set of numeric edge cases were found and fixed. Several surfaces audited clean and are now pinned by tests instead — the entry says which, as usual.
20
+
21
+ - **A single `null` line crashed the MCP server (fixed)** — `src/mcp-server.mjs` destructured every parsed line; a client (or a prompt-injected agent driving one) that sent a bare `null`, a string, a number, or an array killed the process with an unhandled TypeError, taking the bus down for every connected agent. Non-object frames are now ignored, and message handling is wrapped so one bad message can never be fatal. Verified: the pre-fix server exits with code 1 on the first hostile line; the fixed server drops all of them and keeps answering.
22
+ - **Tool arguments are validated against their schemas (`src/tools.mjs`)** — the tools layer passed argument values straight into the core, which coerced: a message body sent as an object was *stored* as the literal string `"[object Object]"`, a body sent as an array as its first character, a numeric file path became a state key of the same number, a `threadId` sent as an object landed in memory as `NaN` (invisible until reload, when JSON rewrote it to `null`), and a null capsule slug created a capsule named `"null"`. One shared validator now runs before dispatch: required arguments must be present, declared types are enforced (strings stay strings, numbers must be finite, arrays and plain objects must be arrays and plain objects), and violations return the suite's existing clean error results. Arguments the schema doesn't declare (the optional per-agent `token`, for one) pass through untouched.
23
+ - **Numeric parameters are clamped in the core** — the list/sync reads trusted their numbers: `limit=0` returned *every* message (slice(-0)), `limit=-1` returned all-but-one, events the same, and a garbage `sinceSeq` cursor made `bus_sync` report an agent as caught up (`truncated: false`) while replaying nothing. Limits now clamp (≤0 → none, absurd → the documented 500 cap, non-finite → the default page size) and a garbage cursor replays honestly from the start. Message thread ids are sanitized the same way — a non-numeric `threadId` is stored as `null`, never `NaN`.
24
+ - **`/api/wait` never waits on a garbage timeout (fixed)** — `timeout=abc` made the long-poll reply instantly with an empty inbox and `waited: true`. A non-numeric or negative timeout now means the 25 s default; real values still clamp to [0, 55] s.
25
+ - **Board task ids escape too** — the one board interpolation not routed through `esc()` was the task id in two templates. Ids are numbers from the core, so it was unreachable in normal operation, but a hand-crafted imported snapshot could put a string there; it goes through `esc()` now like everything else.
26
+ - **Audited, already safe — now pinned by tests.** Board data path: hostile roles, message bodies, task titles, and file names seeded through the real API come back verbatim as JSON (inert by content type), every interpolated field is escaped, and the workspace key lives in local storage and travels as a header — never in a URL. `/probe` and `/health` return exactly their documented counts-only shapes. HTTP edges: method mismatches, trailing slashes, unknown routes, and URL-encoded traversal in workspace segments (`%2e%2e%2f`) all return clean 404s; a traversal-looking file path is an inert key miss (400), never a filesystem read. CLI: missing arguments, unknown commands, and bad values already exit non-zero with one-line errors and no stack dumps — pinned as-is. Prototype pollution through tool arguments: not reachable (JSON clone discipline), canary-checked in the fuzz.
27
+ - **Tests** — five new checks (64 in the main suite, was 59): tools-boundary validation + core clamps (including that rejected calls store nothing), a bounded stdio fuzz (hostile raw frames, one wrong-typed variant for every declared property of all 29 tools, a 100 KB body, a 300-deep nested context, then bus-integrity assertions), the board data path, HTTP parameter edges on relay + hosted, and the CLI pins. Against the 0.5.4 code the new checks fail on the crash, the coercions, and the numeric semantics; with the change the full suite is green, serverless stays 6/6, and both demos pass.
28
+ - **Scope boundary, honestly** — what did NOT change: no tool, route, or CLI behavior changes for well-formed input (valid calls produce identical results; the fuzz asserts registered-agent counts and a normal round-trip after the battery); argument validation rejects only values the schemas never allowed; the deferred networked multi-writer store is still future work.
29
+
3
30
  ## 0.5.4 — 2026-10-11
4
31
 
5
32
  Security hardening, round 3 — hostile input: the values a malicious (or merely buggy) client controls, followed to the filesystem, the object graph, and state validation. Audit-first as usual: two of the five surfaces turned out to be already safe, and the entry says which and why rather than dressing them up as fixes.
package/README.md CHANGED
@@ -23,7 +23,7 @@ Built and in use: 29 MCP tools across presence, messages, tasks, a shared worksp
23
23
  ```bash
24
24
  git clone https://github.com/littlestjames82-sys/ghostbus.git ghostbus && cd ghostbus
25
25
  node examples/two-agent-demo.mjs # watch a planner and a builder cooperate
26
- node test.mjs # 59 checks: core, tokens, capsules, dependencies, sync, persistence, concurrency, MCP stdio, HTTP, SSE, CLI, hosted security, hostile input
26
+ node test.mjs # 69 checks: core, tokens, capsules, dependencies, sync, persistence, concurrency, MCP stdio, HTTP, SSE, CLI, hosted security, hostile input, input fuzz, load & resilience
27
27
  node examples/three-agent-team.mjs # planner + builder + reviewer, no human relaying
28
28
  ```
29
29
 
package/docs/hosted.md CHANGED
@@ -165,6 +165,91 @@ validation:
165
165
  codebase, and the suite carries canary checks that try anyway through
166
166
  the context writer and restore payloads.
167
167
 
168
+ Round 4 (v0.5.5) treated the clients themselves as untrusted input —
169
+ including MCP clients and any agent driving one:
170
+
171
+ - **MCP clients are untrusted input — say it plainly.** A client (or a
172
+ prompt-injected agent behind one) can send any argument shapes it wants.
173
+ Tool arguments are validated against each tool's declared schema at the
174
+ tools boundary: wrong-typed or non-finite values are rejected with a
175
+ clean error result, never coerced into shared state, and a malformed
176
+ JSON-RPC frame can no longer kill the stdio server process — bad frames
177
+ are dropped, the bus keeps serving every other agent.
178
+ - **Numeric parameters are clamped everywhere.** Query and argument
179
+ numbers (`limit`, `sinceSeq`, timeouts) that arrive as garbage,
180
+ negatives, or absurd magnitudes fall back to defaults and documented
181
+ caps instead of slice-arithmetic surprises (a `limit` of 0 or -1 used
182
+ to return *more* data, and a garbage sync cursor falsely reported an
183
+ agent as caught up).
184
+ - **The board's data path is pinned end-to-end.** Hostile roles, message
185
+ bodies, task titles, and file names are served verbatim as JSON data
186
+ (inert by content type), the page routes every interpolated field
187
+ through its HTML escaper, and the workspace key lives in local storage
188
+ and travels as a header — never in a URL, where it would land in logs
189
+ and referers. `/health` and `/probe` return exactly their documented
190
+ counts-only shapes.
191
+
192
+ ## Capacity & limits (measured, v0.5.6)
193
+
194
+ First published load figures, from `scripts/load-bench.mjs` — a repeatable
195
+ bench that boots the real hosted server (per-agent tokens enforced) and
196
+ drives N agents through mixed register/heartbeat/send/inbox/task/file
197
+ operations, then reconciles the final state **exactly** (messages sent ==
198
+ messages stored, tasks created == tasks completed, files put == files
199
+ stored). One development machine (Node 24, local disk); treat these as
200
+ evidence about the shape of the limits, not as a hosted SLA.
201
+
202
+ | run | ops | throughput | errors | latency (p50 / p95) |
203
+ |---|---|---|---|---|
204
+ | 25 agents × 30 s | 2,506 | ~83 ops/s | 0 | ~280–360 ms / ~390–595 ms per op class |
205
+ | 25 agents × 60 s | 3,438 | ~57 ops/s | 0 | ~315–470 ms / ~910–1,870 ms per op class |
206
+
207
+ Repeat runs vary with machine load — five 30 s runs on the shared
208
+ development VM landed between ~55 and ~112 ops/s. What did not vary, in
209
+ any run: **0 errors, and exact reconciliation.**
210
+
211
+ What the numbers mean:
212
+
213
+ - **Correctness holds under concurrency.** Zero errors and exact
214
+ reconciliation at every size — the per-workspace lock serializes
215
+ operations, so nothing is lost or double-applied.
216
+ - **Throughput is bounded by design, and declines as a workspace grows.**
217
+ Every operation loads and re-saves the workspace's whole state file, so
218
+ per-operation cost is O(state size), and operations queue behind each
219
+ other: with 25 agents active, most of the ~300 ms p50 is queue wait, not
220
+ work. The 60 s run is slower per op than the 30 s run because its state
221
+ file grew from ~0.9 MB to ~1.2 MB during the run. History trims (10k
222
+ messages, 5k events, per-file history) bound how large that cost can
223
+ get, but a hot workspace is a real cost center — split hot work into
224
+ more workspaces rather than feeding one forever.
225
+ - **Memory plateaus.** Under sustained load the server's RSS climbed,
226
+ then oscillated in a ~140–260 MB band (garbage collection keeping up
227
+ with per-operation state copies). It did not grow linearly with the
228
+ operation count; no leak was found.
229
+ - **Durability under a hard kill is proven, not assumed.** The suite's
230
+ recovery drill SIGKILLs the server mid-load and restarts on the same
231
+ data directory: the state file parses (writes are temp-file + atomic
232
+ rename, so a kill can strand at most an inert `.tmp` file), every
233
+ operation the server had already answered with HTTP 200 is present, and
234
+ each agent's stored messages form a contiguous prefix of what it
235
+ attempted — nothing half-applied.
236
+
237
+ Abuse limits an operator can tune (environment variables):
238
+
239
+ | variable | default | meaning |
240
+ |---|---|---|
241
+ | `GHOSTBUS_MAX_STREAMS_PER_WORKSPACE` | 100 | concurrent `/api/stream` subscribers per workspace; over the cap → `429` |
242
+ | `GHOSTBUS_MAX_STREAMS_TOTAL` | 1000 | concurrent streams across the whole server (hosted) |
243
+ | `GHOSTBUS_HEADERS_TIMEOUT_MS` | 10000 | deadline for a client to deliver complete request headers |
244
+ | `GHOSTBUS_REQUEST_TIMEOUT_MS` | 30000 | deadline to deliver a declared request body (never bounds a response — SSE and long-poll outlive it by design) |
245
+ | `GHOSTBUS_RATE_LIMIT_PER_MIN` | 300 | per-IP, per-workspace request budget (the v0.4 limiter, now tunable) |
246
+
247
+ When to scale out: one process serving a handful of team-sized workspaces
248
+ is the design point. If a single workspace's sustained op rate or state
249
+ size becomes the bottleneck, the honest answer today is "more, smaller
250
+ workspaces" — the networked multi-writer store that would lift the
251
+ single-file ceiling is documented future work, not a flag you can flip.
252
+
168
253
  ## Option B — serverless on Netlify (Blobs)
169
254
 
170
255
  `deploy/netlify/` contains a Netlify Function implementing the same multi-workspace semantics over Netlify Blobs (strong consistency):
package/package.json CHANGED
@@ -44,5 +44,5 @@
44
44
  "hosted": "node src/hosted-server.mjs"
45
45
  },
46
46
  "type": "module",
47
- "version": "0.5.4"
47
+ "version": "0.5.6"
48
48
  }
package/src/board.html CHANGED
@@ -64,7 +64,7 @@ async function refresh(){
64
64
  `<div class="agent"><strong>${esc(a.name)}</strong><span class="pill ${a.online?'green':''}">${a.online?'online':'away'}</span><br><span class="muted">${esc(a.role||'agent')}${a.capabilities?.length ? ' · ' + esc(a.capabilities.join(', ')) : ''} · last seen ${esc(a.lastSeenAt)}</span></div>`).join('') : '<span class="muted">none registered</span>';
65
65
  const open = tasks.tasks.filter(t => !['done','cancelled'].includes(t.status));
66
66
  $('tasks').innerHTML = open.length ? open.map(t =>
67
- `<div class="task">#${t.id} <strong>${esc(t.title)}</strong><span class="pill ${t.status==='needs-approval'?'amber':''}">${esc(t.status)}</span><br><span class="muted">from ${esc(t.from)}${t.claimedBy ? ' · claimed by ' + esc(t.claimedBy) : ''}${t.assignee ? ' · for ' + esc(t.assignee) : ''}</span></div>`).join('') : '<span class="muted">queue is empty</span>';
67
+ `<div class="task">#${esc(t.id)} <strong>${esc(t.title)}</strong><span class="pill ${t.status==='needs-approval'?'amber':''}">${esc(t.status)}</span><br><span class="muted">from ${esc(t.from)}${t.claimedBy ? ' · claimed by ' + esc(t.claimedBy) : ''}${t.assignee ? ' · for ' + esc(t.assignee) : ''}</span></div>`).join('') : '<span class="muted">queue is empty</span>';
68
68
  $('files').innerHTML = files.files.length ? files.files.map(f =>
69
69
  `<div class="file">📄 ${esc(f.path)}<br><span class="muted">${f.bytes} bytes · ${esc(f.updatedBy)} · ${esc(f.updatedAt)}</span></div>`).join('') : '<span class="muted">no shared files</span>';
70
70
  $('events').innerHTML = events.events.slice().reverse().map(e =>
package/src/core.mjs CHANGED
@@ -14,7 +14,7 @@ import path from 'node:path';
14
14
  import crypto from 'node:crypto';
15
15
  import { AsyncLocalStorage } from 'node:async_hooks';
16
16
 
17
- export const GHOSTBUS_VERSION = '0.5.4';
17
+ export const GHOSTBUS_VERSION = '0.5.6';
18
18
  const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
19
19
  // Timing-safe equality for hex digests (v0.5.3): secret comparisons must not
20
20
  // short-circuit on the first differing character the way === does.
@@ -36,6 +36,19 @@ const RESERVED_FILE_NAMES = new Set(['__proto__', 'constructor', 'prototype']);
36
36
 
37
37
  const now = () => new Date().toISOString();
38
38
  const clone = (v) => JSON.parse(JSON.stringify(v));
39
+ // Untrusted numeric inputs (HTTP query params, MCP arguments, direct API
40
+ // callers) arrive as NaN, negatives, and absurd magnitudes, and raw
41
+ // slice(-n) arithmetic turns them into nonsense: limit=0 or NaN returned
42
+ // EVERYTHING (slice(-0) / slice(NaN) both mean "from the start"), limit=-1
43
+ // returned all-but-one (v0.5.5). Every list/read path clamps through here:
44
+ // non-finite -> the default, fractional -> floored, out-of-range -> the
45
+ // [0, max] bound; a clamped limit of 0 honestly means "return none".
46
+ const clampLimit = (v, def, max) => {
47
+ const n = Number(v);
48
+ if (!Number.isFinite(n)) return def;
49
+ return Math.max(0, Math.min(Math.floor(n), max));
50
+ };
51
+ const tailSlice = (arr, lim) => (lim > 0 ? arr.slice(-lim) : []);
39
52
 
40
53
  function emptyState(workspace = {}) {
41
54
  return {
@@ -245,9 +258,13 @@ export class GhostBus {
245
258
  if (to !== '*') this._existsAgent(to);
246
259
  if (!body || !String(body).trim()) throw new Error('message body required');
247
260
  this.state.msgSeq += 1;
261
+ // threadId names a message id; anything that isn't a positive finite
262
+ // number (an object used to land here as NaN, invisible to thread()
263
+ // until a reload quietly rewrote it to null) is simply "no thread".
264
+ const tid = Number(threadId);
248
265
  const msg = {
249
266
  id: this.state.msgSeq, from, to, channel: String(channel).slice(0, 64),
250
- body: String(body), threadId: threadId ? Number(threadId) : null,
267
+ body: String(body), threadId: Number.isFinite(tid) && tid >= 1 ? Math.floor(tid) : null,
251
268
  createdAt: now(), readBy: [from],
252
269
  };
253
270
  this.state.messages.push(msg);
@@ -265,7 +282,7 @@ export class GhostBus {
265
282
  let msgs = this.state.messages.filter(m =>
266
283
  m.to === agentName || m.to === '*' || m.from === agentName);
267
284
  if (unreadOnly) msgs = msgs.filter(m => !m.readBy.includes(agentName));
268
- msgs = msgs.slice(-Math.min(limit, 500));
285
+ msgs = tailSlice(msgs, clampLimit(limit, 50, 500));
269
286
  if (markRead) {
270
287
  let changed = false;
271
288
  for (const m of msgs) if (!m.readBy.includes(agentName)) { m.readBy.push(agentName); changed = true; }
@@ -399,7 +416,7 @@ export class GhostBus {
399
416
  async _putFile({ by, path: p, text, token = null }) {
400
417
  await this.init();
401
418
  this._requireAgent(by, token);
402
- if (!p || typeof text !== 'string') throw new Error('path and text required');
419
+ if (typeof p !== 'string' || !p || typeof text !== 'string') throw new Error('path and text required');
403
420
  if (String(p).includes('..')) throw new Error('path may not contain ..');
404
421
  if (RESERVED_FILE_NAMES.has(p)) throw new Error('path is a reserved name');
405
422
  const prev = this.state.files[p];
@@ -495,11 +512,12 @@ export class GhostBus {
495
512
  const q = String(query || '').toLowerCase().trim();
496
513
  if (q.length < 2) throw new Error('search query needs at least 2 characters');
497
514
  const hit = (text) => String(text || '').toLowerCase().includes(q);
515
+ const lim = clampLimit(limit, 20, 500);
498
516
  return {
499
517
  query,
500
- messages: clone(this.state.messages.filter(m => hit(m.body)).slice(-limit)),
501
- tasks: clone(this.state.tasks.filter(t => hit(t.title) || hit(t.body) || hit(t.result)).slice(-limit)),
502
- files: Object.entries(this.state.files).filter(([p, f]) => hit(p) || hit(f.text)).slice(0, limit)
518
+ messages: clone(tailSlice(this.state.messages.filter(m => hit(m.body)), lim)),
519
+ tasks: clone(tailSlice(this.state.tasks.filter(t => hit(t.title) || hit(t.body) || hit(t.result)), lim)),
520
+ files: Object.entries(this.state.files).filter(([p, f]) => hit(p) || hit(f.text)).slice(0, lim)
503
521
  .map(([p, f]) => ({ path: p, updatedBy: f.updatedBy, updatedAt: f.updatedAt, excerpt: f.text.slice(0, 200) })),
504
522
  };
505
523
  }
@@ -596,14 +614,20 @@ export class GhostBus {
596
614
  async sync(...args) { return this._serialized(() => this._sync(...args)); }
597
615
  async _sync({ sinceSeq = 0, limit = 500 } = {}) {
598
616
  await this.init();
599
- const evs = this.state.events.filter(e => e.seq > Number(sinceSeq));
600
- return { events: clone(evs.slice(0, Math.min(limit, 1000))), headSeq: this.state.seq, truncated: evs.length > Math.min(limit, 1000) };
617
+ // A garbage cursor (NaN from a non-numeric query param) used to match
618
+ // NOTHING and report truncated:false — an offline agent would believe
619
+ // itself caught up while missing everything (v0.5.5). It now means
620
+ // what a missing cursor means: replay from the start, limit-bounded.
621
+ const since = Number.isFinite(Number(sinceSeq)) ? Math.max(0, Math.floor(Number(sinceSeq))) : 0;
622
+ const lim = clampLimit(limit, 500, 1000);
623
+ const evs = this.state.events.filter(e => e.seq > since);
624
+ return { events: clone(evs.slice(0, lim)), headSeq: this.state.seq, truncated: evs.length > lim };
601
625
  }
602
626
 
603
627
  async events(...args) { return this._serialized(() => this._events(...args)); }
604
628
  async _events({ limit = 50 } = {}) {
605
629
  await this.init();
606
- return clone(this.state.events.slice(-Math.min(limit, 500)));
630
+ return clone(tailSlice(this.state.events, clampLimit(limit, 50, 500)));
607
631
  }
608
632
 
609
633
  async status(...args) { return this._serialized(() => this._status(...args)); }
package/src/handler.mjs CHANGED
@@ -75,16 +75,117 @@ export const HTML_SECURITY_HEADERS = {
75
75
 
76
76
  const TOO_LARGE = Symbol('body-too-large');
77
77
 
78
+ // Per-IP request limiter (per workspace — one handler serves one workspace).
79
+ // The default (300/min) is unchanged since v0.3; GHOSTBUS_RATE_LIMIT_PER_MIN
80
+ // is an operator knob for two legitimate cases: many agents behind one NAT
81
+ // address sharing a single source IP, and load benchmarking, where the
82
+ // limiter — not the bus — would otherwise be the first ceiling (v0.5.6).
83
+ const rateLimitPerMin = () => {
84
+ const n = Number(process.env.GHOSTBUS_RATE_LIMIT_PER_MIN);
85
+ return Number.isFinite(n) && n >= 1 ? Math.floor(n) : 300;
86
+ };
87
+
88
+ // Server timeouts (v0.5.6). Three different clocks, three different jobs:
89
+ // - headersMs bounds how long a client may take to deliver complete request
90
+ // HEADERS (the classic slow-loris drip).
91
+ // - requestMs bounds how long it may take to deliver the request BODY after
92
+ // that. It must never bound a response: /api/stream and /api/wait are
93
+ // long-lived BY DESIGN.
94
+ // - keepAliveMs is Node's own idle keep-alive lifetime.
95
+ // Why this is not just "set the three Node properties": Node's own
96
+ // enforcement of both knobs was probed on Node 24 before this design was
97
+ // chosen, and neither is a deterministic drop-in for the deadlines above.
98
+ // - server.headersTimeout IS set (it only governs the header phase), but
99
+ // its enforcement rides on Node's socket-timeout machinery: with the
100
+ // default server.timeout of 0, a partial-headers socket was probed to
101
+ // survive 15 s at headersTimeout=1000.
102
+ // - server.requestTimeout is pinned to 0: its semantics proved impossible
103
+ // to reason about safely for long-lived responses (a handler that
104
+ // writes constantly masks its clock entirely), and the deadline it is
105
+ // documented for — receiving the request — is exactly the one the
106
+ // guard below enforces deterministically instead.
107
+ // The guard: arm the header deadline on connect, clear it when headers
108
+ // land, bound a *declared* body from there, re-arm while the socket sits
109
+ // between keep-alive requests. server.timeout stays 0 on purpose — socket
110
+ // *inactivity* is not a deadline here, because a long-poll legitimately
111
+ // idles for up to 55 s and an SSE stream between events for longer.
112
+ export const SERVER_TIMEOUT_DEFAULTS = { headersMs: 10_000, requestMs: 30_000, keepAliveMs: 5_000 };
113
+ export function applyServerTimeouts(server, opts = {}) {
114
+ const headersMs = opts.headersMs ?? SERVER_TIMEOUT_DEFAULTS.headersMs;
115
+ const requestMs = opts.requestMs ?? SERVER_TIMEOUT_DEFAULTS.requestMs;
116
+ const keepAliveMs = opts.keepAliveMs ?? SERVER_TIMEOUT_DEFAULTS.keepAliveMs;
117
+ server.headersTimeout = headersMs;
118
+ server.requestTimeout = 0; // see above — Node's clock kills live streams; the guard owns this deadline
119
+ server.keepAliveTimeout = keepAliveMs;
120
+ server.timeout = 0; // see above — inactivity is not the deadline
121
+ server.on('connection', (socket) => {
122
+ const armHeaderDeadline = () => {
123
+ clearTimeout(socket._gbHeaderTimer);
124
+ socket._gbHeaderTimer = setTimeout(() => socket.destroy(), headersMs);
125
+ socket._gbHeaderTimer.unref?.();
126
+ };
127
+ socket._gbArmHeaderDeadline = armHeaderDeadline;
128
+ armHeaderDeadline();
129
+ socket.on('close', () => clearTimeout(socket._gbHeaderTimer));
130
+ });
131
+ server.on('request', (req, res) => {
132
+ const socket = req.socket;
133
+ clearTimeout(socket._gbHeaderTimer); // headers are in — that deadline is met
134
+ clearTimeout(socket._gbBodyTimer);
135
+ // Bound the body ONLY when one was declared. req.complete cannot be
136
+ // the trigger: for a bodyless GET whose stream nobody reads (exactly
137
+ // the /api/stream and /api/wait shape) it stays false indefinitely, so
138
+ // a timer armed on it would destroy healthy long-lived exchanges —
139
+ // the first cut of this guard did precisely that to SSE streams.
140
+ const declaresBody = Number(req.headers['content-length'] || 0) > 0 || 'transfer-encoding' in req.headers;
141
+ if (declaresBody && !req.complete) {
142
+ socket._gbBodyTimer = setTimeout(() => socket.destroy(), requestMs);
143
+ socket._gbBodyTimer.unref?.();
144
+ const clear = () => clearTimeout(socket._gbBodyTimer);
145
+ req.on('end', clear);
146
+ req.on('close', clear);
147
+ res.on('finish', clear);
148
+ res.on('close', clear);
149
+ }
150
+ // Between keep-alive requests the socket owes us a fresh set of
151
+ // headers: re-arm the header deadline when this exchange finishes so a
152
+ // stalled *next* request is bounded too. A still-open response (SSE)
153
+ // never emits 'finish', and its request completed on arrival, so no
154
+ // timer is ever pending against a live stream or long-poll.
155
+ res.on('finish', () => { if (!socket.destroyed && socket._gbArmHeaderDeadline) socket._gbArmHeaderDeadline(); });
156
+ res.on('close', () => clearTimeout(socket._gbHeaderTimer));
157
+ });
158
+ return server;
159
+ }
160
+
161
+ // SSE stream caps (v0.5.6): before this round a workspace's /api/stream
162
+ // subscribers were bounded by nothing but the request limiter — each one
163
+ // holds a bus listener, a socket, and a 25 s ping interval, forever, and a
164
+ // buggy client reconnect-looping could pile them up without limit. Caps are
165
+ // generous against real teams (an agent holds at most a stream or two) and
166
+ // refuse cleanly with 429 past them. The per-process total spans every
167
+ // handler in the process, which is what protects the hosted server.
168
+ const streamCap = (envName, def) => {
169
+ const n = Number(process.env[envName]);
170
+ return Number.isFinite(n) && n >= 1 ? Math.floor(n) : def;
171
+ };
172
+ let activeStreamsTotal = 0;
173
+
78
174
  export function createBusHandler({ id = 'default', bus, key = '' }) {
79
175
  const buckets = new Map();
176
+ const rateLimit = rateLimitPerMin();
80
177
  const rateOk = (req) => {
81
178
  const ip = (req.socket && req.socket.remoteAddress) || 'unknown';
82
179
  const t = Date.now();
180
+ // Opportunistic sweep: one bucket per source IP per minute would
181
+ // otherwise accumulate forever on a long-lived server (v0.5.6).
182
+ if (buckets.size > 4096) { for (const [k, v] of buckets) if (t - v.start > 60_000) buckets.delete(k); }
83
183
  let b = buckets.get(ip);
84
184
  if (!b || t - b.start > 60_000) { b = { start: t, count: 0 }; buckets.set(ip, b); }
85
185
  b.count += 1;
86
- return b.count <= 300;
186
+ return b.count <= rateLimit;
87
187
  };
188
+ let activeStreams = 0; // this workspace's live /api/stream subscribers
88
189
  const keyOk = (req) => {
89
190
  if (!key) return true;
90
191
  const given = req.headers['x-bus-key'] || String(req.headers.authorization || '').replace(/^Bearer\s+/i, '');
@@ -99,7 +200,7 @@ export function createBusHandler({ id = 'default', bus, key = '' }) {
99
200
  res.writeHead(status, { 'content-type': type }); res.end(body);
100
201
  };
101
202
  if (!rateOk(req) && url.pathname !== '/health' && url.pathname !== '/probe') {
102
- return send(429, { error: 'rate limit: 300 requests/min per IP per workspace' });
203
+ return send(429, { error: `rate limit: ${rateLimit} requests/min per IP per workspace` });
103
204
  }
104
205
  const send413 = () => {
105
206
  try {
@@ -124,22 +225,45 @@ export function createBusHandler({ id = 'default', bus, key = '' }) {
124
225
  if (req.method === 'GET' && url.pathname === '/api/stream') {
125
226
  const agent = url.searchParams.get('agent');
126
227
  if (!agent) return send(400, { error: 'agent query param required' });
228
+ // Stream caps (v0.5.6): refuse BEFORE opening the stream — a 429 the
229
+ // client can back off from, not a socket that dies later.
230
+ const maxPerWorkspace = streamCap('GHOSTBUS_MAX_STREAMS_PER_WORKSPACE', 100);
231
+ const maxTotal = streamCap('GHOSTBUS_MAX_STREAMS_TOTAL', 1000);
232
+ if (activeStreams >= maxPerWorkspace) return send(429, { error: `too many event streams for this workspace (limit ${maxPerWorkspace})` });
233
+ if (activeStreamsTotal >= maxTotal) return send(429, { error: `too many event streams on this server (limit ${maxTotal})` });
234
+ activeStreams += 1; activeStreamsTotal += 1;
127
235
  res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
128
236
  res.write(`event: ready\ndata: ${JSON.stringify({ agent, workspace: id, version: GHOSTBUS_VERSION })}\n\n`);
237
+ let cleaned = false;
129
238
  const off = bus.onEvent((ev) => {
130
239
  const forMe = ev.type === 'message.send' ? (ev.to === agent || ev.to === '*' || ev.actor === agent) : true;
131
240
  if (!forMe) return;
132
- res.write(`event: ${ev.type}\ndata: ${JSON.stringify(ev)}\n\n`);
241
+ try { res.write(`event: ${ev.type}\ndata: ${JSON.stringify(ev)}\n\n`); } catch { cleanup(); }
133
242
  });
134
- const ping = setInterval(() => res.write(': ping\n\n'), 25000);
135
- req.on('close', () => { clearInterval(ping); off(); });
243
+ const ping = setInterval(() => { try { res.write(': ping\n\n'); } catch { cleanup(); } }, 25000);
244
+ // Reaping (v0.5.6): whichever side of the connection dies first —
245
+ // request close, response close, or a write into a dead socket —
246
+ // the listener, the ping, and both cap slots are released exactly
247
+ // once, so churn can neither leak listeners nor strand cap slots.
248
+ function cleanup() {
249
+ if (cleaned) return; cleaned = true;
250
+ clearInterval(ping); off();
251
+ activeStreams -= 1; activeStreamsTotal -= 1;
252
+ }
253
+ req.on('close', cleanup);
254
+ res.on('close', cleanup);
255
+ res.on('error', cleanup);
136
256
  return;
137
257
  }
138
258
 
139
259
  if (req.method === 'GET' && url.pathname === '/api/wait') {
140
260
  const agent = url.searchParams.get('agent');
141
261
  if (!agent) return send(400, { error: 'agent query param required' });
142
- const timeout = Math.min(Number(url.searchParams.get('timeout') || 25), 55) * 1000;
262
+ // Garbage timeouts (timeout=abc -> NaN -> an instant empty reply;
263
+ // negative -> the same) fall back to the 25s default; real values
264
+ // clamp into [0, 55]s (v0.5.5).
265
+ const tParam = Number(url.searchParams.get('timeout') || 25);
266
+ const timeout = (Number.isFinite(tParam) ? Math.min(Math.max(tParam, 0), 55) : 25) * 1000;
143
267
  const sinceSeq = url.searchParams.has('sinceSeq') ? Number(url.searchParams.get('sinceSeq')) : (await bus.sync({})).headSeq;
144
268
  const immediate = await bus.sync({ sinceSeq, limit: 100 });
145
269
  const relevantNow = immediate.events.filter(ev => ev.type !== 'message.send' || ev.to === agent || ev.to === '*' || ev.actor === agent);
@@ -37,7 +37,7 @@ import fs from 'node:fs';
37
37
  import path from 'node:path';
38
38
  import crypto from 'node:crypto';
39
39
  import { createBus, FileStore, GHOSTBUS_VERSION, validateSnapshot } from './core.mjs';
40
- import { createBusHandler, keyMatches, hashMatches, readJsonBody, MAX_BODY_BYTES, HTML_SECURITY_HEADERS } from './handler.mjs';
40
+ import { createBusHandler, keyMatches, hashMatches, readJsonBody, MAX_BODY_BYTES, HTML_SECURITY_HEADERS, applyServerTimeouts, SERVER_TIMEOUT_DEFAULTS } from './handler.mjs';
41
41
 
42
42
  const argv = process.argv.slice(2);
43
43
  const argVal = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : null; };
@@ -145,6 +145,9 @@ const authThrottled = (ip) => {
145
145
  };
146
146
  const noteAuthFail = (ip) => {
147
147
  const t = Date.now();
148
+ // Opportunistic sweep (v0.5.6): one entry per attacking IP per window
149
+ // would otherwise accumulate forever on a long-lived server.
150
+ if (authFails.size > 4096) { for (const [k, v] of authFails) if (t - v.start > AUTH_FAIL_WINDOW_MS) authFails.delete(k); }
148
151
  let b = authFails.get(ip);
149
152
  if (!b || t - b.start > AUTH_FAIL_WINDOW_MS) { b = { start: t, count: 0 }; authFails.set(ip, b); }
150
153
  b.count += 1;
@@ -316,6 +319,15 @@ const server = http.createServer(async (req, res) => {
316
319
  }
317
320
  });
318
321
 
322
+ // Explicit server timeouts (v0.5.6): slow-loris header/body deadlines, set
323
+ // here rather than inherited from Node defaults — see applyServerTimeouts
324
+ // in handler.mjs for the scoping (long-lived streams are never bounded).
325
+ const timeoutOpt = (envName, def) => { const n = Number(process.env[envName]); return Number.isFinite(n) && n > 0 ? Math.floor(n) : def; };
326
+ applyServerTimeouts(server, {
327
+ headersMs: timeoutOpt('GHOSTBUS_HEADERS_TIMEOUT_MS', SERVER_TIMEOUT_DEFAULTS.headersMs),
328
+ requestMs: timeoutOpt('GHOSTBUS_REQUEST_TIMEOUT_MS', SERVER_TIMEOUT_DEFAULTS.requestMs),
329
+ });
330
+
319
331
  server.listen(port, () => {
320
332
  process.stderr.write(`ghostbus HOSTED v${GHOSTBUS_VERSION} · http://127.0.0.1:${port} · data=${dataDir} · workspaces=${Object.keys(loadRegistry().workspaces).length} · agentTokens=${REQUIRE_TOKENS ? 'enforced' : 'issued, not enforced'}\n`);
321
333
  });
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import http from 'node:http';
16
16
  import { createBus, MemoryStore, FileStore, GHOSTBUS_VERSION } from './core.mjs';
17
- import { createBusHandler } from './handler.mjs';
17
+ import { createBusHandler, applyServerTimeouts, SERVER_TIMEOUT_DEFAULTS } from './handler.mjs';
18
18
 
19
19
  const argv = process.argv.slice(2);
20
20
  const argVal = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : null; };
@@ -32,6 +32,14 @@ const server = http.createServer((req, res) => {
32
32
  const url = new URL(req.url, 'http://localhost');
33
33
  handle(req, res, url).catch((e) => { process.stderr.write(`ghostbus relay: internal error: ${(e && e.stack) || e}\n`); try { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'internal error' })); } catch {} });
34
34
  });
35
+ // Explicit server timeouts (v0.5.6): slow-loris header/body deadlines, set
36
+ // here rather than inherited from Node defaults — see applyServerTimeouts
37
+ // in handler.mjs for the scoping (long-lived streams are never bounded).
38
+ const timeoutOpt = (envName, def) => { const n = Number(process.env[envName]); return Number.isFinite(n) && n > 0 ? Math.floor(n) : def; };
39
+ applyServerTimeouts(server, {
40
+ headersMs: timeoutOpt('GHOSTBUS_HEADERS_TIMEOUT_MS', SERVER_TIMEOUT_DEFAULTS.headersMs),
41
+ requestMs: timeoutOpt('GHOSTBUS_REQUEST_TIMEOUT_MS', SERVER_TIMEOUT_DEFAULTS.requestMs),
42
+ });
35
43
  server.listen(port, () => {
36
44
  process.stderr.write(`ghostbus http relay v${GHOSTBUS_VERSION} · http://127.0.0.1:${port} · store=${storePath} · auth=${KEY ? 'workspace key required' : 'open (set GHOSTBUS_KEY)'}\n`);
37
45
  // Node's listen() with no host binds ALL network interfaces, not just
@@ -56,6 +56,12 @@ rl.on('line', async (line) => {
56
56
  if (!line.trim()) return;
57
57
  let msg;
58
58
  try { msg = JSON.parse(line); } catch { return; }
59
- await handle(msg);
59
+ // A JSON-RPC message is a plain object. Anything else on the wire — null,
60
+ // a bare string/number, an array — is ignored, never fatal (v0.5.5: one
61
+ // bare `null` line used to throw in handle() and kill the server process
62
+ // for every connected agent). And handle() itself is wrapped: one bad
63
+ // message must never take the bus down.
64
+ if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return;
65
+ try { await handle(msg); } catch { /* a malformed message is dropped, not fatal */ }
60
66
  });
61
67
  process.stderr.write(`ghostbus mcp-server v${GHOSTBUS_VERSION} · store=${storePath} · workspace="${wsName}"\n`);
package/src/tools.mjs CHANGED
@@ -35,7 +35,45 @@ export const TOOLS = [
35
35
  { name: 'bus_events', description: 'Read the provenance event log (who did what, when).', inputSchema: { type: 'object', properties: { limit: { type: 'number', default: 50 } } } },
36
36
  ];
37
37
 
38
+ // Argument validation (v0.5.5): an MCP client — or a prompt-injected agent
39
+ // driving one — can send ANY argument shapes, and the core bus deliberately
40
+ // coerces a lot (String(body) turned an object body into a stored
41
+ // "[object Object]" message; a numeric `path` became a state key). The
42
+ // tools layer is the trust boundary, so arguments are checked against the
43
+ // tool's own declared inputSchema here, once, instead of per-tool ad-hoc
44
+ // checks: required arguments must be present (null counts as missing, the
45
+ // codebase's absent-value convention), and every provided argument must
46
+ // match its declared type — numbers must be finite, objects must be plain
47
+ // objects (not arrays/null). Undeclared extra arguments pass through
48
+ // untouched (several tools read an undeclared `token`). Violations throw a
49
+ // plain Error, which both transports already surface as a clean isError /
50
+ // 400 result — never a crash, never coerced garbage in shared state.
51
+ const ARG_TYPE_CHECKS = {
52
+ string: { ok: (v) => typeof v === 'string', label: 'a string' },
53
+ number: { ok: (v) => typeof v === 'number' && Number.isFinite(v), label: 'a finite number' },
54
+ boolean: { ok: (v) => typeof v === 'boolean', label: 'a boolean' },
55
+ array: { ok: (v) => Array.isArray(v), label: 'an array' },
56
+ object: { ok: (v) => !!v && typeof v === 'object' && !Array.isArray(v), label: 'an object' },
57
+ };
58
+ function validateToolArgs(tool, args) {
59
+ if (!args || typeof args !== 'object' || Array.isArray(args)) {
60
+ throw new Error(`tool ${tool.name}: arguments must be an object`);
61
+ }
62
+ const schema = tool.inputSchema || {};
63
+ for (const k of schema.required || []) {
64
+ if (args[k] === undefined || args[k] === null) throw new Error(`tool ${tool.name}: missing required argument "${k}"`);
65
+ }
66
+ for (const [k, spec] of Object.entries(schema.properties || {})) {
67
+ const v = args[k];
68
+ if (v === undefined || v === null) continue; // absent optional — the ?? defaults below apply
69
+ const check = ARG_TYPE_CHECKS[spec.type];
70
+ if (check && !check.ok(v)) throw new Error(`tool ${tool.name}: argument "${k}" must be ${check.label}`);
71
+ }
72
+ }
73
+
38
74
  export async function callTool(bus, name, args = {}) {
75
+ const tool = TOOLS.find(t => t.name === name);
76
+ if (tool) validateToolArgs(tool, args);
39
77
  switch (name) {
40
78
  case 'bus_status': return bus.status();
41
79
  case 'bus_register': return bus.registerAgent({ name: args.agent, role: args.role, capabilities: args.capabilities, token: args.token ?? null });