hivecode-mcp 0.14.1 → 0.14.2
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/HIVE_RULES.md +98 -98
- package/LICENSE +21 -21
- package/README.md +421 -421
- package/core.js +91 -91
- package/docs/ALWAYS_ON.md +310 -310
- package/docs/GITHUB_WORKFLOW.md +394 -394
- package/docs/HIVE_RULES.md +41 -41
- package/docs/MCP.md +157 -157
- package/docs/QUICKSTART.md +266 -266
- package/docs/SELF_HOSTING.md +256 -256
- package/docs/SLACK_SETUP.md +474 -474
- package/docs/TROUBLESHOOTING.md +105 -105
- package/hive-activity.js +336 -336
- package/hive-authors.js +46 -44
- package/hive-await.js +230 -230
- package/hive-branch.js +608 -607
- package/hive-busy.js +88 -88
- package/hive-coord.js +110 -110
- package/hive-gate.js +135 -135
- package/hive-github.js +3 -1
- package/hive-hook-install.js +179 -179
- package/hive-instructions.js +74 -74
- package/hive-land.js +695 -691
- package/hive-listen.js +81 -81
- package/hive-mailbox.js +520 -520
- package/hive-mcp-http.js +591 -591
- package/hive-mcp.js +33 -6
- package/hive-mux-proto.js +58 -58
- package/hive-mux.js +351 -351
- package/hive-notify.js +234 -233
- package/hive-obligations.js +247 -247
- package/hive-poll-proto.js +50 -50
- package/hive-pr-staleness.js +189 -189
- package/hive-pr.js +600 -512
- package/hive-privacy.js +327 -327
- package/hive-receipts.js +233 -233
- package/hive-relay-health.js +234 -234
- package/hive-repos.js +430 -430
- package/hive-room-key.js +210 -210
- package/hive-room-mode.js +1668 -1668
- package/hive-room-watcher.js +562 -561
- package/hive-secrets.js +89 -89
- package/hive-session.js +114 -114
- package/hive-setup.js +560 -560
- package/hive-slack-agent.js +408 -408
- package/hive-slack.js +2384 -2367
- package/hive-stdio.js +172 -172
- package/hive-stop-hook.js +293 -293
- package/hive-throttle.js +118 -118
- package/hive-time.js +71 -71
- package/hive-transport.js +578 -578
- package/hive-update.js +58 -58
- package/hive-version.js +110 -110
- package/hive-webhook.js +55 -54
- package/icr-merge.js +58 -58
- package/icr.js +851 -851
- package/lang-brace.js +305 -305
- package/lang-js.js +651 -651
- package/lang-json.js +211 -211
- package/lang-python.js +588 -588
- package/lang-ruby.js +230 -230
- package/lang-toml.js +136 -136
- package/lang-yaml.js +183 -183
- package/limits.js +413 -413
- package/package.json +1 -1
- package/reconcile.cjs +621 -621
- package/slack-app-manifest-agent.json +61 -61
- package/slack-app-manifest-inbound.json +36 -36
- package/slack-app-manifest.json +27 -27
- package/substrate.js +252 -252
- package/sync.js +169 -11
- package/token.js +201 -201
package/hive-room-key.js
CHANGED
|
@@ -1,210 +1,210 @@
|
|
|
1
|
-
// PERSONAL ROOMS (8 Oct 2026): a room that only the people its host invites can enter.
|
|
2
|
-
//
|
|
3
|
-
// The relay already enforces this for any room whose id is "hs_<fp>_<rand>". fp is a fingerprint of
|
|
4
|
-
// the host's public key, every token for the room carries that key, and only the matching private
|
|
5
|
-
// key can sign one (server.js authorize). So a stranger holding the room's name gets nothing, even
|
|
6
|
-
// on the open public relay. What was missing was a way for an AGENT to make such a room: until now
|
|
7
|
-
// only the VS Code extension could, holding the key in the editor's secret storage.
|
|
8
|
-
//
|
|
9
|
-
// THE KEY NEVER LIVES IN A ROOM FOLDER. It sits in ~/.hivecode/keys/<room>.json, outside every
|
|
10
|
-
// folder the sync engine shares, so no client -- old, new or misconfigured -- can publish it.
|
|
11
|
-
// Whoever holds that file owns the room: lose it and nobody can invite again; copy it and the copy
|
|
12
|
-
// can invite too.
|
|
13
|
-
//
|
|
14
|
-
// INVITES ARE A COMMAND, NOT A TOOL. Every tool definition is paid on every request by every agent
|
|
15
|
-
// (tests/token-budget-test.mjs holds that to 3,500 tokens), so inviting -- something a host does a
|
|
16
|
-
// few times a week -- is `hivecode-mcp invite <name>`, run by the person or by an agent that has a
|
|
17
|
-
// shell. One link per person, carrying a token minted for them alone: an invite IS that person,
|
|
18
|
-
// which is why it is never pasted into the room chat (hive_say refuses tokens).
|
|
19
|
-
import fs from 'fs'
|
|
20
|
-
import os from 'os'
|
|
21
|
-
import path from 'path'
|
|
22
|
-
import crypto from 'crypto'
|
|
23
|
-
import { sign, decodeUnsafe, keyFingerprint, roomFingerprint, isSecuredRoom, makeSecuredRoomId } from './token.js'
|
|
24
|
-
import { validateMemberName } from './hive-mailbox.js'
|
|
25
|
-
import { relayFetch } from './hive-transport.js' // through the proxy, as every call to the relay
|
|
26
|
-
|
|
27
|
-
export const keysDir = () => process.env.HIVE_KEYS_DIR || path.join(os.homedir(), '.hivecode', 'keys')
|
|
28
|
-
const keyFile = (room) => path.join(keysDir(), `${room}.json`)
|
|
29
|
-
const DAY = 86400
|
|
30
|
-
const nowSec = () => Math.floor(Date.now() / 1000)
|
|
31
|
-
|
|
32
|
-
/** A new personal room, in memory only: nothing is written until the join is allowed to go ahead. */
|
|
33
|
-
export function newPersonalRoom() {
|
|
34
|
-
const kp = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 })
|
|
35
|
-
const publicKey = kp.publicKey.export({ type: 'spki', format: 'pem' })
|
|
36
|
-
const privateKey = kp.privateKey.export({ type: 'pkcs8', format: 'pem' })
|
|
37
|
-
return { room: makeSecuredRoomId(publicKey, crypto.randomBytes(6).toString('hex')), publicKey, privateKey }
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function saveRoomKey(k, { relay, host }) {
|
|
41
|
-
fs.mkdirSync(keysDir(), { recursive: true, mode: 0o700 })
|
|
42
|
-
const rec = { room: k.room, relay, host, publicKey: k.publicKey, privateKey: k.privateKey, created: Date.now(), invites: [] }
|
|
43
|
-
// 'wx': a fresh room never overwrites a key that exists, which would orphan the room it opened.
|
|
44
|
-
fs.writeFileSync(keyFile(k.room), JSON.stringify(rec, null, 2), { mode: 0o600, flag: 'wx' })
|
|
45
|
-
return rec
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/** The key record for a personal room this machine hosts, or null. A file whose key does not match
|
|
49
|
-
* the room's fingerprint is treated as absent: it would only mint tokens the relay refuses. */
|
|
50
|
-
export function loadRoomKey(room) {
|
|
51
|
-
if (!isSecuredRoom(room)) return null
|
|
52
|
-
try {
|
|
53
|
-
const rec = JSON.parse(fs.readFileSync(keyFile(room), 'utf8'))
|
|
54
|
-
return rec && rec.privateKey && keyFingerprint(rec.publicKey) === roomFingerprint(room) ? rec : null
|
|
55
|
-
} catch { return null }
|
|
56
|
-
}
|
|
57
|
-
const writeRec = (rec) => fs.writeFileSync(keyFile(rec.room), JSON.stringify(rec, null, 2), { mode: 0o600 })
|
|
58
|
-
|
|
59
|
-
/** The same claims the extension mints (extension.js mintToken), so either can host and the relay
|
|
60
|
-
* cannot tell them apart. paths = what they may SEE; writePaths = the part of it they may EDIT. */
|
|
61
|
-
export function mintRoomToken(rec, { name, kind = 'ai', role = 'writer', paths, writePaths, owner, ttlSec = 7 * DAY }) {
|
|
62
|
-
const now = nowSec()
|
|
63
|
-
const jti = 'jti-' + crypto.randomBytes(9).toString('base64url')
|
|
64
|
-
const payload = {
|
|
65
|
-
iss: 'hivecode', sub: name, name, kind, ...(owner ? { owner } : {}),
|
|
66
|
-
pk: rec.publicKey, // self-certifying: the relay binds this key to the room id
|
|
67
|
-
scopes: [{ room: rec.room, role, ...(paths && paths.length ? { paths } : {}), ...(writePaths && writePaths.length ? { writePaths } : {}) }],
|
|
68
|
-
iat: now, exp: now + ttlSec, jti,
|
|
69
|
-
}
|
|
70
|
-
return { token: sign(payload, { privateKey: rec.privateKey }), jti, exp: payload.exp }
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** The host's own token, minted afresh when it is missing or within an hour of running out: the host
|
|
74
|
-
* holds the key, so its own access must never lapse. Only for the name the room was hosted under --
|
|
75
|
-
* another participant on this machine, joining with its own invite, keeps its own narrower grant. */
|
|
76
|
-
export function hostToken(room, name, current, { kind = 'ai', owner = '' } = {}) {
|
|
77
|
-
const rec = loadRoomKey(room)
|
|
78
|
-
if (!rec || rec.host !== name) return current
|
|
79
|
-
const p = current ? decodeUnsafe(current) : null
|
|
80
|
-
const fresh = !!(p && p.sub === name && p.exp > nowSec() + 3600 && p.pk && keyFingerprint(p.pk) === roomFingerprint(room))
|
|
81
|
-
return fresh ? current : mintRoomToken(rec, { name, kind, role: 'maintainer', owner, ttlSec: 30 * DAY }).token
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// A path a token may name: relative, no "..", no drive or root. Globs are fine ("src/**", "!**/*.env").
|
|
85
|
-
function badPath(g) {
|
|
86
|
-
const s = String(g).replace(/^!/, '')
|
|
87
|
-
return !s || s.length > 200 || /^([A-Za-z]:|[\\/])/.test(s) || /(^|[\\/])\.\.([\\/]|$)/.test(s) || /[\x00-\x1f]/.test(s)
|
|
88
|
-
}
|
|
89
|
-
const list = (v) => (v == null || v === true ? [] : String(v).split(',').map((s) => s.trim()).filter(Boolean))
|
|
90
|
-
const when = (sec) => new Date(sec * 1000).toISOString().slice(0, 10)
|
|
91
|
-
|
|
92
|
-
function whatTheyGet(i) {
|
|
93
|
-
const see = i.paths && i.paths.length ? i.paths.join(', ') : 'every file in the room'
|
|
94
|
-
const edit = i.role === 'reader' ? 'read only' : i.writePaths && i.writePaths.length ? `may edit ${i.writePaths.join(', ')}` : 'may edit what they see'
|
|
95
|
-
return `${i.kind}, ${i.role}: sees ${see}; ${edit}`
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** Make one person's invite. Returns { ok, text, link?, jti? }. */
|
|
99
|
-
export function makeInvite({ room, relay, name, kind = 'ai', reader = false, paths = [], writePaths = [], days = 7, owner = '' }) {
|
|
100
|
-
const rec = loadRoomKey(room)
|
|
101
|
-
if (!rec) {
|
|
102
|
-
return { ok: false, text: isSecuredRoom(room)
|
|
103
|
-
? `Only the machine that hosted this personal room can invite people to it, and this one does not hold its key (${keysDir()}). Ask the host.`
|
|
104
|
-
: `Room ${room} is open: anyone with its link can join, so there is nothing to invite into. For a room only invited people can enter, host one with hive_join and private: true.` }
|
|
105
|
-
}
|
|
106
|
-
const nc = validateMemberName(name)
|
|
107
|
-
if (!nc.ok) return { ok: false, text: `${nc.error}\nNothing was minted.` }
|
|
108
|
-
if (name === rec.host) return { ok: false, text: `"${name}" is the host of this room. Pick the invitee's own name.\nNothing was minted.` }
|
|
109
|
-
const bad = [...paths, ...writePaths].filter(badPath)
|
|
110
|
-
if (bad.length) return { ok: false, text: `These paths cannot go in an invite (they must be relative, with no ".."): ${bad.join(', ')}\nNothing was minted.` }
|
|
111
|
-
const d = Number(days)
|
|
112
|
-
if (!Number.isFinite(d) || d <= 0 || d > 30) return { ok: false, text: `--days must be between 1 and 30 (got ${days}). A short invite is one you never have to remember to take back.\nNothing was minted.` }
|
|
113
|
-
const k = kind === 'human' ? 'human' : 'ai'
|
|
114
|
-
const role = reader ? 'reader' : 'writer'
|
|
115
|
-
const { token, jti, exp } = mintRoomToken(rec, { name, kind: k, role, paths, writePaths, owner, ttlSec: Math.round(d * DAY) })
|
|
116
|
-
const inv = { jti, name, kind: k, role, paths: paths.length ? paths : null, writePaths: writePaths.length ? writePaths : null, exp, at: Date.now() }
|
|
117
|
-
rec.invites = [...(rec.invites || []), inv]
|
|
118
|
-
writeRec(rec)
|
|
119
|
-
const link = `${relay || rec.relay}|${room}|${token}`
|
|
120
|
-
const text = `INVITE for ${name} (${whatTheyGet(inv)}), until ${when(exp)}.
|
|
121
|
-
Send it to ${name} directly, never in the room chat: whoever holds this link joins as ${name}.
|
|
122
|
-
|
|
123
|
-
Join my Hivecode room. In a terminal, run this once (needs Node.js 18+), then do what it prints:
|
|
124
|
-
npx -y hivecode-mcp@latest setup "${link}"
|
|
125
|
-
|
|
126
|
-
Take it back any time: npx -y hivecode-mcp@latest revoke ${name}`
|
|
127
|
-
return { ok: true, text, link, jti }
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const httpBase = (relay) => String(relay).replace(/^ws(s?):\/\//i, 'http$1://').replace(/\/+$/, '')
|
|
131
|
-
|
|
132
|
-
/** Take back every live invite for a name. The relay refuses those tokens from then on and closes any
|
|
133
|
-
* connection using them. Returns { ok, text }. */
|
|
134
|
-
export async function revokeInvite({ room, relay, name }) {
|
|
135
|
-
const rec = loadRoomKey(room)
|
|
136
|
-
if (!rec) return { ok: false, text: `This machine does not host room ${room}, so it cannot take invites back. Only the host can.` }
|
|
137
|
-
const live = (rec.invites || []).filter((i) => i.name === name && !i.revokedAt)
|
|
138
|
-
if (!live.length) {
|
|
139
|
-
const names = [...new Set((rec.invites || []).filter((i) => !i.revokedAt).map((i) => i.name))]
|
|
140
|
-
return { ok: false, text: `No live invite for "${name}" in this room.${names.length ? ` Invited: ${names.join(', ')}.` : ' Nobody has been invited yet.'}` }
|
|
141
|
-
}
|
|
142
|
-
const owner = mintRoomToken(rec, { name: rec.host, role: 'maintainer', ttlSec: 600 }).token
|
|
143
|
-
let done = 0, dropped = 0
|
|
144
|
-
const failures = []
|
|
145
|
-
for (const i of live) {
|
|
146
|
-
try {
|
|
147
|
-
const r = await relayFetch(`${httpBase(relay || rec.relay)}/__hive/revoke`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ room, token: owner, jti: i.jti }) })
|
|
148
|
-
const body = await r.json().catch(() => ({}))
|
|
149
|
-
if (r.ok && body.ok) { i.revokedAt = Date.now(); done++; dropped += Number(body.dropped) || 0 }
|
|
150
|
-
else failures.push(`HTTP ${r.status}${body.error ? ` ${body.error}` : ''}`)
|
|
151
|
-
} catch (e) { failures.push(e.message) }
|
|
152
|
-
}
|
|
153
|
-
writeRec(rec)
|
|
154
|
-
if (!done) return { ok: false, text: `Could not reach the relay to revoke ${name}'s invite (${failures.join('; ')}). Nothing changed; try again.` }
|
|
155
|
-
return { ok: true, text: `Revoked ${done} invite(s) for ${name}. The relay refuses them from now on${dropped ? `, and closed ${dropped} connection(s) using them` : ''}.${failures.length ? `\n${failures.length} more could not be revoked (${failures.join('; ')}); run this again.` : ''}` }
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
// ---- the command line: hivecode-mcp invite <name> ... | hivecode-mcp revoke <name> ----
|
|
159
|
-
|
|
160
|
-
function parseArgs(argv) {
|
|
161
|
-
const o = { _: [] }
|
|
162
|
-
for (let i = 0; i < argv.length; i++) {
|
|
163
|
-
const a = argv[i]
|
|
164
|
-
if (!a.startsWith('--')) { o._.push(a); continue }
|
|
165
|
-
const key = a.slice(2)
|
|
166
|
-
if (['reader', 'human'].includes(key)) o[key] = true
|
|
167
|
-
else o[key] = argv[++i]
|
|
168
|
-
}
|
|
169
|
-
return o
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
// Which room: --dir's .hive.json, else this folder's, else the only room this machine hosts.
|
|
173
|
-
function findRoom(o) {
|
|
174
|
-
const dir = path.resolve(o.dir || '.')
|
|
175
|
-
try {
|
|
176
|
-
const c = JSON.parse(fs.readFileSync(path.join(dir, '.hive.json'), 'utf8'))
|
|
177
|
-
if (c && c.room) return { room: c.room, relay: c.relay || '' }
|
|
178
|
-
} catch { /* not a room folder */ }
|
|
179
|
-
let mine = []
|
|
180
|
-
try { mine = fs.readdirSync(keysDir()).filter((f) => f.endsWith('.json')).map((f) => loadRoomKey(f.slice(0, -5))).filter(Boolean) } catch { /* no keys yet */ }
|
|
181
|
-
if (mine.length === 1) return { room: mine[0].room, relay: mine[0].relay }
|
|
182
|
-
return { error: mine.length
|
|
183
|
-
? `${dir} is not a room folder, and this machine hosts ${mine.length} personal rooms. Say which with --dir <the room's folder>:\n${mine.map((r) => ` ${r.room} (host ${r.host}, created ${new Date(r.created).toISOString().slice(0, 10)})`).join('\n')}`
|
|
184
|
-
: `${dir} is not a room folder, and this machine hosts no personal room. Host one first: hive_join with private: true.` }
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
const USAGE = `Usage:
|
|
188
|
-
hivecode-mcp invite <name> [--reader] [--human] [--paths a,b] [--edit a,b] [--days 7] [--owner <their human>] [--dir <room folder>]
|
|
189
|
-
hivecode-mcp revoke <name> [--dir <room folder>]
|
|
190
|
-
|
|
191
|
-
--reader they can read but not edit --paths only these folders/globs are visible to them
|
|
192
|
-
--human the invitee is a person, not AI --edit of what they see, only these may be edited
|
|
193
|
-
--days how long the invite lasts (default 7, at most 30)`
|
|
194
|
-
|
|
195
|
-
export async function main(argv) {
|
|
196
|
-
const cmd = argv[0]
|
|
197
|
-
const o = parseArgs(argv.slice(1))
|
|
198
|
-
const name = o._[0]
|
|
199
|
-
if (!name) { console.log(USAGE); return 2 }
|
|
200
|
-
const where = findRoom(o)
|
|
201
|
-
if (where.error) { console.log(where.error); return 1 }
|
|
202
|
-
if (cmd === 'revoke') {
|
|
203
|
-
const r = await revokeInvite({ room: where.room, relay: where.relay, name })
|
|
204
|
-
console.log(r.text)
|
|
205
|
-
return r.ok ? 0 : 1
|
|
206
|
-
}
|
|
207
|
-
const r = makeInvite({ room: where.room, relay: where.relay, name, kind: o.human ? 'human' : 'ai', reader: !!o.reader, paths: list(o.paths), writePaths: list(o.edit), days: o.days == null ? 7 : o.days, owner: o.owner || '' })
|
|
208
|
-
console.log(r.text)
|
|
209
|
-
return r.ok ? 0 : 1
|
|
210
|
-
}
|
|
1
|
+
// PERSONAL ROOMS (8 Oct 2026): a room that only the people its host invites can enter.
|
|
2
|
+
//
|
|
3
|
+
// The relay already enforces this for any room whose id is "hs_<fp>_<rand>". fp is a fingerprint of
|
|
4
|
+
// the host's public key, every token for the room carries that key, and only the matching private
|
|
5
|
+
// key can sign one (server.js authorize). So a stranger holding the room's name gets nothing, even
|
|
6
|
+
// on the open public relay. What was missing was a way for an AGENT to make such a room: until now
|
|
7
|
+
// only the VS Code extension could, holding the key in the editor's secret storage.
|
|
8
|
+
//
|
|
9
|
+
// THE KEY NEVER LIVES IN A ROOM FOLDER. It sits in ~/.hivecode/keys/<room>.json, outside every
|
|
10
|
+
// folder the sync engine shares, so no client -- old, new or misconfigured -- can publish it.
|
|
11
|
+
// Whoever holds that file owns the room: lose it and nobody can invite again; copy it and the copy
|
|
12
|
+
// can invite too.
|
|
13
|
+
//
|
|
14
|
+
// INVITES ARE A COMMAND, NOT A TOOL. Every tool definition is paid on every request by every agent
|
|
15
|
+
// (tests/token-budget-test.mjs holds that to 3,500 tokens), so inviting -- something a host does a
|
|
16
|
+
// few times a week -- is `hivecode-mcp invite <name>`, run by the person or by an agent that has a
|
|
17
|
+
// shell. One link per person, carrying a token minted for them alone: an invite IS that person,
|
|
18
|
+
// which is why it is never pasted into the room chat (hive_say refuses tokens).
|
|
19
|
+
import fs from 'fs'
|
|
20
|
+
import os from 'os'
|
|
21
|
+
import path from 'path'
|
|
22
|
+
import crypto from 'crypto'
|
|
23
|
+
import { sign, decodeUnsafe, keyFingerprint, roomFingerprint, isSecuredRoom, makeSecuredRoomId } from './token.js'
|
|
24
|
+
import { validateMemberName } from './hive-mailbox.js'
|
|
25
|
+
import { relayFetch } from './hive-transport.js' // through the proxy, as every call to the relay
|
|
26
|
+
|
|
27
|
+
export const keysDir = () => process.env.HIVE_KEYS_DIR || path.join(os.homedir(), '.hivecode', 'keys')
|
|
28
|
+
const keyFile = (room) => path.join(keysDir(), `${room}.json`)
|
|
29
|
+
const DAY = 86400
|
|
30
|
+
const nowSec = () => Math.floor(Date.now() / 1000)
|
|
31
|
+
|
|
32
|
+
/** A new personal room, in memory only: nothing is written until the join is allowed to go ahead. */
|
|
33
|
+
export function newPersonalRoom() {
|
|
34
|
+
const kp = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 })
|
|
35
|
+
const publicKey = kp.publicKey.export({ type: 'spki', format: 'pem' })
|
|
36
|
+
const privateKey = kp.privateKey.export({ type: 'pkcs8', format: 'pem' })
|
|
37
|
+
return { room: makeSecuredRoomId(publicKey, crypto.randomBytes(6).toString('hex')), publicKey, privateKey }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function saveRoomKey(k, { relay, host }) {
|
|
41
|
+
fs.mkdirSync(keysDir(), { recursive: true, mode: 0o700 })
|
|
42
|
+
const rec = { room: k.room, relay, host, publicKey: k.publicKey, privateKey: k.privateKey, created: Date.now(), invites: [] }
|
|
43
|
+
// 'wx': a fresh room never overwrites a key that exists, which would orphan the room it opened.
|
|
44
|
+
fs.writeFileSync(keyFile(k.room), JSON.stringify(rec, null, 2), { mode: 0o600, flag: 'wx' })
|
|
45
|
+
return rec
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The key record for a personal room this machine hosts, or null. A file whose key does not match
|
|
49
|
+
* the room's fingerprint is treated as absent: it would only mint tokens the relay refuses. */
|
|
50
|
+
export function loadRoomKey(room) {
|
|
51
|
+
if (!isSecuredRoom(room)) return null
|
|
52
|
+
try {
|
|
53
|
+
const rec = JSON.parse(fs.readFileSync(keyFile(room), 'utf8'))
|
|
54
|
+
return rec && rec.privateKey && keyFingerprint(rec.publicKey) === roomFingerprint(room) ? rec : null
|
|
55
|
+
} catch { return null }
|
|
56
|
+
}
|
|
57
|
+
const writeRec = (rec) => fs.writeFileSync(keyFile(rec.room), JSON.stringify(rec, null, 2), { mode: 0o600 })
|
|
58
|
+
|
|
59
|
+
/** The same claims the extension mints (extension.js mintToken), so either can host and the relay
|
|
60
|
+
* cannot tell them apart. paths = what they may SEE; writePaths = the part of it they may EDIT. */
|
|
61
|
+
export function mintRoomToken(rec, { name, kind = 'ai', role = 'writer', paths, writePaths, owner, ttlSec = 7 * DAY }) {
|
|
62
|
+
const now = nowSec()
|
|
63
|
+
const jti = 'jti-' + crypto.randomBytes(9).toString('base64url')
|
|
64
|
+
const payload = {
|
|
65
|
+
iss: 'hivecode', sub: name, name, kind, ...(owner ? { owner } : {}),
|
|
66
|
+
pk: rec.publicKey, // self-certifying: the relay binds this key to the room id
|
|
67
|
+
scopes: [{ room: rec.room, role, ...(paths && paths.length ? { paths } : {}), ...(writePaths && writePaths.length ? { writePaths } : {}) }],
|
|
68
|
+
iat: now, exp: now + ttlSec, jti,
|
|
69
|
+
}
|
|
70
|
+
return { token: sign(payload, { privateKey: rec.privateKey }), jti, exp: payload.exp }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The host's own token, minted afresh when it is missing or within an hour of running out: the host
|
|
74
|
+
* holds the key, so its own access must never lapse. Only for the name the room was hosted under --
|
|
75
|
+
* another participant on this machine, joining with its own invite, keeps its own narrower grant. */
|
|
76
|
+
export function hostToken(room, name, current, { kind = 'ai', owner = '' } = {}) {
|
|
77
|
+
const rec = loadRoomKey(room)
|
|
78
|
+
if (!rec || rec.host !== name) return current
|
|
79
|
+
const p = current ? decodeUnsafe(current) : null
|
|
80
|
+
const fresh = !!(p && p.sub === name && p.exp > nowSec() + 3600 && p.pk && keyFingerprint(p.pk) === roomFingerprint(room))
|
|
81
|
+
return fresh ? current : mintRoomToken(rec, { name, kind, role: 'maintainer', owner, ttlSec: 30 * DAY }).token
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// A path a token may name: relative, no "..", no drive or root. Globs are fine ("src/**", "!**/*.env").
|
|
85
|
+
function badPath(g) {
|
|
86
|
+
const s = String(g).replace(/^!/, '')
|
|
87
|
+
return !s || s.length > 200 || /^([A-Za-z]:|[\\/])/.test(s) || /(^|[\\/])\.\.([\\/]|$)/.test(s) || /[\x00-\x1f]/.test(s)
|
|
88
|
+
}
|
|
89
|
+
const list = (v) => (v == null || v === true ? [] : String(v).split(',').map((s) => s.trim()).filter(Boolean))
|
|
90
|
+
const when = (sec) => new Date(sec * 1000).toISOString().slice(0, 10)
|
|
91
|
+
|
|
92
|
+
function whatTheyGet(i) {
|
|
93
|
+
const see = i.paths && i.paths.length ? i.paths.join(', ') : 'every file in the room'
|
|
94
|
+
const edit = i.role === 'reader' ? 'read only' : i.writePaths && i.writePaths.length ? `may edit ${i.writePaths.join(', ')}` : 'may edit what they see'
|
|
95
|
+
return `${i.kind}, ${i.role}: sees ${see}; ${edit}`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Make one person's invite. Returns { ok, text, link?, jti? }. */
|
|
99
|
+
export function makeInvite({ room, relay, name, kind = 'ai', reader = false, paths = [], writePaths = [], days = 7, owner = '' }) {
|
|
100
|
+
const rec = loadRoomKey(room)
|
|
101
|
+
if (!rec) {
|
|
102
|
+
return { ok: false, text: isSecuredRoom(room)
|
|
103
|
+
? `Only the machine that hosted this personal room can invite people to it, and this one does not hold its key (${keysDir()}). Ask the host.`
|
|
104
|
+
: `Room ${room} is open: anyone with its link can join, so there is nothing to invite into. For a room only invited people can enter, host one with hive_join and private: true.` }
|
|
105
|
+
}
|
|
106
|
+
const nc = validateMemberName(name)
|
|
107
|
+
if (!nc.ok) return { ok: false, text: `${nc.error}\nNothing was minted.` }
|
|
108
|
+
if (name === rec.host) return { ok: false, text: `"${name}" is the host of this room. Pick the invitee's own name.\nNothing was minted.` }
|
|
109
|
+
const bad = [...paths, ...writePaths].filter(badPath)
|
|
110
|
+
if (bad.length) return { ok: false, text: `These paths cannot go in an invite (they must be relative, with no ".."): ${bad.join(', ')}\nNothing was minted.` }
|
|
111
|
+
const d = Number(days)
|
|
112
|
+
if (!Number.isFinite(d) || d <= 0 || d > 30) return { ok: false, text: `--days must be between 1 and 30 (got ${days}). A short invite is one you never have to remember to take back.\nNothing was minted.` }
|
|
113
|
+
const k = kind === 'human' ? 'human' : 'ai'
|
|
114
|
+
const role = reader ? 'reader' : 'writer'
|
|
115
|
+
const { token, jti, exp } = mintRoomToken(rec, { name, kind: k, role, paths, writePaths, owner, ttlSec: Math.round(d * DAY) })
|
|
116
|
+
const inv = { jti, name, kind: k, role, paths: paths.length ? paths : null, writePaths: writePaths.length ? writePaths : null, exp, at: Date.now() }
|
|
117
|
+
rec.invites = [...(rec.invites || []), inv]
|
|
118
|
+
writeRec(rec)
|
|
119
|
+
const link = `${relay || rec.relay}|${room}|${token}`
|
|
120
|
+
const text = `INVITE for ${name} (${whatTheyGet(inv)}), until ${when(exp)}.
|
|
121
|
+
Send it to ${name} directly, never in the room chat: whoever holds this link joins as ${name}.
|
|
122
|
+
|
|
123
|
+
Join my Hivecode room. In a terminal, run this once (needs Node.js 18+), then do what it prints:
|
|
124
|
+
npx -y hivecode-mcp@latest setup "${link}"
|
|
125
|
+
|
|
126
|
+
Take it back any time: npx -y hivecode-mcp@latest revoke ${name}`
|
|
127
|
+
return { ok: true, text, link, jti }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const httpBase = (relay) => String(relay).replace(/^ws(s?):\/\//i, 'http$1://').replace(/\/+$/, '')
|
|
131
|
+
|
|
132
|
+
/** Take back every live invite for a name. The relay refuses those tokens from then on and closes any
|
|
133
|
+
* connection using them. Returns { ok, text }. */
|
|
134
|
+
export async function revokeInvite({ room, relay, name }) {
|
|
135
|
+
const rec = loadRoomKey(room)
|
|
136
|
+
if (!rec) return { ok: false, text: `This machine does not host room ${room}, so it cannot take invites back. Only the host can.` }
|
|
137
|
+
const live = (rec.invites || []).filter((i) => i.name === name && !i.revokedAt)
|
|
138
|
+
if (!live.length) {
|
|
139
|
+
const names = [...new Set((rec.invites || []).filter((i) => !i.revokedAt).map((i) => i.name))]
|
|
140
|
+
return { ok: false, text: `No live invite for "${name}" in this room.${names.length ? ` Invited: ${names.join(', ')}.` : ' Nobody has been invited yet.'}` }
|
|
141
|
+
}
|
|
142
|
+
const owner = mintRoomToken(rec, { name: rec.host, role: 'maintainer', ttlSec: 600 }).token
|
|
143
|
+
let done = 0, dropped = 0
|
|
144
|
+
const failures = []
|
|
145
|
+
for (const i of live) {
|
|
146
|
+
try {
|
|
147
|
+
const r = await relayFetch(`${httpBase(relay || rec.relay)}/__hive/revoke`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ room, token: owner, jti: i.jti }) })
|
|
148
|
+
const body = await r.json().catch(() => ({}))
|
|
149
|
+
if (r.ok && body.ok) { i.revokedAt = Date.now(); done++; dropped += Number(body.dropped) || 0 }
|
|
150
|
+
else failures.push(`HTTP ${r.status}${body.error ? ` ${body.error}` : ''}`)
|
|
151
|
+
} catch (e) { failures.push(e.message) }
|
|
152
|
+
}
|
|
153
|
+
writeRec(rec)
|
|
154
|
+
if (!done) return { ok: false, text: `Could not reach the relay to revoke ${name}'s invite (${failures.join('; ')}). Nothing changed; try again.` }
|
|
155
|
+
return { ok: true, text: `Revoked ${done} invite(s) for ${name}. The relay refuses them from now on${dropped ? `, and closed ${dropped} connection(s) using them` : ''}.${failures.length ? `\n${failures.length} more could not be revoked (${failures.join('; ')}); run this again.` : ''}` }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ---- the command line: hivecode-mcp invite <name> ... | hivecode-mcp revoke <name> ----
|
|
159
|
+
|
|
160
|
+
function parseArgs(argv) {
|
|
161
|
+
const o = { _: [] }
|
|
162
|
+
for (let i = 0; i < argv.length; i++) {
|
|
163
|
+
const a = argv[i]
|
|
164
|
+
if (!a.startsWith('--')) { o._.push(a); continue }
|
|
165
|
+
const key = a.slice(2)
|
|
166
|
+
if (['reader', 'human'].includes(key)) o[key] = true
|
|
167
|
+
else o[key] = argv[++i]
|
|
168
|
+
}
|
|
169
|
+
return o
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Which room: --dir's .hive.json, else this folder's, else the only room this machine hosts.
|
|
173
|
+
function findRoom(o) {
|
|
174
|
+
const dir = path.resolve(o.dir || '.')
|
|
175
|
+
try {
|
|
176
|
+
const c = JSON.parse(fs.readFileSync(path.join(dir, '.hive.json'), 'utf8'))
|
|
177
|
+
if (c && c.room) return { room: c.room, relay: c.relay || '' }
|
|
178
|
+
} catch { /* not a room folder */ }
|
|
179
|
+
let mine = []
|
|
180
|
+
try { mine = fs.readdirSync(keysDir()).filter((f) => f.endsWith('.json')).map((f) => loadRoomKey(f.slice(0, -5))).filter(Boolean) } catch { /* no keys yet */ }
|
|
181
|
+
if (mine.length === 1) return { room: mine[0].room, relay: mine[0].relay }
|
|
182
|
+
return { error: mine.length
|
|
183
|
+
? `${dir} is not a room folder, and this machine hosts ${mine.length} personal rooms. Say which with --dir <the room's folder>:\n${mine.map((r) => ` ${r.room} (host ${r.host}, created ${new Date(r.created).toISOString().slice(0, 10)})`).join('\n')}`
|
|
184
|
+
: `${dir} is not a room folder, and this machine hosts no personal room. Host one first: hive_join with private: true.` }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const USAGE = `Usage:
|
|
188
|
+
hivecode-mcp invite <name> [--reader] [--human] [--paths a,b] [--edit a,b] [--days 7] [--owner <their human>] [--dir <room folder>]
|
|
189
|
+
hivecode-mcp revoke <name> [--dir <room folder>]
|
|
190
|
+
|
|
191
|
+
--reader they can read but not edit --paths only these folders/globs are visible to them
|
|
192
|
+
--human the invitee is a person, not AI --edit of what they see, only these may be edited
|
|
193
|
+
--days how long the invite lasts (default 7, at most 30)`
|
|
194
|
+
|
|
195
|
+
export async function main(argv) {
|
|
196
|
+
const cmd = argv[0]
|
|
197
|
+
const o = parseArgs(argv.slice(1))
|
|
198
|
+
const name = o._[0]
|
|
199
|
+
if (!name) { console.log(USAGE); return 2 }
|
|
200
|
+
const where = findRoom(o)
|
|
201
|
+
if (where.error) { console.log(where.error); return 1 }
|
|
202
|
+
if (cmd === 'revoke') {
|
|
203
|
+
const r = await revokeInvite({ room: where.room, relay: where.relay, name })
|
|
204
|
+
console.log(r.text)
|
|
205
|
+
return r.ok ? 0 : 1
|
|
206
|
+
}
|
|
207
|
+
const r = makeInvite({ room: where.room, relay: where.relay, name, kind: o.human ? 'human' : 'ai', reader: !!o.reader, paths: list(o.paths), writePaths: list(o.edit), days: o.days == null ? 7 : o.days, owner: o.owner || '' })
|
|
208
|
+
console.log(r.text)
|
|
209
|
+
return r.ok ? 0 : 1
|
|
210
|
+
}
|