ghostbus 0.0.0-stage → 0.5.0

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.
@@ -0,0 +1,166 @@
1
+ /**
2
+ * GhostBus HTTP request handler — transport logic shared by the single-workspace
3
+ * relay (http-server.mjs) and the hosted multi-workspace server (hosted-server.mjs).
4
+ *
5
+ * createBusHandler({ id, bus, key }) -> async (req, res, url)
6
+ * - `url` is a URL whose pathname is the path WITHIN the workspace (any
7
+ * /w/<id> prefix already stripped by the caller).
8
+ * - `key` is the workspace key ('' = open). /health, /probe and the board page
9
+ * stay open; everything else needs the key when one is set.
10
+ */
11
+ import crypto from 'node:crypto';
12
+ import fs from 'node:fs';
13
+ import { GHOSTBUS_VERSION } from './core.mjs';
14
+ import { TOOLS, callTool } from './tools.mjs';
15
+
16
+ export const BOARD_HTML = fs.readFileSync(new URL('./board.html', import.meta.url), 'utf8');
17
+
18
+ const agentTokenOf = (req, body = {}, q = {}) =>
19
+ body.token || q.token || req.headers['x-agent-token'] || null;
20
+
21
+ const hashEq = (given, expected) => {
22
+ const a = Buffer.from(crypto.createHash('sha256').update(String(given)).digest('hex'));
23
+ const b = Buffer.from(crypto.createHash('sha256').update(String(expected)).digest('hex'));
24
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
25
+ };
26
+ export const keyMatches = (given, expected) => !!expected && !!given && hashEq(given, expected);
27
+
28
+ export function createBusHandler({ id = 'default', bus, key = '' }) {
29
+ const buckets = new Map();
30
+ const rateOk = (req) => {
31
+ const ip = (req.socket && req.socket.remoteAddress) || 'unknown';
32
+ const t = Date.now();
33
+ let b = buckets.get(ip);
34
+ if (!b || t - b.start > 60_000) { b = { start: t, count: 0 }; buckets.set(ip, b); }
35
+ b.count += 1;
36
+ return b.count <= 300;
37
+ };
38
+ const keyOk = (req) => {
39
+ if (!key) return true;
40
+ const given = req.headers['x-bus-key'] || String(req.headers.authorization || '').replace(/^Bearer\s+/i, '');
41
+ return keyMatches(given, key);
42
+ };
43
+
44
+ return async function handle(req, res, url) {
45
+ res.setHeader('x-content-type-options', 'nosniff');
46
+ res.setHeader('referrer-policy', 'no-referrer');
47
+ const send = (status, obj, type = 'application/json') => {
48
+ const body = type === 'application/json' ? JSON.stringify(obj) : String(obj);
49
+ res.writeHead(status, { 'content-type': type }); res.end(body);
50
+ };
51
+ if (!rateOk(req) && url.pathname !== '/health' && url.pathname !== '/probe') {
52
+ return send(429, { error: 'rate limit: 300 requests/min per IP per workspace' });
53
+ }
54
+ const readBody = () => new Promise((resolve) => {
55
+ let data = ''; req.on('data', c => { data += c; if (data.length > 5_000_000) req.destroy(); });
56
+ req.on('end', () => { try { resolve(data ? JSON.parse(data) : {}); } catch { resolve(null); } });
57
+ });
58
+
59
+ try {
60
+ if (req.method === 'GET' && url.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(BOARD_HTML); }
61
+ if (req.method === 'GET' && url.pathname === '/health') return send(200, { ok: true, server: 'ghostbus', workspace: id, version: GHOSTBUS_VERSION });
62
+ if (req.method === 'GET' && url.pathname === '/probe') {
63
+ const s = await bus.status();
64
+ return send(200, { workspace: id, queued: s.tasks.queued, needsApproval: s.tasks.needsApproval, agents: s.agents, version: GHOSTBUS_VERSION });
65
+ }
66
+ if (!keyOk(req)) return send(401, { error: 'missing or wrong bus key' });
67
+
68
+ if (req.method === 'GET' && url.pathname === '/api/stream') {
69
+ const agent = url.searchParams.get('agent');
70
+ if (!agent) return send(400, { error: 'agent query param required' });
71
+ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
72
+ res.write(`event: ready\ndata: ${JSON.stringify({ agent, workspace: id, version: GHOSTBUS_VERSION })}\n\n`);
73
+ const off = bus.onEvent((ev) => {
74
+ const forMe = ev.type === 'message.send' ? (ev.to === agent || ev.to === '*' || ev.actor === agent) : true;
75
+ if (!forMe) return;
76
+ res.write(`event: ${ev.type}\ndata: ${JSON.stringify(ev)}\n\n`);
77
+ });
78
+ const ping = setInterval(() => res.write(': ping\n\n'), 25000);
79
+ req.on('close', () => { clearInterval(ping); off(); });
80
+ return;
81
+ }
82
+
83
+ if (req.method === 'GET' && url.pathname === '/api/wait') {
84
+ const agent = url.searchParams.get('agent');
85
+ if (!agent) return send(400, { error: 'agent query param required' });
86
+ const timeout = Math.min(Number(url.searchParams.get('timeout') || 25), 55) * 1000;
87
+ const sinceSeq = url.searchParams.has('sinceSeq') ? Number(url.searchParams.get('sinceSeq')) : (await bus.sync({})).headSeq;
88
+ const immediate = await bus.sync({ sinceSeq, limit: 100 });
89
+ const relevantNow = immediate.events.filter(ev => ev.type !== 'message.send' || ev.to === agent || ev.to === '*' || ev.actor === agent);
90
+ if (relevantNow.length) return send(200, { events: relevantNow, headSeq: immediate.headSeq, waited: false });
91
+ return await new Promise((resolve) => {
92
+ let done = false;
93
+ const finish = (events) => { if (done) return; done = true; off(); clearTimeout(timer); resolve(send(200, { events, headSeq: events.length ? events[events.length - 1].seq : sinceSeq, waited: true })); };
94
+ const off = bus.onEvent((ev) => {
95
+ if (ev.seq <= sinceSeq) return;
96
+ if (ev.type === 'message.send' && !(ev.to === agent || ev.to === '*' || ev.actor === agent)) return;
97
+ finish([ev]);
98
+ });
99
+ const timer = setTimeout(() => finish([]), timeout);
100
+ req.on('close', () => { if (!done) { done = true; off(); clearTimeout(timer); resolve(); } });
101
+ });
102
+ }
103
+
104
+ if (req.method === 'POST' && url.pathname === '/mcp') {
105
+ const msg = await readBody();
106
+ if (!msg) return send(400, { error: 'invalid JSON body' });
107
+ if (msg.method === 'tools/list') return send(200, { jsonrpc: '2.0', id: msg.id, result: { tools: TOOLS } });
108
+ if (msg.method === 'tools/call') {
109
+ try {
110
+ const argsIn = { ...(msg.params.arguments || {}) };
111
+ const hdrToken = req.headers['x-agent-token'];
112
+ if (hdrToken && !argsIn.token) argsIn.token = hdrToken;
113
+ const out = await callTool(bus, msg.params.name, argsIn);
114
+ return send(200, { jsonrpc: '2.0', id: msg.id, result: { content: [{ type: 'text', text: JSON.stringify(out, null, 2) }] } });
115
+ } catch (e) { return send(200, { jsonrpc: '2.0', id: msg.id, result: { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true } }); }
116
+ }
117
+ return send(400, { error: `unsupported MCP method over HTTP relay: ${msg.method} (use tools/list or tools/call)` });
118
+ }
119
+
120
+ const body = ['POST', 'PUT', 'DELETE'].includes(req.method) ? await readBody().catch(() => ({})) : {};
121
+ const safeBody = body && typeof body === 'object' ? body : {};
122
+ const q = Object.fromEntries(url.searchParams);
123
+
124
+ if (req.method === 'GET' && url.pathname === '/api/status') return send(200, await bus.status());
125
+ if (req.method === 'GET' && url.pathname === '/api/agents') return send(200, { agents: await bus.listAgents() });
126
+ if (req.method === 'POST' && url.pathname === '/api/agents') return send(200, await bus.registerAgent({ name: safeBody.agent, role: safeBody.role, capabilities: safeBody.capabilities, token: agentTokenOf(req, safeBody) }));
127
+ if (req.method === 'POST' && url.pathname === '/api/messages') return send(200, await bus.sendMessage({ from: safeBody.agent, to: safeBody.to ?? '*', body: safeBody.body, channel: safeBody.channel ?? 'general', threadId: safeBody.threadId ?? null, token: agentTokenOf(req, safeBody) }));
128
+ if (req.method === 'GET' && url.pathname === '/api/inbox') return send(200, { messages: await bus.inbox(q.agent, { unreadOnly: q.unread === '1', limit: Number(q.limit || 50), token: agentTokenOf(req, {}, q) }) });
129
+ if (req.method === 'POST' && url.pathname === '/api/heartbeat') return send(200, await bus.heartbeat({ name: safeBody.agent, token: agentTokenOf(req, safeBody) }));
130
+ if (req.method === 'GET' && url.pathname === '/api/search') return send(200, await bus.search(q.q || '', { limit: Number(q.limit || 20) }));
131
+ if (req.method === 'GET' && url.pathname === '/api/channels') return send(200, { channels: await bus.listChannels() });
132
+ if (req.method === 'POST' && url.pathname === '/api/tasks') return send(200, await bus.createTask({ from: safeBody.agent, title: safeBody.title, body: safeBody.body ?? '', assignee: safeBody.assignee ?? null, priority: safeBody.priority ?? 'normal', needsApproval: !!safeBody.needsApproval, blockedBy: safeBody.blockedBy ?? [], token: agentTokenOf(req, safeBody) }));
133
+ if (req.method === 'GET' && url.pathname === '/api/tasks') return send(200, { tasks: await bus.listTasks({ status: q.status || null, assignee: q.assignee || null }) });
134
+ const taskAction = url.pathname.match(/^\/api\/tasks\/(\d+)\/(approve|claim|complete|cancel)$/);
135
+ if (req.method === 'POST' && taskAction) {
136
+ const [, tid, action] = taskAction;
137
+ const tk = agentTokenOf(req, safeBody);
138
+ if (action === 'approve') return send(200, await bus.approveTask(tid, { by: safeBody.agent, token: tk }));
139
+ if (action === 'claim') return send(200, await bus.claimTask(tid, { by: safeBody.agent, token: tk }));
140
+ if (action === 'complete') return send(200, await bus.completeTask(tid, { by: safeBody.agent, result: safeBody.result ?? '', token: tk }));
141
+ if (action === 'cancel') return send(200, await bus.cancelTask(tid, { by: safeBody.agent, token: tk }));
142
+ }
143
+ const taskComment = url.pathname.match(/^\/api\/tasks\/(\d+)\/comment$/);
144
+ if (req.method === 'POST' && taskComment) return send(200, await bus.addTaskComment(taskComment[1], { by: safeBody.agent, body: safeBody.body, token: agentTokenOf(req, safeBody) }));
145
+ if (req.method === 'PUT' && url.pathname === '/api/files') return send(200, await bus.putFile({ by: safeBody.agent, path: safeBody.path, text: safeBody.text, token: agentTokenOf(req, safeBody) }));
146
+ if (req.method === 'DELETE' && url.pathname === '/api/files') return send(200, await bus.deleteFile({ by: q.agent || safeBody.agent, path: q.path || safeBody.path, token: agentTokenOf(req, safeBody, q) }));
147
+ if (req.method === 'GET' && url.pathname === '/api/files') return send(200, await bus.getFile(q.path));
148
+ if (req.method === 'GET' && url.pathname === '/api/files/list') return send(200, { files: await bus.listFiles() });
149
+ if (req.method === 'GET' && url.pathname === '/api/files/history') return send(200, await bus.fileHistory(q.path));
150
+ if (req.method === 'PUT' && url.pathname === '/api/context') return send(200, await bus.putContext({ by: safeBody.agent, context: safeBody.context, token: agentTokenOf(req, safeBody) }));
151
+ if (req.method === 'GET' && url.pathname === '/api/context') return send(200, await bus.getContext());
152
+ if (req.method === 'GET' && url.pathname === '/api/board') return send(200, { board: await bus.board() });
153
+ if (req.method === 'GET' && url.pathname === '/api/events') return send(200, { events: await bus.events({ limit: Number(q.limit || 50) }) });
154
+ if (req.method === 'GET' && url.pathname === '/api/sync') return send(200, await bus.sync({ sinceSeq: Number(q.sinceSeq || 0), limit: Number(q.limit || 500) }));
155
+ if (req.method === 'GET' && url.pathname === '/api/capsules') return send(200, { capsules: await bus.listCapsules() });
156
+ const capsuleGet = url.pathname.match(/^\/api\/capsules\/([a-z0-9-]+)$/);
157
+ if (req.method === 'GET' && capsuleGet) return send(200, await bus.getCapsule(capsuleGet[1], { create: q.create === '1', by: q.agent || null }));
158
+ const capsulePut = url.pathname.match(/^\/api\/capsules\/([a-z0-9-]+)\/section$/);
159
+ if (req.method === 'POST' && capsulePut) return send(200, await bus.updateCapsule(capsulePut[1], { by: safeBody.agent, section: safeBody.section, text: safeBody.text, token: agentTokenOf(req, safeBody) }));
160
+
161
+ return send(404, { error: 'not found' });
162
+ } catch (e) {
163
+ return send(400, { error: e.message, code: e.code || undefined });
164
+ }
165
+ };
166
+ }
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * GhostBus HOSTED server — many isolated workspaces, one process.
4
+ *
5
+ * GHOSTBUS_ADMIN_KEY=<long secret> node src/hosted-server.mjs [--port 8388] [--data-dir ./ghostbus-hosted]
6
+ *
7
+ * - Each workspace lives under /w/<id>/… (board at /w/<id>/, REST at
8
+ * /w/<id>/api/*, MCP at /w/<id>/mcp, probe at /w/<id>/probe).
9
+ * - Workspaces are fully isolated: separate stores, agents, tasks, files.
10
+ * - Every workspace gets its OWN key at creation (returned once; only its
11
+ * SHA-256 is kept in the registry). The admin key manages workspaces and
12
+ * also works as a master key inside any workspace.
13
+ * - Admin API (x-bus-key: <admin key>):
14
+ * POST /api/workspaces {id?, name} -> {id, name, key (once)}
15
+ * GET /api/workspaces -> [{id, name, createdAt, agents, queued}]
16
+ * DELETE /api/workspaces/<id> -> removes registry entry + data file
17
+ * POST /api/workspaces/<id>/rotate-key -> {id, key (once)}; old key dies at once
18
+ * GET /api/workspaces/<id>/export -> full-state export envelope (backup/migrate)
19
+ * POST /api/workspaces/import {id?, name?, state|export} -> {id, name, key (once)}
20
+ * - Registry + one data file per workspace live in the data dir.
21
+ * Set GHOSTBUS_REQUIRE_TOKENS=1 to enforce per-agent tokens in all workspaces.
22
+ */
23
+ import http from 'node:http';
24
+ import fs from 'node:fs';
25
+ import path from 'node:path';
26
+ import crypto from 'node:crypto';
27
+ import { createBus, FileStore, GHOSTBUS_VERSION, validateSnapshot } from './core.mjs';
28
+ import { createBusHandler, keyMatches } from './handler.mjs';
29
+
30
+ const argv = process.argv.slice(2);
31
+ const argVal = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : null; };
32
+ const port = Number(argVal('--port') || process.env.GHOSTBUS_PORT || 8388);
33
+ const dataDir = argVal('--data-dir') || process.env.GHOSTBUS_DATA_DIR || './ghostbus-hosted';
34
+ const ADMIN_KEY = process.env.GHOSTBUS_ADMIN_KEY || '';
35
+ const REQUIRE_TOKENS = process.env.GHOSTBUS_REQUIRE_TOKENS === '1';
36
+
37
+ if (!ADMIN_KEY) {
38
+ process.stderr.write('GHOSTBUS_ADMIN_KEY is required for the hosted server (workspace creation must not be open).\n');
39
+ process.exit(2);
40
+ }
41
+
42
+ fs.mkdirSync(dataDir, { recursive: true });
43
+ const registryPath = path.join(dataDir, 'registry.json');
44
+ const loadRegistry = () => { try { return JSON.parse(fs.readFileSync(registryPath, 'utf8')); } catch { return { workspaces: {} }; } };
45
+ const saveRegistry = (reg) => {
46
+ // Unique temp per save: two processes sharing this registry must never
47
+ // collide on one fixed '.tmp' name (first rename wins, second throws ENOENT).
48
+ const tmp = registryPath + '.tmp.' + process.pid + '.' + Math.random().toString(36).slice(2, 10);
49
+ try {
50
+ fs.writeFileSync(tmp, JSON.stringify(reg, null, 2));
51
+ fs.renameSync(tmp, registryPath);
52
+ } finally {
53
+ try { fs.unlinkSync(tmp); } catch { /* already renamed away */ }
54
+ }
55
+ };
56
+ const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
57
+ const validId = (id) => /^[a-z0-9][a-z0-9-]{1,62}$/.test(String(id || ''));
58
+
59
+ const buses = new Map(); // id -> {bus, handle}
60
+ async function getWorkspace(id) {
61
+ if (buses.has(id)) return buses.get(id);
62
+ const reg = loadRegistry();
63
+ const meta = reg.workspaces[id];
64
+ if (!meta) return null;
65
+ const bus = await createBus(new FileStore(path.join(dataDir, `${id}.json`)), { id, name: meta.name }, { requireTokens: REQUIRE_TOKENS });
66
+ const entry = { bus, meta };
67
+ buses.set(id, entry);
68
+ return entry;
69
+ }
70
+
71
+ // Per-workspace handler cache with hash-based key check: we build the core
72
+ // handler with key '' (open) and gate in the router using the stored hash.
73
+ const handlers = new Map();
74
+ function handlerFor(id, bus) {
75
+ if (!handlers.has(id)) handlers.set(id, createBusHandler({ id, bus, key: '' }));
76
+ return handlers.get(id);
77
+ }
78
+
79
+ const isAdmin = (req) => keyMatches(req.headers['x-bus-key'] || String(req.headers.authorization || '').replace(/^Bearer\s+/i, ''), ADMIN_KEY);
80
+ const wsKeyOk = (req, meta) => {
81
+ const given = req.headers['x-bus-key'] || String(req.headers.authorization || '').replace(/^Bearer\s+/i, '');
82
+ if (!given) return false;
83
+ if (keyMatches(given, ADMIN_KEY)) return true; // master key
84
+ return sha256(given) === meta.keyHash;
85
+ };
86
+
87
+ const LANDING = `<!doctype html><html><head><meta charset="utf-8"><title>GhostBus Hosted</title>
88
+ <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
+ <body><h1>👻 GhostBus Hosted</h1>
90
+ <p>Multi-workspace agent message bus. Each workspace lives at <code>/w/&lt;id&gt;/</code> with its own board, REST API, MCP endpoint, and key. Workspace data is never shown here — open your workspace board with its key.</p>
91
+ <p>Operators: see <code>POST /api/workspaces</code> (admin key) and the repo's <code>docs/hosted.md</code>.</p>
92
+ <p style="font-size:13px">GhostBus v${GHOSTBUS_VERSION} · Ghost Developer Studio</p></body></html>`;
93
+
94
+ const server = http.createServer(async (req, res) => {
95
+ const url = new URL(req.url, 'http://localhost');
96
+ const send = (status, obj, type = 'application/json') => {
97
+ const b = type === 'application/json' ? JSON.stringify(obj) : String(obj);
98
+ res.writeHead(status, { 'content-type': type }); res.end(b);
99
+ };
100
+ res.setHeader('x-content-type-options', 'nosniff');
101
+ res.setHeader('referrer-policy', 'no-referrer');
102
+ const readBody = () => new Promise((resolve) => {
103
+ let data = ''; req.on('data', c => { data += c; if (data.length > 5_000_000) req.destroy(); });
104
+ req.on('end', () => { try { resolve(data ? JSON.parse(data) : {}); } catch { resolve(null); } });
105
+ });
106
+
107
+ try {
108
+ if (req.method === 'GET' && url.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(LANDING); }
109
+ 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
+
111
+ // ---- admin ----
112
+ if (url.pathname === '/api/workspaces' && req.method === 'POST') {
113
+ if (!isAdmin(req)) return send(401, { error: 'admin key required' });
114
+ const body = await readBody();
115
+ if (!body) return send(400, { error: 'invalid JSON body' });
116
+ const reg = loadRegistry();
117
+ const id = body.id || `ws-${crypto.randomBytes(4).toString('hex')}`;
118
+ if (!validId(id)) return send(400, { error: 'workspace id: lowercase letters, digits, - (2-63 chars)' });
119
+ if (reg.workspaces[id]) return send(409, { error: `workspace already exists: ${id}` });
120
+ const key = crypto.randomBytes(24).toString('hex');
121
+ reg.workspaces[id] = { name: String(body.name || id).slice(0, 120), keyHash: sha256(key), createdAt: new Date().toISOString() };
122
+ saveRegistry(reg);
123
+ 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
+ }
125
+ if (url.pathname === '/api/workspaces' && req.method === 'GET') {
126
+ if (!isAdmin(req)) return send(401, { error: 'admin key required' });
127
+ const reg = loadRegistry();
128
+ const out = [];
129
+ for (const [id, meta] of Object.entries(reg.workspaces)) {
130
+ const w = await getWorkspace(id);
131
+ const s = await w.bus.status();
132
+ out.push({ id, name: meta.name, createdAt: meta.createdAt, agents: s.agents, queued: s.tasks.queued, needsApproval: s.tasks.needsApproval, messages: s.messages });
133
+ }
134
+ return send(200, { workspaces: out });
135
+ }
136
+ const rotateMatch = url.pathname.match(/^\/api\/workspaces\/([a-z0-9-]+)\/rotate-key$/);
137
+ if (req.method === 'POST' && rotateMatch) {
138
+ if (!isAdmin(req)) return send(401, { error: 'admin key required' });
139
+ const reg = loadRegistry();
140
+ const id = rotateMatch[1];
141
+ if (!reg.workspaces[id]) return send(404, { error: `no such workspace: ${id}` });
142
+ const key = crypto.randomBytes(24).toString('hex');
143
+ reg.workspaces[id].keyHash = sha256(key);
144
+ reg.workspaces[id].keyRotatedAt = new Date().toISOString();
145
+ saveRegistry(reg);
146
+ // Refresh the cached meta so the old key dies immediately, not on restart.
147
+ const cached = buses.get(id);
148
+ if (cached) cached.meta = reg.workspaces[id];
149
+ 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
+ }
151
+ const exportMatch = url.pathname.match(/^\/api\/workspaces\/([a-z0-9-]+)\/export$/);
152
+ if (req.method === 'GET' && exportMatch) {
153
+ if (!isAdmin(req)) return send(401, { error: 'admin key required' });
154
+ const id = exportMatch[1];
155
+ const w = await getWorkspace(id);
156
+ if (!w) return send(404, { error: `no such workspace: ${id}` });
157
+ const state = await w.bus.snapshot();
158
+ return send(200, { ghostbusExport: 1, version: GHOSTBUS_VERSION, exportedAt: new Date().toISOString(), workspace: { id, name: w.meta.name }, state });
159
+ }
160
+ if (url.pathname === '/api/workspaces/import' && req.method === 'POST') {
161
+ if (!isAdmin(req)) return send(401, { error: 'admin key required' });
162
+ const body = await readBody();
163
+ if (!body) return send(400, { error: 'invalid JSON body' });
164
+ // Accept either a full export envelope or a bare {state} / raw state.
165
+ const env = body.ghostbusExport ? body : null;
166
+ const state = env ? env.state : (body.state || body);
167
+ const bad = validateSnapshot(state);
168
+ if (bad) return send(400, { error: `invalid snapshot: ${bad}` });
169
+ const reg = loadRegistry();
170
+ const id = body.id || (env && env.workspace && env.workspace.id) || state.workspace.id || `ws-${crypto.randomBytes(4).toString('hex')}`;
171
+ if (!validId(id)) return send(400, { error: 'workspace id: lowercase letters, digits, - (2-63 chars)' });
172
+ if (reg.workspaces[id]) return send(409, { error: `workspace already exists: ${id} (delete it first, or import under a new id)` });
173
+ const name = String(body.name || (env && env.workspace && env.workspace.name) || state.workspace.name || id).slice(0, 120);
174
+ const key = crypto.randomBytes(24).toString('hex');
175
+ reg.workspaces[id] = { name, keyHash: sha256(key), createdAt: new Date().toISOString(), importedAt: new Date().toISOString() };
176
+ saveRegistry(reg);
177
+ // Write the state file first, then load it through the normal path so
178
+ // the bus sees exactly what a restart would see.
179
+ const file = path.join(dataDir, `${id}.json`);
180
+ const tmp = file + '.tmp.' + process.pid + '.' + Math.random().toString(36).slice(2, 10);
181
+ fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
182
+ fs.renameSync(tmp, file);
183
+ const w = await getWorkspace(id);
184
+ const status = await w.bus.status();
185
+ 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
+ }
187
+ const delMatch = url.pathname.match(/^\/api\/workspaces\/([a-z0-9-]+)$/);
188
+ if (req.method === 'DELETE' && delMatch) {
189
+ if (!isAdmin(req)) return send(401, { error: 'admin key required' });
190
+ const reg = loadRegistry();
191
+ const id = delMatch[1];
192
+ if (!reg.workspaces[id]) return send(404, { error: `no such workspace: ${id}` });
193
+ delete reg.workspaces[id];
194
+ saveRegistry(reg);
195
+ buses.delete(id); handlers.delete(id);
196
+ try { fs.unlinkSync(path.join(dataDir, `${id}.json`)); } catch {}
197
+ return send(200, { id, deleted: true });
198
+ }
199
+
200
+ // ---- workspace traffic ----
201
+ const wMatch = url.pathname.match(/^\/w\/([a-z0-9-]+)(\/.*)?$/);
202
+ if (wMatch) {
203
+ const id = wMatch[1];
204
+ const w = await getWorkspace(id);
205
+ if (!w) return send(404, { error: `no such workspace: ${id}` });
206
+ const sub = new URL(url.toString());
207
+ sub.pathname = wMatch[2] || '/';
208
+ // Open surfaces mirror the single relay: board page, health, probe.
209
+ const open = sub.pathname === '/' || sub.pathname === '/health' || sub.pathname === '/probe';
210
+ if (!open && !wsKeyOk(req, w.meta)) return send(401, { error: 'missing or wrong workspace key' });
211
+ return handlerFor(id, w.bus)(req, res, sub);
212
+ }
213
+
214
+ return send(404, { error: 'not found' });
215
+ } catch (e) {
216
+ return send(500, { error: e.message });
217
+ }
218
+ });
219
+
220
+ server.listen(port, () => {
221
+ 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`);
222
+ });
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * GhostBus HTTP relay (single workspace) — zero dependencies.
4
+ * Same core + same MCP tools over HTTP: agents that can't share a local file
5
+ * connect to one relay instead. For many isolated workspaces on one process,
6
+ * use hosted-server.mjs.
7
+ *
8
+ * node src/http-server.mjs [--port 8377] [--store ./ghostbus-data.json]
9
+ * Env: GHOSTBUS_PORT, GHOSTBUS_STORE, GHOSTBUS_WORKSPACE_NAME,
10
+ * GHOSTBUS_KEY — if set, every /api and /mcp call needs header
11
+ * `x-bus-key: <key>` or `Authorization: Bearer <key>`. /health and
12
+ * /probe (counts only) stay open.
13
+ * GHOSTBUS_REQUIRE_TOKENS=1 enforces per-agent tokens.
14
+ */
15
+ import http from 'node:http';
16
+ import { createBus, MemoryStore, FileStore, GHOSTBUS_VERSION } from './core.mjs';
17
+ import { createBusHandler } from './handler.mjs';
18
+
19
+ const argv = process.argv.slice(2);
20
+ const argVal = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : null; };
21
+ const port = Number(argVal('--port') || process.env.GHOSTBUS_PORT || 8377);
22
+ const storePath = argVal('--store') || process.env.GHOSTBUS_STORE || './ghostbus-data.json';
23
+ const wsName = argVal('--name') || process.env.GHOSTBUS_WORKSPACE_NAME || 'GhostBus Workspace';
24
+ const KEY = process.env.GHOSTBUS_KEY || '';
25
+
26
+ const store = storePath === ':memory:' ? new MemoryStore() : new FileStore(storePath);
27
+ const bus = await createBus(store, { name: wsName }, { requireTokens: process.env.GHOSTBUS_REQUIRE_TOKENS === '1' });
28
+ const handle = createBusHandler({ id: 'default', bus, key: KEY });
29
+
30
+ const server = http.createServer((req, res) => {
31
+ 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: e.message })); } catch {} });
33
+ });
34
+ server.listen(port, () => {
35
+ 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`);
36
+ });
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * GhostBus MCP server (stdio) — zero dependencies.
4
+ * Any MCP client (Claude, Cursor, Antigravity, custom agents) connects here and
5
+ * every connected client shares the SAME workspace store, so agents actually
6
+ * meet each other instead of each holding a private copy.
7
+ *
8
+ * node src/mcp-server.mjs [--store ./ghostbus-data.json] [--name "My Workspace"]
9
+ *
10
+ * Env: GHOSTBUS_STORE (file path; default ./ghostbus-data.json),
11
+ * GHOSTBUS_WORKSPACE_NAME.
12
+ * Use ":memory:" as the store for an ephemeral workspace.
13
+ */
14
+ import readline from 'node:readline';
15
+ import { createBus, MemoryStore, FileStore, GHOSTBUS_VERSION } from './core.mjs';
16
+ import { TOOLS, callTool } from './tools.mjs';
17
+
18
+ const argv = process.argv.slice(2);
19
+ const argVal = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : null; };
20
+ const storePath = argVal('--store') || process.env.GHOSTBUS_STORE || './ghostbus-data.json';
21
+ const wsName = argVal('--name') || process.env.GHOSTBUS_WORKSPACE_NAME || 'GhostBus Workspace';
22
+
23
+ const store = storePath === ':memory:' ? new MemoryStore() : new FileStore(storePath);
24
+ const bus = await createBus(store, { name: wsName }, { requireTokens: process.env.GHOSTBUS_REQUIRE_TOKENS === '1' });
25
+
26
+ const send = (obj) => process.stdout.write(JSON.stringify(obj) + '\n');
27
+ const result = (id, value) => send({ jsonrpc: '2.0', id, result: value });
28
+ const error = (id, code, message) => send({ jsonrpc: '2.0', id, error: { code, message } });
29
+
30
+ async function handle(msg) {
31
+ const { id, method, params } = msg;
32
+ if (method === 'initialize') {
33
+ return result(id, {
34
+ protocolVersion: '2024-11-05',
35
+ capabilities: { tools: {} },
36
+ serverInfo: { name: 'ghostbus', version: GHOSTBUS_VERSION },
37
+ });
38
+ }
39
+ if (method === 'notifications/initialized' || method === 'notifications/cancelled') return; // no id reply
40
+ if (method === 'ping') return result(id, {});
41
+ if (method === 'tools/list') return result(id, { tools: TOOLS });
42
+ if (method === 'tools/call') {
43
+ try {
44
+ const out = await callTool(bus, params.name, params.arguments || {});
45
+ const text = typeof out === 'string' ? out : JSON.stringify(out, null, 2);
46
+ return result(id, { content: [{ type: 'text', text }] });
47
+ } catch (e) {
48
+ return result(id, { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true });
49
+ }
50
+ }
51
+ if (id !== undefined) return error(id, -32601, `method not found: ${method}`);
52
+ }
53
+
54
+ const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity });
55
+ rl.on('line', async (line) => {
56
+ if (!line.trim()) return;
57
+ let msg;
58
+ try { msg = JSON.parse(line); } catch { return; }
59
+ await handle(msg);
60
+ });
61
+ process.stderr.write(`ghostbus mcp-server v${GHOSTBUS_VERSION} · store=${storePath} · workspace="${wsName}"\n`);
package/src/tools.mjs ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * GhostBus MCP tool definitions — shared by the stdio server and the HTTP /mcp endpoint.
3
+ * Every tool takes `agent` (the caller's registered name) where it acts on someone's behalf,
4
+ * so the provenance log always names a real participant.
5
+ */
6
+ export const TOOLS = [
7
+ { name: 'bus_status', description: 'Workspace status: agents, message/task/file counts, version.', inputSchema: { type: 'object', properties: {} } },
8
+ { name: 'bus_register', description: 'Register (or rejoin) this agent on the bus with a role and capabilities. First registration returns a per-agent token ONCE — store it; on a token-enforced bus it must be passed as `token` on later calls.', inputSchema: { type: 'object', required: ['agent'], properties: { agent: { type: 'string' }, role: { type: 'string' }, capabilities: { type: 'array', items: { type: 'string' } }, token: { type: 'string' } } } },
9
+ { name: 'bus_rotate_token', description: 'Rotate this agent\'s token (requires the current token on an enforced bus). The old token stops working; the new one is shown once.', inputSchema: { type: 'object', required: ['agent'], properties: { agent: { type: 'string' }, token: { type: 'string' } } } },
10
+ { name: 'bus_heartbeat', description: 'Heartbeat: mark this agent present and get the live agent list with online status.', inputSchema: { type: 'object', required: ['agent'], properties: { agent: { type: 'string' }, token: { type: 'string' } } } },
11
+ { name: 'bus_search', description: 'Search messages, tasks, and shared files by text.', inputSchema: { type: 'object', required: ['query'], properties: { query: { type: 'string' }, limit: { type: 'number', default: 20 } } } },
12
+ { name: 'bus_channels', description: 'List message channels with message counts.', inputSchema: { type: 'object', properties: {} } },
13
+ { name: 'bus_agents', description: 'List registered agents with roles, capabilities, and last-seen times.', inputSchema: { type: 'object', properties: {} } },
14
+ { name: 'bus_send', description: 'Send a message to one agent (to = its name) or broadcast (to = "*"). Set threadId to reply in a thread.', inputSchema: { type: 'object', required: ['agent', 'body'], properties: { agent: { type: 'string' }, to: { type: 'string', default: '*' }, body: { type: 'string' }, channel: { type: 'string', default: 'general' }, threadId: { type: 'number' } } } },
15
+ { name: 'bus_inbox', description: 'Read messages visible to this agent (direct, broadcast, and its own). Marks them read unless markRead=false.', inputSchema: { type: 'object', required: ['agent'], properties: { agent: { type: 'string' }, unreadOnly: { type: 'boolean', default: false }, limit: { type: 'number', default: 50 }, markRead: { type: 'boolean', default: true } } } },
16
+ { name: 'bus_thread', description: 'Read a message thread (root message + its replies).', inputSchema: { type: 'object', required: ['id'], properties: { id: { type: 'number' } } } },
17
+ { name: 'bus_create_task', description: 'Create a handoff task. Optionally assign it, mark urgent, or gate it behind human/agent approval (needsApproval).', inputSchema: { type: 'object', required: ['agent', 'title'], properties: { agent: { type: 'string' }, title: { type: 'string' }, body: { type: 'string' }, assignee: { type: 'string' }, priority: { type: 'string', enum: ['normal', 'urgent'] }, needsApproval: { type: 'boolean', default: false }, blockedBy: { type: 'array', items: { type: 'number' } } } } },
18
+ { name: 'bus_list_tasks', description: 'List tasks, optionally filtered by status or assignee/claimer.', inputSchema: { type: 'object', properties: { status: { type: 'string' }, assignee: { type: 'string' } } } },
19
+ { name: 'bus_approve_task', description: 'Approve a task that is waiting in needs-approval, releasing it to the queue.', inputSchema: { type: 'object', required: ['agent', 'id'], properties: { agent: { type: 'string' }, id: { type: 'number' } } } },
20
+ { name: 'bus_claim_task', description: 'Claim a queued task exclusively (15-minute lease). Fails if another agent holds it.', inputSchema: { type: 'object', required: ['agent', 'id'], properties: { agent: { type: 'string' }, id: { type: 'number' } } } },
21
+ { name: 'bus_complete_task', description: 'Complete a task you claimed, with a result summary for the sender.', inputSchema: { type: 'object', required: ['agent', 'id'], properties: { agent: { type: 'string' }, id: { type: 'number' }, result: { type: 'string' } } } },
22
+ { name: 'bus_cancel_task', description: 'Cancel an open task.', inputSchema: { type: 'object', required: ['agent', 'id'], properties: { agent: { type: 'string' }, id: { type: 'number' } } } },
23
+ { name: 'workspace_put_file', description: 'Write a shared workspace file (capsule, note, deliverable). Path may not contain "..".', inputSchema: { type: 'object', required: ['agent', 'path', 'text'], properties: { agent: { type: 'string' }, path: { type: 'string' }, text: { type: 'string' } } } },
24
+ { name: 'workspace_get_file', description: 'Read a shared workspace file.', inputSchema: { type: 'object', required: ['path'], properties: { path: { type: 'string' } } } },
25
+ { name: 'workspace_list_files', description: 'List shared workspace files with size and last writer.', inputSchema: { type: 'object', properties: {} } },
26
+ { name: 'workspace_put_context', description: 'Publish the shared context object (project state every agent reads first).', inputSchema: { type: 'object', required: ['agent', 'context'], properties: { agent: { type: 'string' }, context: { type: 'object' } } } },
27
+ { name: 'workspace_get_context', description: 'Read the shared context object.', inputSchema: { type: 'object', properties: {} } },
28
+ { name: 'workspace_board', description: 'Render the shared board: agents, open tasks, files, recent activity.', inputSchema: { type: 'object', properties: {} } },
29
+ { name: 'bus_comment_task', description: 'Comment on a task (discussion lives on the task, next to its history).', inputSchema: { type: 'object', required: ['agent', 'id', 'body'], properties: { agent: { type: 'string' }, id: { type: 'number' }, body: { type: 'string' }, token: { type: 'string' } } } },
30
+ { name: 'workspace_delete_file', description: 'Delete a shared workspace file.', inputSchema: { type: 'object', required: ['agent', 'path'], properties: { agent: { type: 'string' }, path: { type: 'string' }, token: { type: 'string' } } } },
31
+ { name: 'bus_sync', description: 'Catch-up sync: all provenance events after a sequence number (an agent that was offline replays exactly what it missed). Returns headSeq to store as the next cursor.', inputSchema: { type: 'object', properties: { sinceSeq: { type: 'number', default: 0 }, limit: { type: 'number', default: 500 } } } },
32
+ { name: 'workspace_get_capsule', description: 'Read a project capsule (structured shared memory: State / Decisions (locked) / Next / Session log). Creates it from the template when create=true.', inputSchema: { type: 'object', required: ['slug'], properties: { slug: { type: 'string' }, create: { type: 'boolean', default: false }, title: { type: 'string' }, agent: { type: 'string' }, token: { type: 'string' } } } },
33
+ { name: 'workspace_update_capsule', description: 'Update one capsule section (State, Decisions (locked), Next) or append a Session log entry. Other sections are preserved.', inputSchema: { type: 'object', required: ['agent', 'slug', 'section', 'text'], properties: { agent: { type: 'string' }, slug: { type: 'string' }, section: { type: 'string', enum: ['State', 'Decisions (locked)', 'Next', 'Session log'] }, text: { type: 'string' }, token: { type: 'string' } } } },
34
+ { name: 'workspace_file_history', description: 'Version history of a shared file (current version + up to 10 previous).', inputSchema: { type: 'object', required: ['path'], properties: { path: { type: 'string' } } } },
35
+ { name: 'bus_events', description: 'Read the provenance event log (who did what, when).', inputSchema: { type: 'object', properties: { limit: { type: 'number', default: 50 } } } },
36
+ ];
37
+
38
+ export async function callTool(bus, name, args = {}) {
39
+ switch (name) {
40
+ case 'bus_status': return bus.status();
41
+ case 'bus_register': return bus.registerAgent({ name: args.agent, role: args.role, capabilities: args.capabilities, token: args.token ?? null });
42
+ case 'bus_agents': return { agents: await bus.listAgents() };
43
+ case 'bus_send': return bus.sendMessage({ from: args.agent, to: args.to ?? '*', body: args.body, channel: args.channel ?? 'general', threadId: args.threadId ?? null, token: args.token ?? null });
44
+ case 'bus_inbox': return { messages: await bus.inbox(args.agent, { unreadOnly: !!args.unreadOnly, limit: args.limit ?? 50, markRead: args.markRead !== false, token: args.token ?? null }) };
45
+ case 'bus_thread': return { messages: await bus.thread(args.id) };
46
+ case 'bus_create_task': return bus.createTask({ from: args.agent, title: args.title, body: args.body ?? '', assignee: args.assignee ?? null, priority: args.priority ?? 'normal', needsApproval: !!args.needsApproval, blockedBy: args.blockedBy ?? [], token: args.token ?? null });
47
+ case 'bus_list_tasks': return { tasks: await bus.listTasks({ status: args.status ?? null, assignee: args.assignee ?? null }) };
48
+ case 'bus_approve_task': return bus.approveTask(args.id, { by: args.agent, token: args.token ?? null });
49
+ case 'bus_claim_task': return bus.claimTask(args.id, { by: args.agent, token: args.token ?? null });
50
+ case 'bus_complete_task': return bus.completeTask(args.id, { by: args.agent, result: args.result ?? '', token: args.token ?? null });
51
+ case 'bus_cancel_task': return bus.cancelTask(args.id, { by: args.agent, token: args.token ?? null });
52
+ case 'workspace_put_file': return bus.putFile({ by: args.agent, path: args.path, text: args.text, token: args.token ?? null });
53
+ case 'workspace_get_file': return bus.getFile(args.path);
54
+ case 'workspace_list_files': return { files: await bus.listFiles() };
55
+ case 'workspace_put_context': return bus.putContext({ by: args.agent, context: args.context, token: args.token ?? null });
56
+ case 'workspace_get_context': return bus.getContext();
57
+ case 'workspace_board': return { board: await bus.board() };
58
+ case 'bus_events': return { events: await bus.events({ limit: args.limit ?? 50 }) };
59
+ case 'bus_rotate_token': return bus.rotateAgentToken({ name: args.agent, token: args.token ?? null });
60
+ case 'bus_heartbeat': return bus.heartbeat({ name: args.agent, token: args.token ?? null });
61
+ case 'bus_search': return bus.search(args.query, { limit: args.limit ?? 20 });
62
+ case 'bus_channels': return { channels: await bus.listChannels() };
63
+ case 'bus_comment_task': return bus.addTaskComment(args.id, { by: args.agent, body: args.body, token: args.token ?? null });
64
+ case 'workspace_delete_file': return bus.deleteFile({ by: args.agent, path: args.path, token: args.token ?? null });
65
+ case 'bus_sync': return bus.sync({ sinceSeq: args.sinceSeq ?? 0, limit: args.limit ?? 500 });
66
+ case 'workspace_get_capsule': return bus.getCapsule(args.slug, { create: !!args.create, title: args.title ?? '', by: args.agent ?? null, token: args.token ?? null });
67
+ case 'workspace_update_capsule': return bus.updateCapsule(args.slug, { by: args.agent, section: args.section, text: args.text, token: args.token ?? null });
68
+ case 'workspace_file_history': return bus.fileHistory(args.path);
69
+ default: throw Object.assign(new Error(`unknown tool: ${name}`), { code: 'UNKNOWN_TOOL' });
70
+ }
71
+ }