ghostbus 0.5.0 → 0.5.3
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 +36 -0
- package/PLAN.md +1 -1
- package/README.md +29 -2
- package/docs/hosted.md +80 -3
- package/package.json +1 -1
- package/src/core.mjs +111 -36
- package/src/handler.mjs +63 -5
- package/src/hosted-server.mjs +95 -19
- package/src/http-server.mjs +13 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,41 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.5.3 — 2026-10-11
|
|
4
|
+
|
|
5
|
+
Security hardening, round 2 — the layer under the v0.5.2 defaults. v0.5.2 made enforcement the default; this round removes the sharp edges an attacker could still lean on: non-timing-safe secret compares, unthrottled key guessing at the hosted layer, unbounded request bodies, framable HTML pages, world-readable data files, and internal error text leaking to clients.
|
|
6
|
+
|
|
7
|
+
- **Timing-safe secret comparisons everywhere** — the hosted router compared workspace keys with `sha256(given) === meta.keyHash` (plain `===` on a digest), and the core compared agent-token hashes the same way. Both now use `crypto.timingSafeEqual` over the digests (new exported `hashMatches` in `src/handler.mjs`, shared by the router, the core token check, and the serverless pack, whose `wsKeyOk` had the same plain compare). A full-codebase audit found no other secret comparisons.
|
|
8
|
+
- **Failed-auth throttle at the hosted layer** (`src/hosted-server.mjs`) — the handler's 300 req/min limiter only throttles traffic already inside a workspace; nothing slowed down guessing admin or workspace keys. Now: 30 failed auth attempts per minute per IP → `429` on the admin surface and on workspace routes until the window rolls. Successful auth never counts; `/health` and `/probe` stay open so an operator can watch an attack happen.
|
|
9
|
+
- **Request bodies capped at 1 MB** (`readJsonBody` in `src/handler.mjs`) — both body readers accumulated without a cap (past a 5 MB socket-destroy with no useful response). One shared reader now enforces a commented `MAX_BODY_BYTES = 1_000_000` on hosted admin routes, in-workspace routes, and the single relay; over the cap the client gets a clean `413` JSON error and the socket is dropped after the response. File contents travel as JSON in GhostBus, so legitimate traffic fits comfortably; a workspace *import* over 1 MB must be restored from a state file / the CLI instead of over HTTP (stated in `docs/hosted.md`).
|
|
10
|
+
- **HTML pages carry frame/CSP protection** — the board (`src/handler.mjs`) and the hosted landing page are served with `X-Frame-Options: DENY` and a `Content-Security-Policy`: `default-src 'self'`, `frame-ancestors 'none'`, `object-src 'none'`. Both pages are single self-contained documents (inline script/style, same-origin fetches, zero external resources), so the CSP allows `'unsafe-inline'` for script/style specifically — no eval, no external origins.
|
|
11
|
+
- **Data files are written 0600** — the registry and workspace state files hold every key/token hash; `FileStore.save` (core, so the single relay benefits too) and `saveRegistry` plus the hosted import write now create their temp files with mode 0600 (plus a chmod before the atomic rename), and the mode rides through the rename. Existing files tighten on their next save.
|
|
12
|
+
- **Error hygiene** — the single relay and the hosted server returned raw `e.message` in 500s. Both now log the real error (with stack) to stderr and answer a generic `{ error: 'internal error' }`. The handler's 400s are unchanged on purpose: those are deliberate, client-facing validation/auth errors thrown by the core, not internal faults.
|
|
13
|
+
- **Admin audit log** (`src/hosted-server.mjs`) — every admin action (workspace create, delete, rotate-key, export, import) emits one structured stderr line: timestamp, action, workspace id, source IP. Never a key, never a hash. On a paid host, that stderr stream is the operator's audit trail.
|
|
14
|
+
- **SECURITY.md** — new at the repo root: latest-release-only support, private reporting to gdev6145@gmail.com, one-business-day acknowledgement (the studio's stated support posture), and scope notes.
|
|
15
|
+
- **Tests** — three new checks (54 in the main suite, was 51): HTML headers + 0600 file modes + audit lines (create/rotate/delete, asserting no key material appears in stderr); the 1 MB cap returning 413 on hosted-admin, in-workspace, and single-relay routes; and the failed-auth throttle (30 bad keys → 401 each, then even the correct key → 429, admin too, health still 200). All three fail against the 0.5.2 servers (51 passed, 3 failed, each for its intended reason) and pass with the change; serverless stays 6/6 and both demos green. The timing-safe change is covered functionally by the existing right-key/wrong-key checks at every layer — a timing side channel is not observable from a functional test, so that one is verified by the audit above rather than a red/green run.
|
|
16
|
+
- **Scope boundary, honestly** — what did NOT change: no API or behavior change for legitimate clients; storage format and locations; the local stdio/CLI trust model; the serverless pack's opt-in token posture (only its key compare was aligned); the deferred networked multi-writer store is still future work. One judgment call to know about: throttling is per-IP and in-memory — behind a shared NAT, one guesser can 429 a whole office's *unauthenticated* attempts for a minute; authenticated traffic with a valid key is only blocked while the throttle is tripped, and the window is short by design.
|
|
17
|
+
|
|
18
|
+
## 0.5.2 — 2026-10-11
|
|
19
|
+
|
|
20
|
+
Security hardening — the pre-pilot pass. A cold source review scored GhostBus 7.4/10 with security maturity its lowest mark (5.5/10, "authentication enforcement and deployment hardening need attention"). That was the right call to answer before any hosted workspace carries a stranger's work — or their money:
|
|
21
|
+
|
|
22
|
+
- **Hosted: per-agent token enforcement is now the DEFAULT** (`src/hosted-server.mjs`) — every workspace bus is created with enforcement on, no env var needed. The only opt-out is an explicit `GHOSTBUS_REQUIRE_TOKENS=0`, and using it prints a loud three-line stderr warning at startup (with enforcement off, anyone holding a workspace key can act under any agent name). Header comment updated to match.
|
|
23
|
+
- **Hosted: admin-key floor** — the hosted server already refused to start without `GHOSTBUS_ADMIN_KEY`; it now also refuses keys shorter than 16 characters (exit 2). The admin key is operator root (creates/deletes workspaces, master key inside all of them) and gets a floor to match. Storage/rotation guidance (root-only file, chmod 600, rotate by restart) is documented in `docs/hosted.md`.
|
|
24
|
+
- **Single relay: exposure is never silent** (`src/http-server.mjs`) — Node's `listen()` binds all network interfaces, not just loopback. Starting the relay without `GHOSTBUS_KEY` now prints a loud warning saying exactly that; with a key but token enforcement off, a one-line warning names the impersonation trade-off. Semantics unchanged: local trust is still the relay's default model.
|
|
25
|
+
- **Docs** — new "Security & deployment hardening" section in `docs/hosted.md`: enforcement default + opt-out, HTTPS REQUIRED for any public deployment (reverse proxy or a *named* Cloudflare Tunnel; a quick tunnel is explicitly not for paid use), admin-key storage/rotation, per-workspace key handling, scheduled export backups. README hosted section points at it.
|
|
26
|
+
- **Tests** — the two existing hosted checks now present the tokens issued at registration (they exercise the enforced path, as real clients now must), and four new checks cover the pass: default-on enforcement over real HTTP (no token → 400, wrong token → 400, issued token → 200, header and body forms), the `=0` opt-out + its startup warning, the short-admin-key refusal, and the relay's no-key warning. All four fail against the 0.5.1 servers (47 passed, 4 failed) and pass with the change.
|
|
27
|
+
- **Scope boundary, honestly** — what did NOT change: core bus behavior, the local stdio/CLI trust model (tokens stay opt-in there via `GHOSTBUS_REQUIRE_TOKENS=1`), the single relay's opt-in posture beyond the new warnings, storage (memory/JSON file; a networked multi-writer store is still future work, not faked here), and the serverless pack (its `GHOSTBUS_REQUIRE_TOKENS` env remains opt-in). The hosted `open` surfaces — board page, `/health`, `/probe` — were audited, not changed: every workspace is always created with a key and no data route bypasses it; the three open paths serve the static board shell, liveness, and counts-only probe data, the same posture as the single relay, and are documented as such.
|
|
28
|
+
- 51 checks in the main suite (was 47) + 6 serverless, all passing; both demos green.
|
|
29
|
+
|
|
30
|
+
## 0.5.1 — 2026-10-09
|
|
31
|
+
|
|
32
|
+
Reliability fix — the operation-level race above the v0.4.1 FileStore fix:
|
|
33
|
+
|
|
34
|
+
- **Per-Bus mutation mutex** — every public operation on a `GhostBus` (register/heartbeat, message send, task create/claim/complete/comment, file put/delete, capsule/context writes, token ops, snapshot restore, and reads) now runs its whole load → mutate → save cycle inside a per-instance promise-chain mutex (`_serialized` in src/core.mjs), made reentrant via AsyncLocalStorage so operations that call other operations (updateCapsule → getCapsule, restore → status) can't self-deadlock. Before this fix, concurrent calls on ONE bus instance interleaved their cycles against the shared store and silently lost each other's updates: a 4-writer × 12-round probe through the public Bus API ended with one writer's file stuck at an early round and another's missing entirely, with zero errors reported. A throwing operation still releases the chain (try/finally), so one failure can never wedge the bus. Public API and behavior are unchanged.
|
|
35
|
+
- **Scope boundary, honestly** — the mutex serializes operations within one Bus instance in one process. It is not a distributed lock: separate processes sharing one store file still coordinate only at single-operation granularity (each op remains an atomic read-modify-write with the v0.4.1 temp-file fix), and the serverless pack (deploy/netlify) builds a fresh bus per request over an external KV, so cross-invocation interleavings there remain bounded by the KV layer, not by this mutex.
|
|
36
|
+
- **Regression tests in the main suite** — three new checks: concurrent writers lose no updates (4 writers × 12 rounds, verified on disk via a fresh instance), concurrent mixed ops (tasks + messages + files at once) all land with unique task ids, and a throwing operation releases the mutex (with a wedge timeout). All three fail on the pre-fix core (44 passed, 3 failed) and pass with the fix.
|
|
37
|
+
- 47 checks in the main suite (was 44) + 6 serverless, all passing; both demos green.
|
|
38
|
+
|
|
3
39
|
## 0.5.0 — 2026-10-08
|
|
4
40
|
|
|
5
41
|
The operator release — running a hosted bus day-to-day:
|
package/PLAN.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# GhostBus — Agent-to-Agent Message Bus / Shared Workspace MCP
|
|
2
2
|
|
|
3
|
-
**Status:** PUBLISHED — v0.5.
|
|
3
|
+
**Status:** PUBLISHED — v0.5.1 live Oct 9, 2026 (github.com/littlestjames82-sys/ghostbus). v0.5.1 is a reliability patch: a per-Bus mutation mutex in the core serializes every operation's load → mutate → save cycle, closing the silent lost-update race under concurrent writers on one bus instance — 47/47 + 6/6 with three new concurrency regression checks (all red on the pre-fix core). v0.5.0 is the operator release: hosted workspace key rotation (old key dies immediately), full-state export/import for backup + host migration (fresh key on import, keys never inside exports), core snapshot/restore + CLI export/import, and the FileStore race regression test in the main suite — 44/44 + 6/6. v0.4.0 added the hosted multi-workspace server + Docker + Netlify serverless pack (41/41 + 6/6 tests). v0.4.1 fixed the FileStore temp-file race (unique temp per save + serialized saves; stress 0 failures vs 59/60 failing before), shipped the docker-compose + one-shot VPS setup assets, and passed a full hosted smoke test. Still not deployed to a public host — that waits on Ryan's VPS choice. npm: name `ghostbus` verified available; publish rides the Trusted-Publishing workflow after Ryan's one-time npm setup (see LAUNCH.md).
|
|
4
4
|
**Origin:** Generalizes Ghost Bridge (Ryan's private Muse ↔ Antigravity relay, Level 3, live since Oct 6) into a standalone product any agents can join. Ghost Bridge stays private; GhostBus is the clean, general version — same proven patterns, no Ryan-specific context, no studio data in the box.
|
|
5
5
|
|
|
6
6
|
## The problem (one line)
|
package/README.md
CHANGED
|
@@ -6,12 +6,24 @@ Agents today work alone in separate windows — when two of them need to coopera
|
|
|
6
6
|
|
|
7
7
|
Zero dependencies. Node 18+. MIT.
|
|
8
8
|
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install ghostbus
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
GhostBus is published on npm as [`ghostbus`](https://www.npmjs.com/package/ghostbus) (v0.5.0) — zero dependencies, Node 18+. You can also run it straight from a clone of this repository, as the quick start below shows.
|
|
16
|
+
|
|
17
|
+
## Status
|
|
18
|
+
|
|
19
|
+
Built and in use: 29 MCP tools across presence, messages, tasks, a shared workspace, and a provenance log; local stdio servers, an HTTP relay, and a hosted multi-workspace server are all included in this repo, along with a live web board, SSE event pushes, per-agent tokens, and a terminal CLI. See `CHANGELOG.md` for release history and `docs/` for the client setup and hosted-operator guides.
|
|
20
|
+
|
|
9
21
|
## Quick start
|
|
10
22
|
|
|
11
23
|
```bash
|
|
12
|
-
git clone
|
|
24
|
+
git clone https://github.com/littlestjames82-sys/ghostbus.git ghostbus && cd ghostbus
|
|
13
25
|
node examples/two-agent-demo.mjs # watch a planner and a builder cooperate
|
|
14
|
-
node test.mjs #
|
|
26
|
+
node test.mjs # 54 checks: core, tokens, capsules, dependencies, sync, persistence, concurrency, MCP stdio, HTTP, SSE, CLI, hosted security
|
|
15
27
|
node examples/three-agent-team.mjs # planner + builder + reviewer, no human relaying
|
|
16
28
|
```
|
|
17
29
|
|
|
@@ -46,6 +58,8 @@ GHOSTBUS_ADMIN_KEY=pick-a-long-secret node src/hosted-server.mjs --port 8388
|
|
|
46
58
|
|
|
47
59
|
One process serves many **isolated** workspaces under `/w/<id>/` — each with its own key (issued once at creation, hash-only in the registry), board, REST, MCP, SSE and probe. The admin API creates/lists/deletes workspaces, **rotates keys** (old key dies instantly), and **exports/imports** full workspace state for backup and host migration — imports always mint a fresh key. A Dockerfile and a serverless Netlify/Blobs pack are included. Operator guide: `docs/hosted.md`.
|
|
48
60
|
|
|
61
|
+
Hosted mode is the deployment that serves strangers, so it is hardened by default (v0.5.2): per-agent tokens are **enforced** in every workspace (`GHOSTBUS_REQUIRE_TOKENS=0` opts out, loudly), the admin key must be at least 16 characters, and any public deployment belongs behind HTTPS. Details: the "Security & deployment hardening" section of `docs/hosted.md`.
|
|
62
|
+
|
|
49
63
|
Same tools over `POST /mcp`, plus a REST mirror under `/api/*`. `/health` and a counts-only `/probe` stay open for monitoring and wake hooks. A **live web board** is served at `/`, and agents can subscribe to **pushed events** over SSE (`GET /api/stream?agent=<name>`) instead of polling. Set `GHOSTBUS_REQUIRE_TOKENS=1` to enforce the per-agent tokens issued at registration. There's also a terminal CLI (`src/cli.mjs`) and full client setup docs in `docs/client-setup.md`.
|
|
50
64
|
|
|
51
65
|
## The 29 tools
|
|
@@ -77,6 +91,19 @@ Same tools over `POST /mcp`, plus a REST mirror under `/api/*`. `/health` and a
|
|
|
77
91
|
|
|
78
92
|
GhostBus generalizes a private relay that has run in production for one studio's own agents since Oct 2026 (task handoffs, shared project capsules, a live board). This is the clean, general version of that proven pattern. Built by Ghost Developer Studio.
|
|
79
93
|
|
|
94
|
+
## From Ghost Developer Studio
|
|
95
|
+
|
|
96
|
+
GhostBus is one part of the studio's agent-infrastructure family:
|
|
97
|
+
|
|
98
|
+
- **Visual roadmap board** — where every studio product stands, in one picture: [ghost-roadmaps](https://github.com/littlestjames82-sys/ghost-roadmaps)
|
|
99
|
+
- **GhostGuard** — the governance layer for autonomous systems: [ghostguard](https://github.com/littlestjames82-sys/ghostguard)
|
|
100
|
+
- **Ghost Bridge** — MCP bridge + self-hostable relay for handing tasks to a personal agent, with scoped keys and approvals: [ghost-bridge](https://github.com/littlestjames82-sys/ghost-bridge)
|
|
101
|
+
- **Ghost Hands** — governed, recorded, replayable agent hands for the web: [ghost-hands](https://github.com/littlestjames82-sys/ghost-hands)
|
|
102
|
+
- **Agent Seatbelt** — deterministic guardrails that gate what an AI coding agent does before it does it: [agent-seatbelt](https://github.com/littlestjames82-sys/agent-seatbelt)
|
|
103
|
+
- **Ghost Developer Studio storefront** — [storefront](https://github.com/littlestjames82-sys/storefront)
|
|
104
|
+
|
|
105
|
+
Built in public by Ryan Cotten / Ghost Developer Studio.
|
|
106
|
+
|
|
80
107
|
## License
|
|
81
108
|
|
|
82
109
|
MIT — see `LICENSE`.
|
package/docs/hosted.md
CHANGED
|
@@ -53,20 +53,97 @@ Everything from the single-workspace relay works under the prefix:
|
|
|
53
53
|
|
|
54
54
|
- Board: `https://host/w/acme/` (asks for the workspace key in-page)
|
|
55
55
|
- REST: `https://host/w/acme/api/*` with header `x-bus-key: <workspace key>`
|
|
56
|
-
- MCP: `POST https://host/w/acme/mcp` (same header; plus `x-agent-token`
|
|
56
|
+
- MCP: `POST https://host/w/acme/mcp` (same header; plus `x-agent-token` — per-agent token enforcement is ON by default in hosted mode, see below)
|
|
57
57
|
- Probe (open, counts only): `GET https://host/w/acme/probe`
|
|
58
58
|
- Push: SSE at `/w/acme/api/stream?agent=…`, long-poll at `/w/acme/api/wait?agent=…`
|
|
59
59
|
|
|
60
60
|
The admin key also works as a master key inside any workspace — treat it like root: operators only, never hand it to agents.
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
## Security & deployment hardening
|
|
63
|
+
|
|
64
|
+
The hosted server is the one GhostBus deployment that serves people other than
|
|
65
|
+
the operator, so its defaults are stricter than the local relay's (v0.5.2):
|
|
66
|
+
|
|
67
|
+
- **Per-agent tokens are enforced by default** in every workspace: an agent
|
|
68
|
+
registers once, receives its token once, and must present it
|
|
69
|
+
(`x-agent-token` header or `token` field) for every act. A stolen agent
|
|
70
|
+
*name* alone cannot impersonate it. `GHOSTBUS_REQUIRE_TOKENS=0` is the only
|
|
71
|
+
opt-out — the server prints a loud startup warning when it is used, because
|
|
72
|
+
with enforcement off, anyone holding a workspace key can act under any
|
|
73
|
+
agent name. Don't disable it for a public or paid deployment.
|
|
74
|
+
- **HTTPS is REQUIRED for any public deployment.** The server itself speaks
|
|
75
|
+
plain HTTP; terminate TLS in front of it — a reverse proxy with automatic
|
|
76
|
+
certificates (Caddy, nginx + certbot) or a **named** Cloudflare Tunnel with
|
|
77
|
+
a stable hostname. A Cloudflare *quick* tunnel (`trycloudflare.com`) is
|
|
78
|
+
fine for a demo and **not** for paid use: its URL changes on restart and it
|
|
79
|
+
carries no uptime expectation. Never expose the plain-HTTP port directly.
|
|
80
|
+
- **Admin key hygiene.** The admin key is operator root: it creates and
|
|
81
|
+
deletes workspaces, rotates their keys, exports their state, and works as a
|
|
82
|
+
master key inside every workspace. The server refuses to start with a key
|
|
83
|
+
shorter than 16 characters; use 32+ random hex. Store it in a root-only
|
|
84
|
+
file, never in the repo, a ticket, or a chat log:
|
|
85
|
+
```bash
|
|
86
|
+
head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n' > /root/ghostbus-admin-key
|
|
87
|
+
chmod 600 /root/ghostbus-admin-key
|
|
88
|
+
GHOSTBUS_ADMIN_KEY=$(cat /root/ghostbus-admin-key) node src/hosted-server.mjs
|
|
89
|
+
```
|
|
90
|
+
(The shipped `deploy/docker/vps-setup.sh` already generates it this way.)
|
|
91
|
+
To rotate the admin key, restart the server with a new one — existing
|
|
92
|
+
workspace keys are unaffected (they live in the registry as hashes, not
|
|
93
|
+
derived from the admin key).
|
|
94
|
+
- **Per-workspace keys.** Each workspace key is shown exactly once, at
|
|
95
|
+
creation (or import); only its SHA-256 is stored. Hand it to that
|
|
96
|
+
workspace's owner over a secure channel, never publish it, and if one
|
|
97
|
+
leaks, rotate it — `POST /api/workspaces/<id>/rotate-key` kills the old
|
|
98
|
+
key immediately (see above).
|
|
99
|
+
- **Backups are a schedule, not a feature.** The data dir *is* the state
|
|
100
|
+
(`registry.json` + one `<id>.json` per workspace), so back it up on a
|
|
101
|
+
timer, and per-workspace exports give you portable copies:
|
|
102
|
+
```bash
|
|
103
|
+
# nightly, e.g. from cron — plus a plain copy of the data dir
|
|
104
|
+
curl -s localhost:8388/api/workspaces/acme/export -H 'x-bus-key: <admin>' > backups/acme-$(date +%F).json
|
|
105
|
+
```
|
|
106
|
+
Drill a restore (delete + import) before you need one; a restore lands in
|
|
107
|
+
the workspace's provenance log as `workspace.restore`.
|
|
108
|
+
|
|
109
|
+
Round 2 (v0.5.3) closed the next layer:
|
|
110
|
+
|
|
111
|
+
- **Timing-safe secret comparisons everywhere.** Workspace keys (hosted
|
|
112
|
+
router + serverless pack) and per-agent tokens (core) are compared with
|
|
113
|
+
`crypto.timingSafeEqual` over SHA-256 digests — no `===` on secrets
|
|
114
|
+
anywhere in the codebase.
|
|
115
|
+
- **Failed-auth throttling at the hosted layer.** The in-workspace limiter
|
|
116
|
+
(300 req/min/IP) never covered *guessing*: 30 failed auth attempts per
|
|
117
|
+
minute from one IP now gets 429s on the admin surface and on workspace
|
|
118
|
+
routes until the window rolls. Successful auth never counts toward it;
|
|
119
|
+
`/health` and `/probe` stay open so operators can see an attack.
|
|
120
|
+
- **Request bodies are capped at 1 MB** on every JSON route (hosted admin,
|
|
121
|
+
in-workspace, single relay). Over the cap: a clean `413` JSON error.
|
|
122
|
+
File contents travel as JSON in GhostBus, so legitimate files fit
|
|
123
|
+
comfortably — but a workspace *import* larger than 1 MB must be restored
|
|
124
|
+
by placing the state file in the data dir (or via the CLI), not over HTTP.
|
|
125
|
+
- **HTML pages can't be framed and carry a CSP.** The board and the hosted
|
|
126
|
+
landing page answer with `X-Frame-Options: DENY` and a
|
|
127
|
+
`Content-Security-Policy` (`default-src 'self'`, `frame-ancestors 'none'`;
|
|
128
|
+
inline script/style are allowed specifically because both pages are
|
|
129
|
+
single self-contained documents with zero external resources).
|
|
130
|
+
- **Data files are written owner-only (0600).** The registry and every
|
|
131
|
+
workspace state file hold key/token hashes; they are created 0600 through
|
|
132
|
+
the same atomic temp+rename writes as before.
|
|
133
|
+
- **Admin actions are audit-logged.** Every workspace create, delete,
|
|
134
|
+
rotate-key, export, and import writes one structured stderr line
|
|
135
|
+
(timestamp, action, workspace id, source IP) — never a key or a hash.
|
|
136
|
+
Point the hosted process's stderr at your log collector and that *is*
|
|
137
|
+
the operator audit trail.
|
|
138
|
+
- **Reporting a vulnerability:** see `SECURITY.md` at the repo root
|
|
139
|
+
(private email reporting, one-business-day acknowledgement).
|
|
63
140
|
|
|
64
141
|
## Option B — serverless on Netlify (Blobs)
|
|
65
142
|
|
|
66
143
|
`deploy/netlify/` contains a Netlify Function implementing the same multi-workspace semantics over Netlify Blobs (strong consistency):
|
|
67
144
|
|
|
68
145
|
1. Create a Netlify site using `deploy/netlify/` as the site root (or copy `functions/` + `netlify.toml` into a site).
|
|
69
|
-
2. Set site env: `GHOSTBUS_ADMIN_KEY` (required), optionally `GHOSTBUS_REQUIRE_TOKENS=1`.
|
|
146
|
+
2. Set site env: `GHOSTBUS_ADMIN_KEY` (required), optionally `GHOSTBUS_REQUIRE_TOKENS=1`. (The serverless pack keeps token enforcement opt-in via this env; the Node hosted server above enforces by default since v0.5.2.)
|
|
70
147
|
3. Add `@netlify/blobs` to that site's dependencies (`npm i @netlify/blobs` in the site root) and deploy.
|
|
71
148
|
|
|
72
149
|
The routing, admin API, per-workspace keys, and REST/MCP surface match Option A, with two honest differences: **no board page, no SSE, no long-poll** (serverless invocations can't hold them) — agents poll `/w/<id>/probe` + inbox or use `bus_sync`. State per workspace is one Blobs entry, written per request from a fresh bus instance; concurrent writes to the *same* workspace can race (last writer wins a single operation), so very hot single-workspace traffic belongs on Option A.
|
package/package.json
CHANGED
package/src/core.mjs
CHANGED
|
@@ -12,9 +12,16 @@
|
|
|
12
12
|
import fs from 'node:fs';
|
|
13
13
|
import path from 'node:path';
|
|
14
14
|
import crypto from 'node:crypto';
|
|
15
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
15
16
|
|
|
16
|
-
export const GHOSTBUS_VERSION = '0.5.
|
|
17
|
+
export const GHOSTBUS_VERSION = '0.5.3';
|
|
17
18
|
const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
|
|
19
|
+
// Timing-safe equality for hex digests (v0.5.3): secret comparisons must not
|
|
20
|
+
// short-circuit on the first differing character the way === does.
|
|
21
|
+
const hashEqHex = (aHex, bHex) => {
|
|
22
|
+
const a = Buffer.from(String(aHex)); const b = Buffer.from(String(bHex));
|
|
23
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
24
|
+
};
|
|
18
25
|
export const TASK_STATUSES = ['queued', 'claimed', 'needs-approval', 'done', 'cancelled'];
|
|
19
26
|
const CLAIM_LEASE_MS = 15 * 60 * 1000;
|
|
20
27
|
|
|
@@ -61,7 +68,11 @@ export class FileStore {
|
|
|
61
68
|
await fs.promises.mkdir(path.dirname(this.file), { recursive: true });
|
|
62
69
|
const tmp = this.file + '.tmp.' + process.pid + '.' + Math.random().toString(36).slice(2, 10);
|
|
63
70
|
try {
|
|
64
|
-
|
|
71
|
+
// State files hold token hashes — owner-only (0600) from birth
|
|
72
|
+
// (v0.5.3). The mode rides through the atomic rename; the chmod
|
|
73
|
+
// covers filesystems/umasks that would otherwise widen it.
|
|
74
|
+
await fs.promises.writeFile(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
75
|
+
await fs.promises.chmod(tmp, 0o600).catch(() => {});
|
|
65
76
|
await fs.promises.rename(tmp, this.file); // atomic replace
|
|
66
77
|
} finally {
|
|
67
78
|
await fs.promises.unlink(tmp).catch(() => {});
|
|
@@ -82,12 +93,44 @@ export class GhostBus {
|
|
|
82
93
|
// Registration issues the token once; only its sha256 is stored.
|
|
83
94
|
this.requireTokens = !!opts.requireTokens;
|
|
84
95
|
this.listeners = new Set(); // live event subscribers (SSE relay)
|
|
96
|
+
// Mutation mutex (v0.5.1): a promise chain serializing every public
|
|
97
|
+
// operation's load -> mutate -> save cycle on THIS instance. Without it,
|
|
98
|
+
// concurrent calls interleave their cycles against the shared store and
|
|
99
|
+
// silently lose each other's updates (one op's reload discards another's
|
|
100
|
+
// not-yet-saved state). The AsyncLocalStorage marker makes the chain
|
|
101
|
+
// reentrant: public methods that call other public methods (updateCapsule
|
|
102
|
+
// -> getCapsule, restore -> status, heartbeat -> listAgents) run inline
|
|
103
|
+
// instead of deadlocking on their own queue.
|
|
104
|
+
this._chain = Promise.resolve();
|
|
105
|
+
this._als = new AsyncLocalStorage();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Run `fn` inside this bus's serialized section. Callers queue FIFO on the
|
|
110
|
+
* promise chain; a throwing operation still releases the chain (finally),
|
|
111
|
+
* so one failure can never wedge the bus. Scope: per Bus instance, in this
|
|
112
|
+
* process — it cannot span processes sharing one store file (each op there
|
|
113
|
+
* is still a single atomic read-modify-write) nor serverless invocations.
|
|
114
|
+
*/
|
|
115
|
+
async _serialized(fn) {
|
|
116
|
+
if (this._als.getStore() === this) return fn();
|
|
117
|
+
const prev = this._chain;
|
|
118
|
+
let release;
|
|
119
|
+
const gate = new Promise((resolve) => { release = resolve; });
|
|
120
|
+
this._chain = prev.then(() => gate);
|
|
121
|
+
await prev;
|
|
122
|
+
try {
|
|
123
|
+
return await this._als.run(this, fn);
|
|
124
|
+
} finally {
|
|
125
|
+
release();
|
|
126
|
+
}
|
|
85
127
|
}
|
|
86
128
|
|
|
87
129
|
/** Subscribe to provenance events as they happen. Returns an unsubscribe fn. */
|
|
88
130
|
onEvent(fn) { this.listeners.add(fn); return () => this.listeners.delete(fn); }
|
|
89
131
|
|
|
90
|
-
async init() {
|
|
132
|
+
async init() { return this._serialized(() => this._init()); }
|
|
133
|
+
async _init() {
|
|
91
134
|
// Shared (file) stores are re-read on every call: several processes
|
|
92
135
|
// (stdio servers, the CLI, a relay) can then share ONE workspace file
|
|
93
136
|
// and actually see each other's writes. Each mutation is a fresh
|
|
@@ -127,7 +170,7 @@ export class GhostBus {
|
|
|
127
170
|
const a = this._agent(name);
|
|
128
171
|
if (!a) throw Object.assign(new Error(`agent not registered: ${name}`), { code: 'UNKNOWN_AGENT' });
|
|
129
172
|
if (this.requireTokens && a.tokenHash) {
|
|
130
|
-
if (!token || sha256(token)
|
|
173
|
+
if (!token || !hashEqHex(sha256(token), a.tokenHash)) {
|
|
131
174
|
throw Object.assign(new Error(`agent token required or wrong for: ${name}`), { code: 'AGENT_AUTH' });
|
|
132
175
|
}
|
|
133
176
|
}
|
|
@@ -136,7 +179,8 @@ export class GhostBus {
|
|
|
136
179
|
}
|
|
137
180
|
|
|
138
181
|
// ---------- agents ----------
|
|
139
|
-
async registerAgent({
|
|
182
|
+
async registerAgent(...args) { return this._serialized(() => this._registerAgent(...args)); }
|
|
183
|
+
async _registerAgent({ name, role = '', capabilities = [], token = null }) {
|
|
140
184
|
await this.init();
|
|
141
185
|
if (!name || typeof name !== 'string') throw new Error('agent name required');
|
|
142
186
|
name = name.trim();
|
|
@@ -163,7 +207,8 @@ export class GhostBus {
|
|
|
163
207
|
return { agent: this._publicAgent(agent), rejoined: false, token: rawToken, tokenNote: 'Store this token — it is shown once and only its hash is kept. Present it as `token` (tools) or x-agent-token (HTTP) when the bus runs with requireTokens.' };
|
|
164
208
|
}
|
|
165
209
|
|
|
166
|
-
async rotateAgentToken({
|
|
210
|
+
async rotateAgentToken(...args) { return this._serialized(() => this._rotateAgentToken(...args)); }
|
|
211
|
+
async _rotateAgentToken({ name, token = null }) {
|
|
167
212
|
await this.init();
|
|
168
213
|
const a = this._requireAgent(name, token);
|
|
169
214
|
const rawToken = crypto.randomBytes(24).toString('hex');
|
|
@@ -173,16 +218,19 @@ export class GhostBus {
|
|
|
173
218
|
return { agent: this._publicAgent(a), token: rawToken, tokenNote: 'New token — the old one no longer works. Shown once.' };
|
|
174
219
|
}
|
|
175
220
|
|
|
176
|
-
async heartbeat({
|
|
221
|
+
async heartbeat(...args) { return this._serialized(() => this._heartbeat(...args)); }
|
|
222
|
+
async _heartbeat({ name, token = null }) {
|
|
177
223
|
await this.init();
|
|
178
224
|
const a = this._requireAgent(name, token);
|
|
179
225
|
return { agent: this._publicAgent(a), agents: await this.listAgents() };
|
|
180
226
|
}
|
|
181
227
|
|
|
182
|
-
async listAgents() {
|
|
228
|
+
async listAgents(...args) { return this._serialized(() => this._listAgents(...args)); }
|
|
229
|
+
async _listAgents() { await this.init(); return this.state.agents.map(a => this._publicAgent(a)); }
|
|
183
230
|
|
|
184
231
|
// ---------- messages ----------
|
|
185
|
-
async sendMessage({
|
|
232
|
+
async sendMessage(...args) { return this._serialized(() => this._sendMessage(...args)); }
|
|
233
|
+
async _sendMessage({ from, to = '*', body, channel = 'general', threadId = null, token = null }) {
|
|
186
234
|
await this.init();
|
|
187
235
|
this._requireAgent(from, token);
|
|
188
236
|
if (to !== '*') this._existsAgent(to);
|
|
@@ -201,7 +249,8 @@ export class GhostBus {
|
|
|
201
249
|
}
|
|
202
250
|
|
|
203
251
|
/** Messages visible to `agent`: addressed to it, broadcast, or sent by it. Unread-first filtering optional. */
|
|
204
|
-
async inbox(
|
|
252
|
+
async inbox(...args) { return this._serialized(() => this._inbox(...args)); }
|
|
253
|
+
async _inbox(agentName, { unreadOnly = false, limit = 50, markRead = true, token = null } = {}) {
|
|
205
254
|
await this.init();
|
|
206
255
|
this._requireAgent(agentName, token);
|
|
207
256
|
let msgs = this.state.messages.filter(m =>
|
|
@@ -216,7 +265,8 @@ export class GhostBus {
|
|
|
216
265
|
return clone(msgs);
|
|
217
266
|
}
|
|
218
267
|
|
|
219
|
-
async thread(
|
|
268
|
+
async thread(...args) { return this._serialized(() => this._thread(...args)); }
|
|
269
|
+
async _thread(id) {
|
|
220
270
|
await this.init();
|
|
221
271
|
const root = this.state.messages.find(m => m.id === Number(id));
|
|
222
272
|
if (!root) throw new Error(`message not found: ${id}`);
|
|
@@ -224,7 +274,8 @@ export class GhostBus {
|
|
|
224
274
|
}
|
|
225
275
|
|
|
226
276
|
// ---------- tasks ----------
|
|
227
|
-
async createTask({
|
|
277
|
+
async createTask(...args) { return this._serialized(() => this._createTask(...args)); }
|
|
278
|
+
async _createTask({ from, title, body = '', assignee = null, priority = 'normal', needsApproval = false, blockedBy = [], token = null }) {
|
|
228
279
|
await this.init();
|
|
229
280
|
this._requireAgent(from, token);
|
|
230
281
|
if (assignee) this._existsAgent(assignee);
|
|
@@ -257,7 +308,8 @@ export class GhostBus {
|
|
|
257
308
|
}
|
|
258
309
|
}
|
|
259
310
|
|
|
260
|
-
async listTasks({
|
|
311
|
+
async listTasks(...args) { return this._serialized(() => this._listTasks(...args)); }
|
|
312
|
+
async _listTasks({ status = null, assignee = null } = {}) {
|
|
261
313
|
await this.init();
|
|
262
314
|
this._expireClaims(); await this._save();
|
|
263
315
|
let tasks = this.state.tasks;
|
|
@@ -266,7 +318,8 @@ export class GhostBus {
|
|
|
266
318
|
return clone(tasks.sort((a, b) => b.id - a.id));
|
|
267
319
|
}
|
|
268
320
|
|
|
269
|
-
async approveTask(
|
|
321
|
+
async approveTask(...args) { return this._serialized(() => this._approveTask(...args)); }
|
|
322
|
+
async _approveTask(id, { by, token = null }) {
|
|
270
323
|
await this.init();
|
|
271
324
|
this._requireAgent(by, token);
|
|
272
325
|
const task = this._task(id);
|
|
@@ -279,7 +332,8 @@ export class GhostBus {
|
|
|
279
332
|
}
|
|
280
333
|
|
|
281
334
|
/** Claim is exclusive: a second claim while a lease is live fails loudly. */
|
|
282
|
-
async claimTask(
|
|
335
|
+
async claimTask(...args) { return this._serialized(() => this._claimTask(...args)); }
|
|
336
|
+
async _claimTask(id, { by, token = null }) {
|
|
283
337
|
await this.init();
|
|
284
338
|
this._requireAgent(by, token);
|
|
285
339
|
this._expireClaims();
|
|
@@ -298,7 +352,8 @@ export class GhostBus {
|
|
|
298
352
|
return clone(task);
|
|
299
353
|
}
|
|
300
354
|
|
|
301
|
-
async completeTask(
|
|
355
|
+
async completeTask(...args) { return this._serialized(() => this._completeTask(...args)); }
|
|
356
|
+
async _completeTask(id, { by, result = '', token = null }) {
|
|
302
357
|
await this.init();
|
|
303
358
|
this._requireAgent(by, token);
|
|
304
359
|
const task = this._task(id);
|
|
@@ -311,7 +366,8 @@ export class GhostBus {
|
|
|
311
366
|
return clone(task);
|
|
312
367
|
}
|
|
313
368
|
|
|
314
|
-
async cancelTask(
|
|
369
|
+
async cancelTask(...args) { return this._serialized(() => this._cancelTask(...args)); }
|
|
370
|
+
async _cancelTask(id, { by, token = null }) {
|
|
315
371
|
await this.init();
|
|
316
372
|
this._requireAgent(by, token);
|
|
317
373
|
const task = this._task(id);
|
|
@@ -330,7 +386,8 @@ export class GhostBus {
|
|
|
330
386
|
}
|
|
331
387
|
|
|
332
388
|
// ---------- shared workspace: files + context + board ----------
|
|
333
|
-
async putFile({
|
|
389
|
+
async putFile(...args) { return this._serialized(() => this._putFile(...args)); }
|
|
390
|
+
async _putFile({ by, path: p, text, token = null }) {
|
|
334
391
|
await this.init();
|
|
335
392
|
this._requireAgent(by, token);
|
|
336
393
|
if (!p || typeof text !== 'string') throw new Error('path and text required');
|
|
@@ -343,7 +400,8 @@ export class GhostBus {
|
|
|
343
400
|
return { path: p, bytes: Buffer.byteLength(text) };
|
|
344
401
|
}
|
|
345
402
|
|
|
346
|
-
async getFile(
|
|
403
|
+
async getFile(...args) { return this._serialized(() => this._getFile(...args)); }
|
|
404
|
+
async _getFile(p, { withHistory = false } = {}) {
|
|
347
405
|
await this.init();
|
|
348
406
|
const f = this.state.files[p];
|
|
349
407
|
if (!f) throw new Error(`file not found: ${p}`);
|
|
@@ -351,19 +409,22 @@ export class GhostBus {
|
|
|
351
409
|
return { path: p, ...clone(rest), versions: (history || []).length + 1, ...(withHistory ? { history: clone(history || []) } : {}) };
|
|
352
410
|
}
|
|
353
411
|
|
|
354
|
-
async fileHistory(
|
|
412
|
+
async fileHistory(...args) { return this._serialized(() => this._fileHistory(...args)); }
|
|
413
|
+
async _fileHistory(p) {
|
|
355
414
|
await this.init();
|
|
356
415
|
const f = this.state.files[p];
|
|
357
416
|
if (!f) throw new Error(`file not found: ${p}`);
|
|
358
417
|
return { path: p, current: { text: f.text, updatedBy: f.updatedBy, updatedAt: f.updatedAt }, history: clone(f.history || []) };
|
|
359
418
|
}
|
|
360
419
|
|
|
361
|
-
async listFiles() {
|
|
420
|
+
async listFiles(...args) { return this._serialized(() => this._listFiles(...args)); }
|
|
421
|
+
async _listFiles() {
|
|
362
422
|
await this.init();
|
|
363
423
|
return Object.entries(this.state.files).map(([p, f]) => ({ path: p, bytes: Buffer.byteLength(f.text), updatedBy: f.updatedBy, updatedAt: f.updatedAt }));
|
|
364
424
|
}
|
|
365
425
|
|
|
366
|
-
async putContext({
|
|
426
|
+
async putContext(...args) { return this._serialized(() => this._putContext(...args)); }
|
|
427
|
+
async _putContext({ by, context, token = null }) {
|
|
367
428
|
await this.init();
|
|
368
429
|
this._requireAgent(by, token);
|
|
369
430
|
if (!context || typeof context !== 'object' || Array.isArray(context)) throw new Error('context must be an object');
|
|
@@ -373,13 +434,15 @@ export class GhostBus {
|
|
|
373
434
|
return { ok: true };
|
|
374
435
|
}
|
|
375
436
|
|
|
376
|
-
async getContext() {
|
|
437
|
+
async getContext(...args) { return this._serialized(() => this._getContext(...args)); }
|
|
438
|
+
async _getContext() {
|
|
377
439
|
await this.init();
|
|
378
440
|
if (!this.state.context) throw new Error('no context published yet');
|
|
379
441
|
return clone(this.state.context);
|
|
380
442
|
}
|
|
381
443
|
|
|
382
|
-
async addTaskComment(
|
|
444
|
+
async addTaskComment(...args) { return this._serialized(() => this._addTaskComment(...args)); }
|
|
445
|
+
async _addTaskComment(id, { by, body, token = null }) {
|
|
383
446
|
await this.init();
|
|
384
447
|
this._requireAgent(by, token);
|
|
385
448
|
if (!body || !String(body).trim()) throw new Error('comment body required');
|
|
@@ -394,7 +457,8 @@ export class GhostBus {
|
|
|
394
457
|
return clone(task);
|
|
395
458
|
}
|
|
396
459
|
|
|
397
|
-
async deleteFile({
|
|
460
|
+
async deleteFile(...args) { return this._serialized(() => this._deleteFile(...args)); }
|
|
461
|
+
async _deleteFile({ by, path: p, token = null }) {
|
|
398
462
|
await this.init();
|
|
399
463
|
this._requireAgent(by, token);
|
|
400
464
|
if (!this.state.files[p]) throw new Error(`file not found: ${p}`);
|
|
@@ -404,7 +468,8 @@ export class GhostBus {
|
|
|
404
468
|
return { path: p, deleted: true };
|
|
405
469
|
}
|
|
406
470
|
|
|
407
|
-
async listChannels() {
|
|
471
|
+
async listChannels(...args) { return this._serialized(() => this._listChannels(...args)); }
|
|
472
|
+
async _listChannels() {
|
|
408
473
|
await this.init();
|
|
409
474
|
const counts = {};
|
|
410
475
|
for (const m of this.state.messages) counts[m.channel] = (counts[m.channel] || 0) + 1;
|
|
@@ -412,7 +477,8 @@ export class GhostBus {
|
|
|
412
477
|
}
|
|
413
478
|
|
|
414
479
|
/** Full-text search across messages, tasks, and shared files. */
|
|
415
|
-
async search(
|
|
480
|
+
async search(...args) { return this._serialized(() => this._search(...args)); }
|
|
481
|
+
async _search(query, { limit = 20 } = {}) {
|
|
416
482
|
await this.init();
|
|
417
483
|
const q = String(query || '').toLowerCase().trim();
|
|
418
484
|
if (q.length < 2) throw new Error('search query needs at least 2 characters');
|
|
@@ -437,7 +503,8 @@ export class GhostBus {
|
|
|
437
503
|
}
|
|
438
504
|
|
|
439
505
|
/** Read a project capsule, creating it from the template on first touch when create=true. */
|
|
440
|
-
async getCapsule(
|
|
506
|
+
async getCapsule(...args) { return this._serialized(() => this._getCapsule(...args)); }
|
|
507
|
+
async _getCapsule(slug, { create = false, title = '', by = null, token = null } = {}) {
|
|
441
508
|
await this.init();
|
|
442
509
|
const pth = this._capsulePath(slug);
|
|
443
510
|
if (!this.state.files[pth]) {
|
|
@@ -451,7 +518,8 @@ export class GhostBus {
|
|
|
451
518
|
}
|
|
452
519
|
|
|
453
520
|
/** Replace one capsule section (State / Decisions / Next) or append to the Session log. */
|
|
454
|
-
async updateCapsule(
|
|
521
|
+
async updateCapsule(...args) { return this._serialized(() => this._updateCapsule(...args)); }
|
|
522
|
+
async _updateCapsule(slug, { by, section, text, token = null }) {
|
|
455
523
|
await this.init();
|
|
456
524
|
this._requireAgent(by, token);
|
|
457
525
|
await this.getCapsule(slug, { create: true, by, token });
|
|
@@ -477,13 +545,15 @@ export class GhostBus {
|
|
|
477
545
|
return this.getFile(pth);
|
|
478
546
|
}
|
|
479
547
|
|
|
480
|
-
async listCapsules() {
|
|
548
|
+
async listCapsules(...args) { return this._serialized(() => this._listCapsules(...args)); }
|
|
549
|
+
async _listCapsules() {
|
|
481
550
|
await this.init();
|
|
482
551
|
return Object.keys(this.state.files).filter(p => p.startsWith('capsules/') && p.endsWith('.md')).map(p => p.slice('capsules/'.length, -3)).sort();
|
|
483
552
|
}
|
|
484
553
|
|
|
485
554
|
/** A human-readable board rendered from live state — the shared picture at a glance. */
|
|
486
|
-
async board() {
|
|
555
|
+
async board(...args) { return this._serialized(() => this._board(...args)); }
|
|
556
|
+
async _board() {
|
|
487
557
|
await this.init();
|
|
488
558
|
this._expireClaims();
|
|
489
559
|
const s = this.state;
|
|
@@ -511,18 +581,21 @@ export class GhostBus {
|
|
|
511
581
|
|
|
512
582
|
/** Catch-up sync: every event after `sinceSeq`, plus the current head seq.
|
|
513
583
|
* An agent that was offline replays exactly what it missed — nothing more. */
|
|
514
|
-
async sync({
|
|
584
|
+
async sync(...args) { return this._serialized(() => this._sync(...args)); }
|
|
585
|
+
async _sync({ sinceSeq = 0, limit = 500 } = {}) {
|
|
515
586
|
await this.init();
|
|
516
587
|
const evs = this.state.events.filter(e => e.seq > Number(sinceSeq));
|
|
517
588
|
return { events: clone(evs.slice(0, Math.min(limit, 1000))), headSeq: this.state.seq, truncated: evs.length > Math.min(limit, 1000) };
|
|
518
589
|
}
|
|
519
590
|
|
|
520
|
-
async events({
|
|
591
|
+
async events(...args) { return this._serialized(() => this._events(...args)); }
|
|
592
|
+
async _events({ limit = 50 } = {}) {
|
|
521
593
|
await this.init();
|
|
522
594
|
return clone(this.state.events.slice(-Math.min(limit, 500)));
|
|
523
595
|
}
|
|
524
596
|
|
|
525
|
-
async status() {
|
|
597
|
+
async status(...args) { return this._serialized(() => this._status(...args)); }
|
|
598
|
+
async _status() {
|
|
526
599
|
await this.init();
|
|
527
600
|
this._expireClaims();
|
|
528
601
|
const s = this.state;
|
|
@@ -540,7 +613,8 @@ export class GhostBus {
|
|
|
540
613
|
/** Full-state snapshot for backup / migration. The state holds agent token
|
|
541
614
|
* HASHES only, never plaintext secrets; workspace keys live outside the
|
|
542
615
|
* state (hosted registry) and are never part of a snapshot. */
|
|
543
|
-
async snapshot() {
|
|
616
|
+
async snapshot(...args) { return this._serialized(() => this._snapshot(...args)); }
|
|
617
|
+
async _snapshot() {
|
|
544
618
|
await this.init();
|
|
545
619
|
return clone(this.state);
|
|
546
620
|
}
|
|
@@ -549,7 +623,8 @@ export class GhostBus {
|
|
|
549
623
|
* The snapshot is shape-validated, the workspace identity is preserved
|
|
550
624
|
* unless the snapshot carries one, and the restore itself is recorded
|
|
551
625
|
* in the provenance log. Returns the post-restore status. */
|
|
552
|
-
async restore(
|
|
626
|
+
async restore(...args) { return this._serialized(() => this._restore(...args)); }
|
|
627
|
+
async _restore(snapshot, { by = 'operator' } = {}) {
|
|
553
628
|
await this.init();
|
|
554
629
|
const bad = validateSnapshot(snapshot);
|
|
555
630
|
if (bad) throw Object.assign(new Error(`invalid snapshot: ${bad}`), { code: 'BAD_SNAPSHOT' });
|
package/src/handler.mjs
CHANGED
|
@@ -24,6 +24,56 @@ const hashEq = (given, expected) => {
|
|
|
24
24
|
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
25
25
|
};
|
|
26
26
|
export const keyMatches = (given, expected) => !!expected && !!given && hashEq(given, expected);
|
|
27
|
+
// Timing-safe check of a presented secret against a STORED sha256 hex digest
|
|
28
|
+
// (v0.5.3) — the hosted router and the core token check compare against
|
|
29
|
+
// hashes at rest, never against raw keys.
|
|
30
|
+
export const hashMatches = (given, storedHashHex) => {
|
|
31
|
+
if (!given || !storedHashHex) return false;
|
|
32
|
+
const a = Buffer.from(crypto.createHash('sha256').update(String(given)).digest('hex'));
|
|
33
|
+
const b = Buffer.from(String(storedHashHex));
|
|
34
|
+
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
// JSON request-body cap (v0.5.3): file contents travel inside JSON bodies in
|
|
38
|
+
// this codebase, so 1 MB is generous for messages/tasks/files while keeping
|
|
39
|
+
// a single request from exhausting memory. Over the cap the reader rejects
|
|
40
|
+
// with code BODY_TOO_LARGE and the route answers 413.
|
|
41
|
+
export const MAX_BODY_BYTES = 1_000_000;
|
|
42
|
+
export function readJsonBody(req, limit = MAX_BODY_BYTES) {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
let size = 0; let done = false;
|
|
45
|
+
const chunks = [];
|
|
46
|
+
req.on('data', (c) => {
|
|
47
|
+
if (done) return;
|
|
48
|
+
size += c.length;
|
|
49
|
+
if (size > limit) {
|
|
50
|
+
done = true;
|
|
51
|
+
req.pause(); // stop reading; the route answers 413 and drops the socket
|
|
52
|
+
reject(Object.assign(new Error(`request body too large (limit ${limit} bytes)`), { code: 'BODY_TOO_LARGE' }));
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
chunks.push(c);
|
|
56
|
+
});
|
|
57
|
+
req.on('end', () => {
|
|
58
|
+
if (done) return; done = true;
|
|
59
|
+
const data = Buffer.concat(chunks).toString('utf8');
|
|
60
|
+
try { resolve(data ? JSON.parse(data) : {}); } catch { resolve(null); }
|
|
61
|
+
});
|
|
62
|
+
req.on('error', () => { if (!done) { done = true; resolve(null); } });
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Headers for the HTML pages (board + hosted landing), v0.5.3. Both pages are
|
|
67
|
+
// single self-contained documents: inline <script>/<style> and same-origin
|
|
68
|
+
// fetches only, zero external resources — so the CSP allows 'unsafe-inline'
|
|
69
|
+
// for script/style specifically (no eval, no external origins, no plugins)
|
|
70
|
+
// and forbids framing outright.
|
|
71
|
+
export const HTML_SECURITY_HEADERS = {
|
|
72
|
+
'x-frame-options': 'DENY',
|
|
73
|
+
'content-security-policy': "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const TOO_LARGE = Symbol('body-too-large');
|
|
27
77
|
|
|
28
78
|
export function createBusHandler({ id = 'default', bus, key = '' }) {
|
|
29
79
|
const buckets = new Map();
|
|
@@ -51,13 +101,19 @@ export function createBusHandler({ id = 'default', bus, key = '' }) {
|
|
|
51
101
|
if (!rateOk(req) && url.pathname !== '/health' && url.pathname !== '/probe') {
|
|
52
102
|
return send(429, { error: 'rate limit: 300 requests/min per IP per workspace' });
|
|
53
103
|
}
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
104
|
+
const send413 = () => {
|
|
105
|
+
try {
|
|
106
|
+
res.writeHead(413, { 'content-type': 'application/json', connection: 'close' });
|
|
107
|
+
res.end(JSON.stringify({ error: `request body too large (limit ${MAX_BODY_BYTES} bytes)` }), () => { try { req.destroy(); } catch {} });
|
|
108
|
+
} catch {}
|
|
109
|
+
};
|
|
110
|
+
const readBody = async () => {
|
|
111
|
+
try { return await readJsonBody(req); }
|
|
112
|
+
catch (e) { if (e && e.code === 'BODY_TOO_LARGE') { send413(); return TOO_LARGE; } throw e; }
|
|
113
|
+
};
|
|
58
114
|
|
|
59
115
|
try {
|
|
60
|
-
if (req.method === 'GET' && url.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(BOARD_HTML); }
|
|
116
|
+
if (req.method === 'GET' && url.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', ...HTML_SECURITY_HEADERS }); return res.end(BOARD_HTML); }
|
|
61
117
|
if (req.method === 'GET' && url.pathname === '/health') return send(200, { ok: true, server: 'ghostbus', workspace: id, version: GHOSTBUS_VERSION });
|
|
62
118
|
if (req.method === 'GET' && url.pathname === '/probe') {
|
|
63
119
|
const s = await bus.status();
|
|
@@ -103,6 +159,7 @@ export function createBusHandler({ id = 'default', bus, key = '' }) {
|
|
|
103
159
|
|
|
104
160
|
if (req.method === 'POST' && url.pathname === '/mcp') {
|
|
105
161
|
const msg = await readBody();
|
|
162
|
+
if (msg === TOO_LARGE) return; // 413 already sent
|
|
106
163
|
if (!msg) return send(400, { error: 'invalid JSON body' });
|
|
107
164
|
if (msg.method === 'tools/list') return send(200, { jsonrpc: '2.0', id: msg.id, result: { tools: TOOLS } });
|
|
108
165
|
if (msg.method === 'tools/call') {
|
|
@@ -118,6 +175,7 @@ export function createBusHandler({ id = 'default', bus, key = '' }) {
|
|
|
118
175
|
}
|
|
119
176
|
|
|
120
177
|
const body = ['POST', 'PUT', 'DELETE'].includes(req.method) ? await readBody().catch(() => ({})) : {};
|
|
178
|
+
if (body === TOO_LARGE) return; // 413 already sent
|
|
121
179
|
const safeBody = body && typeof body === 'object' ? body : {};
|
|
122
180
|
const q = Object.fromEntries(url.searchParams);
|
|
123
181
|
|
package/src/hosted-server.mjs
CHANGED
|
@@ -18,26 +18,47 @@
|
|
|
18
18
|
* GET /api/workspaces/<id>/export -> full-state export envelope (backup/migrate)
|
|
19
19
|
* POST /api/workspaces/import {id?, name?, state|export} -> {id, name, key (once)}
|
|
20
20
|
* - Registry + one data file per workspace live in the data dir.
|
|
21
|
-
*
|
|
21
|
+
* - Per-agent tokens are ENFORCED in all workspaces by default (v0.5.2):
|
|
22
|
+
* hosted mode serves strangers, so a stolen agent name must not be enough
|
|
23
|
+
* to act. GHOSTBUS_REQUIRE_TOKENS=0 is the only opt-out and starts with a
|
|
24
|
+
* loud warning. GHOSTBUS_ADMIN_KEY is required and must be at least 16
|
|
25
|
+
* characters — it is the operator root key (see docs/hosted.md).
|
|
26
|
+
* - v0.5.3: workspace-key checks are timing-safe, failed auth is throttled
|
|
27
|
+
* per IP at this layer, JSON bodies are capped at 1 MB (413), registry +
|
|
28
|
+
* workspace files are written 0600, HTML pages carry CSP/frame headers,
|
|
29
|
+
* and every admin action is audit-logged to stderr (never keys/hashes).
|
|
22
30
|
*/
|
|
23
31
|
import http from 'node:http';
|
|
24
32
|
import fs from 'node:fs';
|
|
25
33
|
import path from 'node:path';
|
|
26
34
|
import crypto from 'node:crypto';
|
|
27
35
|
import { createBus, FileStore, GHOSTBUS_VERSION, validateSnapshot } from './core.mjs';
|
|
28
|
-
import { createBusHandler, keyMatches } from './handler.mjs';
|
|
36
|
+
import { createBusHandler, keyMatches, hashMatches, readJsonBody, MAX_BODY_BYTES, HTML_SECURITY_HEADERS } from './handler.mjs';
|
|
29
37
|
|
|
30
38
|
const argv = process.argv.slice(2);
|
|
31
39
|
const argVal = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : null; };
|
|
32
40
|
const port = Number(argVal('--port') || process.env.GHOSTBUS_PORT || 8388);
|
|
33
41
|
const dataDir = argVal('--data-dir') || process.env.GHOSTBUS_DATA_DIR || './ghostbus-hosted';
|
|
34
42
|
const ADMIN_KEY = process.env.GHOSTBUS_ADMIN_KEY || '';
|
|
35
|
-
|
|
43
|
+
// v0.5.2: enforcement is the hosted DEFAULT. Only an explicit
|
|
44
|
+
// GHOSTBUS_REQUIRE_TOKENS=0 turns it off (anything else, including unset,
|
|
45
|
+
// means on) — the hosted server is the one deployment that serves people
|
|
46
|
+
// other than the operator, so the safe posture cannot be opt-in.
|
|
47
|
+
const REQUIRE_TOKENS = process.env.GHOSTBUS_REQUIRE_TOKENS !== '0';
|
|
36
48
|
|
|
37
49
|
if (!ADMIN_KEY) {
|
|
38
50
|
process.stderr.write('GHOSTBUS_ADMIN_KEY is required for the hosted server (workspace creation must not be open).\n');
|
|
39
51
|
process.exit(2);
|
|
40
52
|
}
|
|
53
|
+
if (ADMIN_KEY.length < 16) {
|
|
54
|
+
process.stderr.write('GHOSTBUS_ADMIN_KEY is too short: use at least 16 characters (32+ random hex recommended). The admin key is the operator root key — it creates/deletes workspaces and acts as a master key inside every one of them.\n');
|
|
55
|
+
process.exit(2);
|
|
56
|
+
}
|
|
57
|
+
if (!REQUIRE_TOKENS) {
|
|
58
|
+
process.stderr.write('WARNING: GHOSTBUS_REQUIRE_TOKENS=0 — per-agent token enforcement is DISABLED in every workspace.\n');
|
|
59
|
+
process.stderr.write('WARNING: anyone holding a workspace key can act under ANY agent name in that workspace (impersonation).\n');
|
|
60
|
+
process.stderr.write('WARNING: hosted mode serves strangers; run with enforcement on unless you have a specific reason not to.\n');
|
|
61
|
+
}
|
|
41
62
|
|
|
42
63
|
fs.mkdirSync(dataDir, { recursive: true });
|
|
43
64
|
const registryPath = path.join(dataDir, 'registry.json');
|
|
@@ -45,9 +66,11 @@ const loadRegistry = () => { try { return JSON.parse(fs.readFileSync(registryPat
|
|
|
45
66
|
const saveRegistry = (reg) => {
|
|
46
67
|
// Unique temp per save: two processes sharing this registry must never
|
|
47
68
|
// collide on one fixed '.tmp' name (first rename wins, second throws ENOENT).
|
|
69
|
+
// The registry holds every workspace's key hash — owner-only (0600, v0.5.3).
|
|
48
70
|
const tmp = registryPath + '.tmp.' + process.pid + '.' + Math.random().toString(36).slice(2, 10);
|
|
49
71
|
try {
|
|
50
|
-
fs.writeFileSync(tmp, JSON.stringify(reg, null, 2));
|
|
72
|
+
fs.writeFileSync(tmp, JSON.stringify(reg, null, 2), { mode: 0o600 });
|
|
73
|
+
try { fs.chmodSync(tmp, 0o600); } catch { /* mode already applied on create */ }
|
|
51
74
|
fs.renameSync(tmp, registryPath);
|
|
52
75
|
} finally {
|
|
53
76
|
try { fs.unlinkSync(tmp); } catch { /* already renamed away */ }
|
|
@@ -81,9 +104,38 @@ const wsKeyOk = (req, meta) => {
|
|
|
81
104
|
const given = req.headers['x-bus-key'] || String(req.headers.authorization || '').replace(/^Bearer\s+/i, '');
|
|
82
105
|
if (!given) return false;
|
|
83
106
|
if (keyMatches(given, ADMIN_KEY)) return true; // master key
|
|
84
|
-
return
|
|
107
|
+
return hashMatches(given, meta.keyHash); // timing-safe vs the stored hash (v0.5.3)
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// Failed-auth throttle (v0.5.3): the in-workspace handler limiter (300/min)
|
|
111
|
+
// throttles traffic that is already inside a workspace, but nothing slowed
|
|
112
|
+
// down *guessing* at the hosted layer — admin keys and workspace keys were
|
|
113
|
+
// unthrottled. Track FAILED auth per IP; past the limit, admin + workspace
|
|
114
|
+
// routes answer 429 until the window rolls. Successful auth never counts.
|
|
115
|
+
const AUTH_FAIL_LIMIT = 30; // failed auth attempts per window per IP
|
|
116
|
+
const AUTH_FAIL_WINDOW_MS = 60_000; // one-minute window
|
|
117
|
+
const authFails = new Map(); // ip -> {start, count}
|
|
118
|
+
const clientIp = (req) => (req.socket && req.socket.remoteAddress) || 'unknown';
|
|
119
|
+
const authThrottled = (ip) => {
|
|
120
|
+
const b = authFails.get(ip);
|
|
121
|
+
return !!b && Date.now() - b.start <= AUTH_FAIL_WINDOW_MS && b.count >= AUTH_FAIL_LIMIT;
|
|
122
|
+
};
|
|
123
|
+
const noteAuthFail = (ip) => {
|
|
124
|
+
const t = Date.now();
|
|
125
|
+
let b = authFails.get(ip);
|
|
126
|
+
if (!b || t - b.start > AUTH_FAIL_WINDOW_MS) { b = { start: t, count: 0 }; authFails.set(ip, b); }
|
|
127
|
+
b.count += 1;
|
|
85
128
|
};
|
|
86
129
|
|
|
130
|
+
// Operator audit trail (v0.5.3): one structured stderr line per admin
|
|
131
|
+
// action, so a paid host has a record of who did what to which workspace.
|
|
132
|
+
// NEVER log keys or hashes — ids, actions, timestamps, source IPs only.
|
|
133
|
+
const audit = (action, id, req) => {
|
|
134
|
+
process.stderr.write(JSON.stringify({ ts: new Date().toISOString(), audit: 'admin', action, workspace: id, ip: clientIp(req) }) + '\n');
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const TOO_LARGE = Symbol('body-too-large');
|
|
138
|
+
|
|
87
139
|
const LANDING = `<!doctype html><html><head><meta charset="utf-8"><title>GhostBus Hosted</title>
|
|
88
140
|
<style>body{font-family:system-ui;background:#0e0c14;color:#ece8f5;margin:0;padding:40px}h1{font-size:22px}code{background:#17131f;padding:2px 7px;border-radius:6px;color:#c9b8ff}p{color:#9b8fb5;max-width:640px;line-height:1.55}</style></head>
|
|
89
141
|
<body><h1>👻 GhostBus Hosted</h1>
|
|
@@ -99,19 +151,34 @@ const server = http.createServer(async (req, res) => {
|
|
|
99
151
|
};
|
|
100
152
|
res.setHeader('x-content-type-options', 'nosniff');
|
|
101
153
|
res.setHeader('referrer-policy', 'no-referrer');
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
154
|
+
const ip = clientIp(req);
|
|
155
|
+
const adminDenied = () => { noteAuthFail(ip); return send(401, { error: 'admin key required' }); };
|
|
156
|
+
const readBody = async () => {
|
|
157
|
+
try { return await readJsonBody(req); }
|
|
158
|
+
catch (e) {
|
|
159
|
+
if (e && e.code === 'BODY_TOO_LARGE') {
|
|
160
|
+
try {
|
|
161
|
+
res.writeHead(413, { 'content-type': 'application/json', connection: 'close' });
|
|
162
|
+
res.end(JSON.stringify({ error: `request body too large (limit ${MAX_BODY_BYTES} bytes)` }), () => { try { req.destroy(); } catch {} });
|
|
163
|
+
} catch {}
|
|
164
|
+
return TOO_LARGE;
|
|
165
|
+
}
|
|
166
|
+
throw e;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
106
169
|
|
|
107
170
|
try {
|
|
108
|
-
if (req.method === 'GET' && url.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(LANDING); }
|
|
171
|
+
if (req.method === 'GET' && url.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', ...HTML_SECURITY_HEADERS }); return res.end(LANDING); }
|
|
109
172
|
if (req.method === 'GET' && url.pathname === '/health') return send(200, { ok: true, server: 'ghostbus-hosted', version: GHOSTBUS_VERSION, workspaces: Object.keys(loadRegistry().workspaces).length });
|
|
110
173
|
|
|
111
174
|
// ---- admin ----
|
|
175
|
+
// Failed-auth throttle (v0.5.3): sustained key guessing gets 429s on the
|
|
176
|
+
// whole admin surface before any key is even compared.
|
|
177
|
+
if (url.pathname.startsWith('/api/workspaces') && authThrottled(ip)) return send(429, { error: 'too many failed auth attempts — try again shortly' });
|
|
112
178
|
if (url.pathname === '/api/workspaces' && req.method === 'POST') {
|
|
113
|
-
if (!isAdmin(req)) return
|
|
179
|
+
if (!isAdmin(req)) return adminDenied();
|
|
114
180
|
const body = await readBody();
|
|
181
|
+
if (body === TOO_LARGE) return; // 413 already sent
|
|
115
182
|
if (!body) return send(400, { error: 'invalid JSON body' });
|
|
116
183
|
const reg = loadRegistry();
|
|
117
184
|
const id = body.id || `ws-${crypto.randomBytes(4).toString('hex')}`;
|
|
@@ -120,10 +187,11 @@ const server = http.createServer(async (req, res) => {
|
|
|
120
187
|
const key = crypto.randomBytes(24).toString('hex');
|
|
121
188
|
reg.workspaces[id] = { name: String(body.name || id).slice(0, 120), keyHash: sha256(key), createdAt: new Date().toISOString() };
|
|
122
189
|
saveRegistry(reg);
|
|
190
|
+
audit('create', id, req);
|
|
123
191
|
return send(201, { id, name: reg.workspaces[id].name, key, keyNote: 'Workspace key — shown ONCE, only its hash is stored. Agents use it as x-bus-key (or Bearer) under /w/' + id + '/.', board: `/w/${id}/` });
|
|
124
192
|
}
|
|
125
193
|
if (url.pathname === '/api/workspaces' && req.method === 'GET') {
|
|
126
|
-
if (!isAdmin(req)) return
|
|
194
|
+
if (!isAdmin(req)) return adminDenied();
|
|
127
195
|
const reg = loadRegistry();
|
|
128
196
|
const out = [];
|
|
129
197
|
for (const [id, meta] of Object.entries(reg.workspaces)) {
|
|
@@ -135,7 +203,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
135
203
|
}
|
|
136
204
|
const rotateMatch = url.pathname.match(/^\/api\/workspaces\/([a-z0-9-]+)\/rotate-key$/);
|
|
137
205
|
if (req.method === 'POST' && rotateMatch) {
|
|
138
|
-
if (!isAdmin(req)) return
|
|
206
|
+
if (!isAdmin(req)) return adminDenied();
|
|
139
207
|
const reg = loadRegistry();
|
|
140
208
|
const id = rotateMatch[1];
|
|
141
209
|
if (!reg.workspaces[id]) return send(404, { error: `no such workspace: ${id}` });
|
|
@@ -146,20 +214,23 @@ const server = http.createServer(async (req, res) => {
|
|
|
146
214
|
// Refresh the cached meta so the old key dies immediately, not on restart.
|
|
147
215
|
const cached = buses.get(id);
|
|
148
216
|
if (cached) cached.meta = reg.workspaces[id];
|
|
217
|
+
audit('rotate-key', id, req);
|
|
149
218
|
return send(200, { id, key, keyNote: 'New workspace key — shown ONCE. The previous key is invalid from this moment.', keyRotatedAt: reg.workspaces[id].keyRotatedAt });
|
|
150
219
|
}
|
|
151
220
|
const exportMatch = url.pathname.match(/^\/api\/workspaces\/([a-z0-9-]+)\/export$/);
|
|
152
221
|
if (req.method === 'GET' && exportMatch) {
|
|
153
|
-
if (!isAdmin(req)) return
|
|
222
|
+
if (!isAdmin(req)) return adminDenied();
|
|
154
223
|
const id = exportMatch[1];
|
|
155
224
|
const w = await getWorkspace(id);
|
|
156
225
|
if (!w) return send(404, { error: `no such workspace: ${id}` });
|
|
157
226
|
const state = await w.bus.snapshot();
|
|
227
|
+
audit('export', id, req);
|
|
158
228
|
return send(200, { ghostbusExport: 1, version: GHOSTBUS_VERSION, exportedAt: new Date().toISOString(), workspace: { id, name: w.meta.name }, state });
|
|
159
229
|
}
|
|
160
230
|
if (url.pathname === '/api/workspaces/import' && req.method === 'POST') {
|
|
161
|
-
if (!isAdmin(req)) return
|
|
231
|
+
if (!isAdmin(req)) return adminDenied();
|
|
162
232
|
const body = await readBody();
|
|
233
|
+
if (body === TOO_LARGE) return; // 413 already sent
|
|
163
234
|
if (!body) return send(400, { error: 'invalid JSON body' });
|
|
164
235
|
// Accept either a full export envelope or a bare {state} / raw state.
|
|
165
236
|
const env = body.ghostbusExport ? body : null;
|
|
@@ -178,15 +249,17 @@ const server = http.createServer(async (req, res) => {
|
|
|
178
249
|
// the bus sees exactly what a restart would see.
|
|
179
250
|
const file = path.join(dataDir, `${id}.json`);
|
|
180
251
|
const tmp = file + '.tmp.' + process.pid + '.' + Math.random().toString(36).slice(2, 10);
|
|
181
|
-
fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
|
|
252
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 }); // state file: owner-only (v0.5.3)
|
|
253
|
+
try { fs.chmodSync(tmp, 0o600); } catch { /* mode already applied on create */ }
|
|
182
254
|
fs.renameSync(tmp, file);
|
|
255
|
+
audit('import', id, req);
|
|
183
256
|
const w = await getWorkspace(id);
|
|
184
257
|
const status = await w.bus.status();
|
|
185
258
|
return send(201, { id, name, key, keyNote: 'Workspace key — shown ONCE, only its hash is stored. Imported workspaces always get a FRESH key; the source key is not carried over.', agents: status.agents, messages: status.messages, tasks: status.tasks.total, board: `/w/${id}/` });
|
|
186
259
|
}
|
|
187
260
|
const delMatch = url.pathname.match(/^\/api\/workspaces\/([a-z0-9-]+)$/);
|
|
188
261
|
if (req.method === 'DELETE' && delMatch) {
|
|
189
|
-
if (!isAdmin(req)) return
|
|
262
|
+
if (!isAdmin(req)) return adminDenied();
|
|
190
263
|
const reg = loadRegistry();
|
|
191
264
|
const id = delMatch[1];
|
|
192
265
|
if (!reg.workspaces[id]) return send(404, { error: `no such workspace: ${id}` });
|
|
@@ -194,6 +267,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
194
267
|
saveRegistry(reg);
|
|
195
268
|
buses.delete(id); handlers.delete(id);
|
|
196
269
|
try { fs.unlinkSync(path.join(dataDir, `${id}.json`)); } catch {}
|
|
270
|
+
audit('delete', id, req);
|
|
197
271
|
return send(200, { id, deleted: true });
|
|
198
272
|
}
|
|
199
273
|
|
|
@@ -207,13 +281,15 @@ const server = http.createServer(async (req, res) => {
|
|
|
207
281
|
sub.pathname = wMatch[2] || '/';
|
|
208
282
|
// Open surfaces mirror the single relay: board page, health, probe.
|
|
209
283
|
const open = sub.pathname === '/' || sub.pathname === '/health' || sub.pathname === '/probe';
|
|
210
|
-
if (!open &&
|
|
284
|
+
if (!open && authThrottled(ip)) return send(429, { error: 'too many failed auth attempts — try again shortly' });
|
|
285
|
+
if (!open && !wsKeyOk(req, w.meta)) { noteAuthFail(ip); return send(401, { error: 'missing or wrong workspace key' }); }
|
|
211
286
|
return handlerFor(id, w.bus)(req, res, sub);
|
|
212
287
|
}
|
|
213
288
|
|
|
214
289
|
return send(404, { error: 'not found' });
|
|
215
290
|
} catch (e) {
|
|
216
|
-
|
|
291
|
+
process.stderr.write(`ghostbus hosted: internal error: ${(e && e.stack) || e}\n`);
|
|
292
|
+
return send(500, { error: 'internal error' });
|
|
217
293
|
}
|
|
218
294
|
});
|
|
219
295
|
|
package/src/http-server.mjs
CHANGED
|
@@ -23,14 +23,25 @@ const storePath = argVal('--store') || process.env.GHOSTBUS_STORE || './ghostbus
|
|
|
23
23
|
const wsName = argVal('--name') || process.env.GHOSTBUS_WORKSPACE_NAME || 'GhostBus Workspace';
|
|
24
24
|
const KEY = process.env.GHOSTBUS_KEY || '';
|
|
25
25
|
|
|
26
|
+
const REQUIRE_TOKENS = process.env.GHOSTBUS_REQUIRE_TOKENS === '1';
|
|
26
27
|
const store = storePath === ':memory:' ? new MemoryStore() : new FileStore(storePath);
|
|
27
|
-
const bus = await createBus(store, { name: wsName }, { requireTokens:
|
|
28
|
+
const bus = await createBus(store, { name: wsName }, { requireTokens: REQUIRE_TOKENS });
|
|
28
29
|
const handle = createBusHandler({ id: 'default', bus, key: KEY });
|
|
29
30
|
|
|
30
31
|
const server = http.createServer((req, res) => {
|
|
31
32
|
const url = new URL(req.url, 'http://localhost');
|
|
32
|
-
handle(req, res, url).catch((e) => { try { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error:
|
|
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 {} });
|
|
33
34
|
});
|
|
34
35
|
server.listen(port, () => {
|
|
35
36
|
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
|
+
// Node's listen() with no host binds ALL network interfaces, not just
|
|
38
|
+
// loopback — the 127.0.0.1 above is the local URL, not a bind restriction.
|
|
39
|
+
// Local trust is the relay's default model and stays unchanged, but an
|
|
40
|
+
// unkeyed or tokenless relay must never be network-reachable *silently*.
|
|
41
|
+
if (!KEY) {
|
|
42
|
+
process.stderr.write('WARNING: GHOSTBUS_KEY is not set — this relay is OPEN: anyone who can reach this port can read and write the workspace.\n');
|
|
43
|
+
process.stderr.write('WARNING: the relay listens on all network interfaces, not just localhost. Set GHOSTBUS_KEY (a long secret) before exposing it beyond this machine, or firewall the port.\n');
|
|
44
|
+
} else if (!REQUIRE_TOKENS) {
|
|
45
|
+
process.stderr.write('WARNING: per-agent tokens are not enforced (GHOSTBUS_REQUIRE_TOKENS is not 1): anyone holding the workspace key can act under any agent name. The relay listens on all network interfaces — set GHOSTBUS_REQUIRE_TOKENS=1 if untrusted agents share the key.\n');
|
|
46
|
+
}
|
|
36
47
|
});
|