hivecode-mcp 0.11.6 → 0.12.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.
- package/HIVE_RULES.md +127 -127
- package/README.md +250 -13
- package/core.js +14 -146
- package/docs/MCP.md +1 -1
- package/docs/SELF_HOSTING.md +253 -0
- package/hive-activity.js +310 -0
- package/hive-mcp.js +343 -68
- package/hive-pr-staleness.js +189 -0
- package/hive-privacy.js +263 -0
- package/hive-relay-health.js +205 -0
- package/hive-version.js +59 -0
- package/lang-js.js +11 -2
- package/package.json +15 -2
- package/reconcile.cjs +477 -0
- package/sync.js +515 -26
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// hive-pr-staleness.js — do not write a file over a branch change you cannot account for.
|
|
2
|
+
//
|
|
3
|
+
// THE INCIDENT, MEASURED 19 Sep 2026. hive-pr generated a PR that looked perfect: three commits,
|
|
4
|
+
// one per agent, each agent as the real git author, signed ids in `Hivecode-Author:` trailers,
|
|
5
|
+
// files with no verified receipt refused rather than committed. A reviewer would have had every
|
|
6
|
+
// reason to trust it.
|
|
7
|
+
//
|
|
8
|
+
// Diffed against the branch it was cut from:
|
|
9
|
+
//
|
|
10
|
+
// hive-linear.js -77
|
|
11
|
+
// tests/hive-linear-test.mjs -114
|
|
12
|
+
// tests/hive-mcp-http-test.mjs -49
|
|
13
|
+
// tests/hive-governor-test.mjs -32
|
|
14
|
+
// sync.js -39
|
|
15
|
+
//
|
|
16
|
+
// It deleted the HIV-27 claim guard, deleted the HIV-14 truncation fix, deleted ~200 lines of
|
|
17
|
+
// tests, and restored the 10x dashboard bug -- the one where thirty minutes of runtime displays
|
|
18
|
+
// as 3.0. Filed HIV-28.
|
|
19
|
+
//
|
|
20
|
+
// THE CAUSE IS ONE LINE. hive-pr.js did `fs.copyFileSync(f.abs, dest)` and then `git add`. It
|
|
21
|
+
// treats the room as truth and writes it over the branch. Grepping that file for `ahead`,
|
|
22
|
+
// `behind`, `stale`, `diverged`, `merge-base` or `rev-list` returned nothing: it never asked
|
|
23
|
+
// whether the branch had moved.
|
|
24
|
+
//
|
|
25
|
+
// This is not an exotic case, it is the NORMAL one. Any commit reaching the branch by another
|
|
26
|
+
// route puts it ahead -- a hotfix, a second developer, CI, or a human doing exactly the
|
|
27
|
+
// review-then-push workflow this tool exists to serve. In the measured incident the other route
|
|
28
|
+
// was me, working in the git checkout instead of my room folder.
|
|
29
|
+
//
|
|
30
|
+
// WHY "AHEAD/BEHIND" IS THE WRONG QUESTION. There is no merge-base between a Yjs room and a git
|
|
31
|
+
// branch; they are not two git histories. `git rev-list --count` cannot answer this, and reaching
|
|
32
|
+
// for it would produce a check that looks rigorous and measures nothing.
|
|
33
|
+
//
|
|
34
|
+
// THE QUESTION THAT IS ANSWERABLE, per file:
|
|
35
|
+
//
|
|
36
|
+
// has the room ever SEEN what is currently on the branch?
|
|
37
|
+
//
|
|
38
|
+
// It can be answered exactly, from state that already exists. Every landed change appends a
|
|
39
|
+
// receipt carrying `parent` (the contentHash the author built on) and `contentHash` (the result),
|
|
40
|
+
// so a file's ledger IS a hash chain of every state the room has passed through. `provenanceOf()`
|
|
41
|
+
// already returns it -- hive-pr reads it for attribution and then ignores it for safety.
|
|
42
|
+
//
|
|
43
|
+
// If the branch's current bytes hash to something in that chain, the room descends from the
|
|
44
|
+
// branch and writing is continuing the line. If they do not, the branch holds a change the room
|
|
45
|
+
// has never seen, and writing would delete it. That is the whole check.
|
|
46
|
+
|
|
47
|
+
import { contentHash } from './substrate.js'
|
|
48
|
+
|
|
49
|
+
/** Nothing to write: the branch already holds exactly this. */
|
|
50
|
+
export const IDENTICAL = 'IDENTICAL'
|
|
51
|
+
/** The file is not on the branch at all, so there is nothing to revert. */
|
|
52
|
+
export const NEW = 'NEW'
|
|
53
|
+
/** The branch's bytes are in the room's chain: the room descends from them. Safe. */
|
|
54
|
+
export const DESCENDS = 'DESCENDS'
|
|
55
|
+
/** The branch holds bytes the room has never seen. REFUSE. */
|
|
56
|
+
export const DIVERGED = 'DIVERGED'
|
|
57
|
+
/** The room has no chain for this file, so nothing can be proved either way. REFUSE. */
|
|
58
|
+
export const UNPROVEN = 'UNPROVEN'
|
|
59
|
+
|
|
60
|
+
/** The only two states that may be written. A list, so widening it is a visible edit. */
|
|
61
|
+
export const WRITABLE = [NEW, DESCENDS]
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* LINE ENDINGS ARE PRESENTATION, AND ON THIS REPO THEY DIFFER BY DEFAULT.
|
|
65
|
+
*
|
|
66
|
+
* Measured here: `hive-mcp.js` and `sync.js` are CRLF in the working tree and LF in the blob,
|
|
67
|
+
* because `core.autocrlf` is true. So the bytes git stored and the bytes the room holds are not
|
|
68
|
+
* equal for most files in this project, while the file is by any useful definition the same file.
|
|
69
|
+
*
|
|
70
|
+
* A byte comparison would therefore mark nearly everything DIVERGED and refuse every run. That is
|
|
71
|
+
* worse than no check: a guard that fires on the HEALTHY path is the one that gets a `--force`
|
|
72
|
+
* alias in somebody's shell history within a week, and then it is not a guard at all.
|
|
73
|
+
*
|
|
74
|
+
* So both the equality test and the chain lookup are done modulo line endings. Three variants, not
|
|
75
|
+
* a normalisation, because the chain's hashes were computed by substrate.js over whatever text the
|
|
76
|
+
* room actually held at the time -- there is no single canonical form to normalise TO.
|
|
77
|
+
*/
|
|
78
|
+
const variants = (t) => {
|
|
79
|
+
const lf = String(t).replace(/\r\n/g, '\n')
|
|
80
|
+
return [String(t), lf, lf.replace(/\n/g, '\r\n')]
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Same file, ignoring how the lines end. */
|
|
84
|
+
export const sameText = (a, b) =>
|
|
85
|
+
a != null && b != null && String(a).replace(/\r\n/g, '\n') === String(b).replace(/\r\n/g, '\n')
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Every content state this file has passed through in the room.
|
|
89
|
+
*
|
|
90
|
+
* Both fields go in. `parent` is the state an author BUILT ON -- which may be a state no receipt
|
|
91
|
+
* ever produced, because the first write's parent is the hash of the empty string and a rejoin
|
|
92
|
+
* can land on a state whose own receipt lives on another peer. Taking only `contentHash` would
|
|
93
|
+
* miss exactly those, and each miss reads as DIVERGED: a refusal on a healthy file, which is the
|
|
94
|
+
* failure mode that gets a safety check disabled.
|
|
95
|
+
*/
|
|
96
|
+
export function chainOf(receipts) {
|
|
97
|
+
const seen = new Set()
|
|
98
|
+
for (const r of receipts || []) {
|
|
99
|
+
if (!r) continue
|
|
100
|
+
if (r.parent) seen.add(r.parent)
|
|
101
|
+
if (r.contentHash) seen.add(r.contentHash)
|
|
102
|
+
}
|
|
103
|
+
return seen
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Decide whether the room's copy may be written over the branch's.
|
|
108
|
+
*
|
|
109
|
+
* `branchText` is null when the path does not exist on the branch. It must be the RAW blob --
|
|
110
|
+
* read with `git cat-file blob`, never `git show` through a working tree, or a checkout with
|
|
111
|
+
* `core.autocrlf` hands back bytes git never stored and every hash misses.
|
|
112
|
+
*/
|
|
113
|
+
export function accountFor({ rel, branchText, roomText, receipts }) {
|
|
114
|
+
if (branchText == null) return { rel, state: NEW, why: 'not on the branch yet — nothing can be reverted' }
|
|
115
|
+
// Modulo line endings on purpose. A file that differs ONLY in how its lines end must not be
|
|
116
|
+
// rewritten: it would produce a diff of every line in the file, for no change anybody made.
|
|
117
|
+
if (sameText(branchText, roomText)) return { rel, state: IDENTICAL, why: 'the branch already holds this (line endings aside)' }
|
|
118
|
+
|
|
119
|
+
const chain = chainOf(receipts)
|
|
120
|
+
if (!chain.size) {
|
|
121
|
+
return {
|
|
122
|
+
rel, state: UNPROVEN,
|
|
123
|
+
why: 'the room has no provenance chain for this file, so there is no way to tell whether its copy is newer than the branch or older',
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const hashes = variants(branchText).map(contentHash)
|
|
128
|
+
if (hashes.some((h) => chain.has(h))) {
|
|
129
|
+
return { rel, state: DESCENDS, why: 'the room has passed through the branch\'s current content, so its copy is a descendant' }
|
|
130
|
+
}
|
|
131
|
+
return {
|
|
132
|
+
rel, state: DIVERGED, branchHash: hashes[0],
|
|
133
|
+
why: 'the branch holds content this room has never seen — writing the room\'s copy would delete it',
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** May this decision be written? Never inline `state !== DIVERGED` — that admits UNPROVEN. */
|
|
138
|
+
export const writable = (d) => WRITABLE.includes(d.state)
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The run-level verdict.
|
|
142
|
+
*
|
|
143
|
+
* ALL OR NOTHING, and that is the design. Committing the safe files and skipping the rest produces
|
|
144
|
+
* a PR that looks complete and is not -- the same false success the tool already produced once,
|
|
145
|
+
* one level up. A refusal a human can read beats a branch they have to audit.
|
|
146
|
+
*/
|
|
147
|
+
export function verdict(decisions) {
|
|
148
|
+
const blocked = decisions.filter((d) => !writable(d) && d.state !== IDENTICAL)
|
|
149
|
+
return {
|
|
150
|
+
ok: blocked.length === 0,
|
|
151
|
+
blocked,
|
|
152
|
+
writable: decisions.filter(writable),
|
|
153
|
+
identical: decisions.filter((d) => d.state === IDENTICAL),
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* What the operator reads. `lastTouch` is supplied by the caller (hive-pr runs the git command)
|
|
159
|
+
* so this module stays pure and testable without a repository.
|
|
160
|
+
*
|
|
161
|
+
* It names the commit and the person, because "diverged" alone sends someone to `git log` to work
|
|
162
|
+
* out whether this is their own forgotten hotfix or somebody else's live work -- and that lookup
|
|
163
|
+
* is the moment a tired operator reaches for --force.
|
|
164
|
+
*/
|
|
165
|
+
export function explain(blocked, lastTouch = () => '') {
|
|
166
|
+
// Q506, the next free code in hive-pr's own Q500-Q505 block. Q443 would have read naturally
|
|
167
|
+
// here -- it is the other "you are being handed an incomplete picture" refusal -- and it is
|
|
168
|
+
// already the Linear truncation code. Two meanings behind one code is the duplicated-wire
|
|
169
|
+
// -contract defect wearing a three-digit hat.
|
|
170
|
+
const lines = ['Q506 REFUSING TO WRITE — the branch has moved under these files:', '']
|
|
171
|
+
for (const d of blocked) {
|
|
172
|
+
const touch = lastTouch(d.rel)
|
|
173
|
+
lines.push(` ${d.rel}`)
|
|
174
|
+
lines.push(` ${d.why}`)
|
|
175
|
+
if (touch) lines.push(` last changed on the branch by: ${touch}`)
|
|
176
|
+
}
|
|
177
|
+
lines.push('')
|
|
178
|
+
lines.push('Nothing was written and no branch was created. Writing the room\'s copy over these')
|
|
179
|
+
lines.push('would silently revert whatever reached the branch another way — a hotfix, a second')
|
|
180
|
+
lines.push('developer, CI, or your own work done outside the room. That has happened once on this')
|
|
181
|
+
lines.push('project and the resulting PR looked perfect.')
|
|
182
|
+
lines.push('')
|
|
183
|
+
lines.push('Fix it one of two ways:')
|
|
184
|
+
lines.push(' - bring the branch\'s version INTO the room (edit it there, or hive_edit it), so the')
|
|
185
|
+
lines.push(' room\'s copy descends from what is on the branch. Then run this again.')
|
|
186
|
+
lines.push(' - --force "<reason>" if you have decided the room\'s copy should win. The reason is')
|
|
187
|
+
lines.push(' recorded in the PR body, because somebody will ask.')
|
|
188
|
+
return lines.join('\n')
|
|
189
|
+
}
|
package/hive-privacy.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
// WHICH RELAY IS THIS, AND DID ANYBODY CHOOSE IT?
|
|
2
|
+
//
|
|
3
|
+
// Every byte of a team's source leaves their machines through one URL: the relay. The
|
|
4
|
+
// client has always printed that URL and stopped there -- a bare `relay: wss://...` line
|
|
5
|
+
// that says nothing about what it MEANS. That is the whole defect this file exists for,
|
|
6
|
+
// and it is the product's highest-stakes false success:
|
|
7
|
+
//
|
|
8
|
+
// An agent starts with no link and no .hive.json. resolveRoom() falls through to the
|
|
9
|
+
// shipped default -- a relay the team does not run. The agent joins. hive_status says
|
|
10
|
+
// connected. filesSynced climbs. Every signal is green, and the company's source code
|
|
11
|
+
// is now on a stranger's server. Nothing crashed, so nothing reports it.
|
|
12
|
+
//
|
|
13
|
+
// Two separate things are needed and they are NOT the same thing:
|
|
14
|
+
//
|
|
15
|
+
// 1. A VERDICT on every join, so "am I private?" is answered without being asked.
|
|
16
|
+
// Printing the URL is not a verdict. A person who has never run a relay cannot tell
|
|
17
|
+
// wss://livecode-xoss.onrender.com from wss://relay.theircompany.com by looking.
|
|
18
|
+
//
|
|
19
|
+
// 2. A PIN that REFUSES. A verdict you have to read is a verdict that gets skimmed, and
|
|
20
|
+
// an agent does not read banners at all. HIVE_RELAY_PIN says "this machine may only
|
|
21
|
+
// ever talk to these relays" -- set once in the MCP config, and no stale link, no
|
|
22
|
+
// typo'd host and no forgotten env var can send a file anywhere else. The refusal
|
|
23
|
+
// happens BEFORE the socket opens, so a refused join publishes nothing.
|
|
24
|
+
//
|
|
25
|
+
// Comparison is on ORIGIN, never on the raw string. wss://r.co, wss://r.co/ and
|
|
26
|
+
// wss://R.co:443 are one relay; a string compare would refuse a correct configuration,
|
|
27
|
+
// and a guard that fires on the healthy path is a guard people disable.
|
|
28
|
+
|
|
29
|
+
// The relay this build falls back to when nobody chose one -- THE ONE PLACE IT IS WRITTEN.
|
|
30
|
+
//
|
|
31
|
+
// It used to be written four times: here, hive-mcp.js, hive-agent.js and hive-say.js, each a
|
|
32
|
+
// literal. On 23 Sep 2026 the hosted relay's Render account was suspended and a new one stood
|
|
33
|
+
// up at a new address, and moving meant hunting down every copy -- any one missed would have
|
|
34
|
+
// sent that client to a dead host, or made this module call the live default "YOUR RELAY".
|
|
35
|
+
// Every client now imports it from here. The extension's JSON setting cannot import, so
|
|
36
|
+
// extension-privacy-parity-test pins it equal to this value instead.
|
|
37
|
+
export const PUBLIC_DEFAULT_RELAY = 'wss://hivecode-relay.onrender.com'
|
|
38
|
+
|
|
39
|
+
// Relays this project has run publicly and retired. A .hive.json written before a move still
|
|
40
|
+
// names the old one, and if that host ever answers again (a reactivated account, a squatter
|
|
41
|
+
// on the name) the verdict must still say PUBLIC -- not "YOUR RELAY", which is what any host
|
|
42
|
+
// outside this list is called.
|
|
43
|
+
export const RETIRED_PUBLIC_RELAYS = ['wss://livecode-xoss.onrender.com']
|
|
44
|
+
|
|
45
|
+
const DEFAULT_PORT = { 'ws:': '80', 'wss:': '443', 'http:': '80', 'https:': '443' }
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Normalise a relay URL to a comparable origin: scheme://host:port, lowercased, with the
|
|
49
|
+
* scheme's default port made explicit. Returns null for anything that is not a URL --
|
|
50
|
+
* callers must treat null as "unknown", never as "fine".
|
|
51
|
+
*/
|
|
52
|
+
export function relayOrigin(url) {
|
|
53
|
+
const s = String(url || '').trim()
|
|
54
|
+
if (!s) return null
|
|
55
|
+
let u
|
|
56
|
+
try { u = new URL(s) } catch { return null }
|
|
57
|
+
const proto = u.protocol.toLowerCase()
|
|
58
|
+
if (!DEFAULT_PORT[proto]) return null
|
|
59
|
+
const port = u.port || DEFAULT_PORT[proto]
|
|
60
|
+
// ws:// and wss:// to the same host:port are the SAME relay reached two ways, and a pin
|
|
61
|
+
// written as https:// (which is what a person copies out of a browser) must match the
|
|
62
|
+
// wss:// the client actually dials. Scheme is reported separately by classifyRelay so
|
|
63
|
+
// the plaintext warning still fires; it deliberately does not participate in identity.
|
|
64
|
+
const secureScheme = proto === 'wss:' || proto === 'https:'
|
|
65
|
+
return `${secureScheme ? 'wss' : 'ws'}://${u.hostname.toLowerCase()}:${port}`
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '::1', '0.0.0.0', '[::1]'])
|
|
69
|
+
|
|
70
|
+
function isLoopback(host) {
|
|
71
|
+
const h = String(host || '').toLowerCase().replace(/^\[|\]$/g, '')
|
|
72
|
+
if (LOOPBACK.has(h)) return true
|
|
73
|
+
return /^127\./.test(h)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isPrivateLan(host) {
|
|
77
|
+
const h = String(host || '').toLowerCase()
|
|
78
|
+
if (/^10\./.test(h)) return true
|
|
79
|
+
if (/^192\.168\./.test(h)) return true
|
|
80
|
+
if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true
|
|
81
|
+
if (/\.local$/.test(h) || /\.lan$/.test(h) || /\.internal$/.test(h)) return true
|
|
82
|
+
// A PRIVATE MESH (Tailscale and friends) IS A PRIVATE NETWORK, and the first version of this
|
|
83
|
+
// file called it "the public internet in plaintext". 100.64.0.0/10 is the shared address space
|
|
84
|
+
// Tailscale hands out and *.ts.net is its MagicDNS; the transport underneath is WireGuard,
|
|
85
|
+
// end-to-end encrypted between the two devices, and nothing outside the tailnet can even
|
|
86
|
+
// route to the relay. Warning a remote team that chose the most private setup available
|
|
87
|
+
// that their source was travelling in the clear would have taught them to ignore the verdict.
|
|
88
|
+
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(h)) return true
|
|
89
|
+
if (/\.ts\.net$/.test(h)) return true
|
|
90
|
+
return false
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// PUBLIC TUNNELS decrypt at their own edge. A relay on a laptop behind cloudflared or ngrok is
|
|
94
|
+
// the team's relay -- nobody else runs it -- but the tunnel provider terminates TLS and can read
|
|
95
|
+
// every frame, and anyone holding the URL can reach the port. Both facts matter to a team that
|
|
96
|
+
// picked a tunnel *for privacy*, and neither is visible in a URL that starts with wss://.
|
|
97
|
+
const PUBLIC_TUNNEL = /\.(trycloudflare\.com|ngrok-free\.app|ngrok\.app|ngrok\.io|ngrok-free\.dev|loca\.lt|serveo\.net|localhost\.run)$/
|
|
98
|
+
|
|
99
|
+
function isPublicTunnel(host) {
|
|
100
|
+
return PUBLIC_TUNNEL.test(String(host || '').toLowerCase())
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* What kind of relay is this, and does traffic to it leave the machine in the clear?
|
|
105
|
+
*
|
|
106
|
+
* kind: 'public-default' | 'loopback' | 'private-lan' | 'tunnel' | 'own' | 'unknown'
|
|
107
|
+
*
|
|
108
|
+
* 'own' does not mean audited -- it means "not the relay we ship". A team that pastes a
|
|
109
|
+
* link from a blog post lands in 'own' too, which is exactly why the pin exists.
|
|
110
|
+
*/
|
|
111
|
+
export function classifyRelay(url) {
|
|
112
|
+
const origin = relayOrigin(url)
|
|
113
|
+
if (!origin) return { kind: 'unknown', origin: null, host: null, encrypted: false, url: String(url || '') }
|
|
114
|
+
const u = new URL(String(url).trim())
|
|
115
|
+
const host = u.hostname.toLowerCase()
|
|
116
|
+
const encrypted = u.protocol === 'wss:' || u.protocol === 'https:'
|
|
117
|
+
let kind
|
|
118
|
+
if (origin === relayOrigin(PUBLIC_DEFAULT_RELAY) ||
|
|
119
|
+
RETIRED_PUBLIC_RELAYS.some((r) => relayOrigin(r) === origin)) kind = 'public-default'
|
|
120
|
+
else if (isLoopback(host)) kind = 'loopback'
|
|
121
|
+
else if (isPrivateLan(host)) kind = 'private-lan'
|
|
122
|
+
else if (isPublicTunnel(host)) kind = 'tunnel'
|
|
123
|
+
else kind = 'own'
|
|
124
|
+
return { kind, origin, host, encrypted, url: String(url).trim() }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The pin, from the environment or from <dir>/.hive.json's `pinnedRelay`. Comma-separated,
|
|
129
|
+
* because a team legitimately has a staging relay and a production one.
|
|
130
|
+
*
|
|
131
|
+
* A pin that cannot be parsed is an ERROR, never an empty pin. That distinction is the
|
|
132
|
+
* whole value of the feature: a typo silently collapsing to "no pin" leaves somebody
|
|
133
|
+
* believing they are locked to their own relay while the client dials the public one --
|
|
134
|
+
* the same absence-rendered-as-a-value shape the room has been bitten by before.
|
|
135
|
+
*/
|
|
136
|
+
export function readPin({ env = process.env, dir = null, fs = null } = {}) {
|
|
137
|
+
let raw = String((env && env.HIVE_RELAY_PIN) || '').trim()
|
|
138
|
+
let from = 'HIVE_RELAY_PIN'
|
|
139
|
+
if (!raw && dir && fs) {
|
|
140
|
+
try {
|
|
141
|
+
const cfg = JSON.parse(fs.readFileSync(`${dir}/.hive.json`, 'utf8'))
|
|
142
|
+
if (cfg && cfg.pinnedRelay) { raw = String(cfg.pinnedRelay).trim(); from = '.hive.json pinnedRelay' }
|
|
143
|
+
} catch { /* no config, or not JSON -- that is "no pin", not "bad pin" */ }
|
|
144
|
+
}
|
|
145
|
+
if (!raw) return { pinned: false, origins: [], from: null, raw: '' }
|
|
146
|
+
const parts = raw.split(',').map((s) => s.trim()).filter(Boolean)
|
|
147
|
+
const origins = []
|
|
148
|
+
const bad = []
|
|
149
|
+
for (const p of parts) {
|
|
150
|
+
const o = relayOrigin(p)
|
|
151
|
+
if (o) origins.push(o); else bad.push(p)
|
|
152
|
+
}
|
|
153
|
+
if (bad.length || !origins.length) {
|
|
154
|
+
return { pinned: true, origins, from, raw, error: `not a relay URL: ${bad.join(', ') || raw}` }
|
|
155
|
+
}
|
|
156
|
+
return { pinned: true, origins, from, raw }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const HEADLINE = {
|
|
160
|
+
'public-default': 'PUBLIC RELAY — not yours',
|
|
161
|
+
loopback: 'PRIVATE — relay on this machine',
|
|
162
|
+
'private-lan': 'PRIVATE — relay on your network',
|
|
163
|
+
tunnel: 'YOUR RELAY — through a public tunnel',
|
|
164
|
+
own: 'YOUR RELAY',
|
|
165
|
+
unknown: 'UNKNOWN RELAY',
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The one call a client makes before opening a socket.
|
|
170
|
+
*
|
|
171
|
+
* Returns { ok, code, headline, detail, classification, pin }. `ok:false` means DO NOT
|
|
172
|
+
* CONNECT -- the caller must not open the socket and must not publish anything, because
|
|
173
|
+
* publishing is the leak this prevents. A warning (ok:true with `warn`) is printed and
|
|
174
|
+
* proceeds: a team on a plaintext LAN relay has made a choice, and refusing it would be
|
|
175
|
+
* this guard deciding policy it was not given.
|
|
176
|
+
*
|
|
177
|
+
* Q550 the relay is not one this machine is pinned to
|
|
178
|
+
* Q551 HIVE_PRIVATE is set and this is the public relay we ship
|
|
179
|
+
* Q552 the pin itself is malformed, so nothing is actually pinned
|
|
180
|
+
*/
|
|
181
|
+
export function checkRelay({ relay, env = process.env, dir = null, fs = null } = {}) {
|
|
182
|
+
const c = classifyRelay(relay)
|
|
183
|
+
const pin = readPin({ env, dir, fs })
|
|
184
|
+
const strict = String((env && env.HIVE_PRIVATE) || '') === '1'
|
|
185
|
+
|
|
186
|
+
if (pin.pinned && pin.error) {
|
|
187
|
+
return {
|
|
188
|
+
ok: false, code: 'Q552', classification: c, pin,
|
|
189
|
+
headline: 'Q552 THE RELAY PIN IS MALFORMED — REFUSING TO CONNECT',
|
|
190
|
+
detail: [
|
|
191
|
+
`${pin.from} = ${pin.raw}`,
|
|
192
|
+
`problem: ${pin.error}`,
|
|
193
|
+
'',
|
|
194
|
+
'A pin that cannot be parsed is refused rather than ignored. Ignoring it would leave',
|
|
195
|
+
'you believing this machine is locked to your relay while it dials whatever it likes.',
|
|
196
|
+
'Write it as a full URL, e.g. HIVE_RELAY_PIN=wss://relay.yourcompany.com',
|
|
197
|
+
].join('\n'),
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (pin.pinned && c.origin && !pin.origins.includes(c.origin)) {
|
|
202
|
+
return {
|
|
203
|
+
ok: false, code: 'Q550', classification: c, pin,
|
|
204
|
+
headline: 'Q550 THIS RELAY IS NOT THE ONE THIS MACHINE IS PINNED TO — REFUSING TO CONNECT',
|
|
205
|
+
detail: [
|
|
206
|
+
`asked to connect to : ${c.url} (${c.origin})`,
|
|
207
|
+
`pinned to : ${pin.origins.join(', ')} (via ${pin.from})`,
|
|
208
|
+
'',
|
|
209
|
+
'Nothing was opened and nothing was published, so no file has left this machine.',
|
|
210
|
+
c.kind === 'public-default'
|
|
211
|
+
? 'The relay asked for is the PUBLIC one this build ships as a fallback — which is what\nyou get when a link is missing, a .hive.json was not found, or HIVE_RELAY is unset.'
|
|
212
|
+
: 'Check the join link you were given, and HIVE_RELAY in this process\'s environment.',
|
|
213
|
+
].join('\n'),
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!pin.pinned && strict && c.kind === 'public-default') {
|
|
218
|
+
return {
|
|
219
|
+
ok: false, code: 'Q551', classification: c, pin,
|
|
220
|
+
headline: 'Q551 HIVE_PRIVATE=1 AND THIS IS THE PUBLIC RELAY — REFUSING TO CONNECT',
|
|
221
|
+
detail: [
|
|
222
|
+
`relay: ${c.url}`,
|
|
223
|
+
'',
|
|
224
|
+
'This is the relay this build falls back to when nobody chose one. Under HIVE_PRIVATE',
|
|
225
|
+
'that fallback is an error rather than a default, because it is reached by omission —',
|
|
226
|
+
'a missing link or an unset HIVE_RELAY — and never by anybody deciding on it.',
|
|
227
|
+
'',
|
|
228
|
+
'Point HIVE_RELAY at your own relay, or unset HIVE_PRIVATE if you meant to use ours.',
|
|
229
|
+
].join('\n'),
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Connecting. Say what that means, in one line, every time.
|
|
234
|
+
let warn = null
|
|
235
|
+
if (c.kind === 'public-default') {
|
|
236
|
+
warn = 'Your files are being sent to a relay run by the Hivecode author, not by you. ' +
|
|
237
|
+
'Anyone who learns this room id can read them. For private work run your own relay ' +
|
|
238
|
+
'(npx hivecode-relay) and set HIVE_RELAY.'
|
|
239
|
+
} else if (c.kind === 'tunnel') {
|
|
240
|
+
warn = `${c.host} is a public tunnel. The relay is yours, but the tunnel provider decrypts ` +
|
|
241
|
+
'traffic at its edge and can read it, and anyone with this URL can reach the relay. Fine for ' +
|
|
242
|
+
'a trial with --secret set; for private remote work use a private network (e.g. Tailscale) ' +
|
|
243
|
+
'or a server you control.'
|
|
244
|
+
} else if (c.kind === 'own' && !c.encrypted) {
|
|
245
|
+
warn = `ws:// to ${c.host} is NOT encrypted — your source travels the public internet in ` +
|
|
246
|
+
'plaintext. Put TLS in front of the relay and use wss://.'
|
|
247
|
+
} else if (c.kind === 'unknown') {
|
|
248
|
+
warn = `"${c.url}" does not parse as a relay URL — the client will almost certainly fail ` +
|
|
249
|
+
'to connect, and if it does connect you cannot tell where to.'
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
ok: true, code: null, classification: c, pin, warn,
|
|
254
|
+
headline: `${HEADLINE[c.kind]}${c.encrypted ? '' : c.kind === 'loopback' ? '' : ' (UNENCRYPTED)'}`,
|
|
255
|
+
detail: warn || '',
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** One line for a join banner / status block: the URL plus what it means. */
|
|
260
|
+
export function privacyLine(relay, opts = {}) {
|
|
261
|
+
const v = checkRelay({ relay, ...opts })
|
|
262
|
+
return `relay: ${relay} [${v.headline}]`
|
|
263
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// WHY FILES ARE NOT SYNCING — asked of the relay, not guessed from the symptom.
|
|
2
|
+
//
|
|
3
|
+
// THE DEFECT THIS REPLACES.
|
|
4
|
+
// Two places told an operator why their files were not arriving: sync.js, when file docs went
|
|
5
|
+
// quiet, and hive-mcp.js, when hive_join failed to sync. Both carried the same three faults.
|
|
6
|
+
//
|
|
7
|
+
// 1. Both printed the literal string "<relay-http>/health" -- a placeholder, never
|
|
8
|
+
// substituted, while the relay URL sat in scope a few lines away.
|
|
9
|
+
// 2. Both named only Q430 and only limits.maxConnsTotal. limits.js:176 ALSO refuses with
|
|
10
|
+
// Q431 on a PER-ROOM cap, and the two need different fixes: HIVE_MAX_CONNS_TOTAL for the
|
|
11
|
+
// first, HIVE_SEATS for the second. An operator hitting Q431 and following that advice
|
|
12
|
+
// raises a limit that was never the constraint, sees no change, and is entitled to
|
|
13
|
+
// conclude the product is broken.
|
|
14
|
+
// 3. Both told a HUMAN to go read /health. The client can read it itself, in the same
|
|
15
|
+
// breath, and say which cause it actually is.
|
|
16
|
+
//
|
|
17
|
+
// MEASURED, 21 Sep 2026. A third agent joined room `night` and announced that 49 files could
|
|
18
|
+
// not connect, "the usual cause is the relay being full (Q430)". At that moment the relay
|
|
19
|
+
// reported 56 connections of 512. limits.js keys its per-room counter on the FILE-room, and
|
|
20
|
+
// each file held three connections against a cap of 64. Q430 needed 512 and had 56. Q431 needed
|
|
21
|
+
// 64 and had 3. NEITHER REFUSAL COULD HAVE FIRED.
|
|
22
|
+
//
|
|
23
|
+
// What had actually happened: /health went rooms 44 -> 0 -> 29 -> 31 -> 44 over a few minutes.
|
|
24
|
+
// The relay dropped its room state and every client began reconnecting, one socket per file.
|
|
25
|
+
//
|
|
26
|
+
// "The relay is full" and "the relay just dropped everyone and we are all reconnecting" produce
|
|
27
|
+
// an IDENTICAL local symptom -- N file docs with synced=false -- and need opposite responses: a
|
|
28
|
+
// config change, or thirty seconds of patience. The difference is a number only the relay has.
|
|
29
|
+
// So ask it.
|
|
30
|
+
//
|
|
31
|
+
// ONE PRODUCER, ON PURPOSE. learnings/repair-the-class-not-the-copy.md exists in this repo
|
|
32
|
+
// because a fix landed on one instance and the class survived into two more tickets. Two
|
|
33
|
+
// hand-written copies of a diagnosis is that state exactly: they had already drifted (the
|
|
34
|
+
// hive-mcp.js copy listed three candidate causes, the sync.js copy asserted one). This module is
|
|
35
|
+
// the only place the wording lives, and tests/relay-diagnosis-test.mjs COUNTS the producers so a
|
|
36
|
+
// third copy fails a test rather than shipping.
|
|
37
|
+
|
|
38
|
+
/** A free-tier per-room cap. At or below this, Q431 is the likely refusal and seats are the fix. */
|
|
39
|
+
export const FREE_TIER_ROOM_CAP = 8
|
|
40
|
+
|
|
41
|
+
/** Fraction of maxConnsTotal at which "the relay is effectively full" is the right reading. */
|
|
42
|
+
export const NEAR_CAPACITY = 0.9
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Turn a websocket relay URL into its /health URL.
|
|
46
|
+
* Exported because a caller that cannot fetch still wants to NAME the URL correctly, which is
|
|
47
|
+
* the whole of defect (1) above.
|
|
48
|
+
*/
|
|
49
|
+
export function healthUrlFor(relay) {
|
|
50
|
+
const base = String(relay || '')
|
|
51
|
+
.replace(/^wss:/i, 'https:')
|
|
52
|
+
.replace(/^ws:/i, 'http:')
|
|
53
|
+
.replace(/\/+$/, '')
|
|
54
|
+
return /^https?:/i.test(base) ? `${base}/health` : null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Ask the relay for its own numbers.
|
|
59
|
+
*
|
|
60
|
+
* NEVER THROWS, AND NEVER BLOCKS FOR LONG. This runs inside a diagnostic, and a diagnostic that
|
|
61
|
+
* can hang is a diagnostic that withholds the warning it exists to deliver. The warning matters
|
|
62
|
+
* more than the detail: a failed probe still produces a message, with the failure named.
|
|
63
|
+
*
|
|
64
|
+
* `ok` means "the probe worked", NOT "the relay is healthy". Conflating those is how a monitor
|
|
65
|
+
* reports the absence of an answer as a clean bill of health.
|
|
66
|
+
*/
|
|
67
|
+
export async function probeRelayHealth(relay, { timeoutMs = 2500, fetchImpl } = {}) {
|
|
68
|
+
const url = healthUrlFor(relay)
|
|
69
|
+
if (!url) return { ok: false, url: null, error: 'no usable relay URL' }
|
|
70
|
+
const f = fetchImpl || (typeof fetch === 'function' ? fetch : null)
|
|
71
|
+
if (!f) return { ok: false, url, error: 'no fetch available in this runtime' }
|
|
72
|
+
try {
|
|
73
|
+
const ac = new AbortController()
|
|
74
|
+
const t = setTimeout(() => ac.abort(), timeoutMs)
|
|
75
|
+
if (t && t.unref) t.unref()
|
|
76
|
+
let res
|
|
77
|
+
try { res = await f(url, { signal: ac.signal }) } finally { clearTimeout(t) }
|
|
78
|
+
if (!res || !res.ok) return { ok: false, url, error: `HTTP ${res ? res.status : 'no response'}` }
|
|
79
|
+
const j = await res.json()
|
|
80
|
+
return { ok: true, url, health: j }
|
|
81
|
+
} catch (e) {
|
|
82
|
+
const name = e && e.name
|
|
83
|
+
const why = name === 'AbortError' ? `no response in ${timeoutMs}ms` : ((e && e.message) || 'unreachable')
|
|
84
|
+
return { ok: false, url, error: why }
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The four readings this can produce. Named so a test can assert on the verdict, not the prose. */
|
|
89
|
+
export const RELAY_FULL = 'RELAY_FULL'
|
|
90
|
+
export const ROOM_CAP = 'ROOM_CAP'
|
|
91
|
+
export const NOT_CAPACITY = 'NOT_CAPACITY'
|
|
92
|
+
export const UNREACHABLE = 'UNREACHABLE'
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Decide what the numbers mean, and say it in a way an operator can act on.
|
|
96
|
+
*
|
|
97
|
+
* Returns { verdict, text }. The verdict is for tests and for callers that want to branch; the
|
|
98
|
+
* text is what a human reads. Keeping them separate is what lets the test pin the DECISION
|
|
99
|
+
* rather than the wording, so rephrasing the advice does not break the suite and changing the
|
|
100
|
+
* advice does.
|
|
101
|
+
*
|
|
102
|
+
* @param {object} probe the result of probeRelayHealth()
|
|
103
|
+
* @param {object} opts
|
|
104
|
+
* @param {number} opts.stuck how many files are not synced
|
|
105
|
+
* @param {string} opts.what 'files' (sync.js) or 'join' (hive-mcp.js) -- changes the lead only
|
|
106
|
+
*/
|
|
107
|
+
export function diagnose(probe, { stuck = 0, what = 'files' } = {}) {
|
|
108
|
+
const h = (probe && probe.ok && probe.health) || null
|
|
109
|
+
const url = (probe && probe.url) || 'the relay'
|
|
110
|
+
|
|
111
|
+
const lead = what === 'join'
|
|
112
|
+
? 'The relay did not sync this folder.'
|
|
113
|
+
: `${stuck} file(s) are not synced and will READ AS EMPTY until this clears.`
|
|
114
|
+
|
|
115
|
+
if (!h) {
|
|
116
|
+
// A probe that failed is MORE specific than any guess, not less: the relay is unreachable
|
|
117
|
+
// over HTTP from this machine, so capacity is not the question and no limit needs changing.
|
|
118
|
+
return {
|
|
119
|
+
verdict: UNREACHABLE,
|
|
120
|
+
text: [
|
|
121
|
+
lead,
|
|
122
|
+
`I could not reach ${url}${probe && probe.error ? ` (${probe.error})` : ''}, so the relay may be down,`,
|
|
123
|
+
`restarting, or blocked from this machine. THAT IS NOT A CAPACITY PROBLEM and no limit needs raising.`,
|
|
124
|
+
`Check that the relay is running and that this host can reach it.`,
|
|
125
|
+
].join(' '),
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const total = Number(h.limits && h.limits.maxConnsTotal) || 0
|
|
130
|
+
const conns = Number(h.connections) || 0
|
|
131
|
+
const perRoom = Number(h.connsPerRoom) || Number(h.limits && h.limits.maxConnsPerRoom) || 0
|
|
132
|
+
const seats = h.seats ? ` (${h.seats} seats)` : ''
|
|
133
|
+
const facts = `I asked ${url}: ${conns} of ${total} connections in use, ${h.rooms || 0} rooms, per-room cap ${perRoom}${seats}.`
|
|
134
|
+
|
|
135
|
+
if (total && conns >= total * NEAR_CAPACITY) {
|
|
136
|
+
return {
|
|
137
|
+
verdict: RELAY_FULL,
|
|
138
|
+
text: [lead, facts,
|
|
139
|
+
`THE RELAY IS AT OR NEAR CAPACITY — this is Q430. Hivecode opens one socket PER FILE, so a`,
|
|
140
|
+
`single large folder can exhaust it alone. Raise HIVE_MAX_CONNS_TOTAL on the relay, or narrow`,
|
|
141
|
+
`what syncs.`].join(' '),
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (perRoom && perRoom <= FREE_TIER_ROOM_CAP) {
|
|
146
|
+
// The case the old message could not express at all, and the one where its advice was
|
|
147
|
+
// actively harmful: total headroom is fine, so raising the total changes nothing.
|
|
148
|
+
return {
|
|
149
|
+
verdict: ROOM_CAP,
|
|
150
|
+
text: [lead, facts,
|
|
151
|
+
`The relay has total headroom, but its PER-ROOM cap is ${perRoom} and it refuses past that with Q431.`,
|
|
152
|
+
`That is a free-tier default. Set HIVE_SEATS on the relay — raising HIVE_MAX_CONNS_TOTAL will NOT help,`,
|
|
153
|
+
`because the total is not the constraint.`].join(' '),
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
verdict: NOT_CAPACITY,
|
|
159
|
+
text: [lead, facts,
|
|
160
|
+
`CAPACITY IS NOT THE CAUSE: ${Math.max(0, total - conns)} of ${total} connections are free and the per-room`,
|
|
161
|
+
`cap is ${perRoom}. These sockets are most likely still reconnecting — the relay drops room state on`,
|
|
162
|
+
`restart and every file reconnects separately, which looks exactly like this for a minute or two.`,
|
|
163
|
+
`Wait and re-check before changing any limit. If it persists, the room may be secured and this client`,
|
|
164
|
+
`may carry no token, or the relay may be failing its licence check closed.`].join(' '),
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The whole diagnosis in one call, for the two callers that want it.
|
|
170
|
+
* `trailer` is appended verbatim -- sync.js needs "do not trust this folder's contents", a
|
|
171
|
+
* failed join needs its own instructions, and neither belongs in the capacity reasoning.
|
|
172
|
+
*/
|
|
173
|
+
export async function explainNotSyncing(relay, { stuck = 0, what = 'files', trailer = '', timeoutMs = 2500, fetchImpl } = {}) {
|
|
174
|
+
const probe = await probeRelayHealth(relay, { timeoutMs, fetchImpl })
|
|
175
|
+
const d = diagnose(probe, { stuck, what })
|
|
176
|
+
return { ...d, probe, text: trailer ? `${d.text} ${trailer}` : d.text }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Extract HTTP status code from a WebSocket or upgrade error, if present.
|
|
181
|
+
*/
|
|
182
|
+
export function statusFromError(e) {
|
|
183
|
+
if (!e) return null
|
|
184
|
+
if (typeof e === 'number') return e
|
|
185
|
+
if (e.status && typeof e.status === 'number') return e.status
|
|
186
|
+
if (e.code && typeof e.code === 'number') return e.code
|
|
187
|
+
if (e.statusCode && typeof e.statusCode === 'number') return e.statusCode
|
|
188
|
+
const m = /Unexpected server response:\s*(\d{3})/.exec((e.message || e.reason || String(e)))
|
|
189
|
+
return m ? Number(m[1]) : null
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Authoritative explanations for HTTP status codes returned by the relay on connection upgrade.
|
|
194
|
+
*/
|
|
195
|
+
export function explainStatus(code, relay = '') {
|
|
196
|
+
const c = Number(code)
|
|
197
|
+
if (c === 401) return 'the relay rejected your token. It may have expired -- ask the room owner for a new invite.'
|
|
198
|
+
if (c === 402) return 'this room is over its plan limit. Contact support@getgrm.tech and we will sort it out.'
|
|
199
|
+
if (c === 403) return 'the relay refused this room or path for your token. You are outside the folders you were given.'
|
|
200
|
+
if (c === 429) return 'this room is FULL, so you were not let in (Q431). A relay admits a limited number of connections per room -- 8 on the free tier -- and this room is at that ceiling. Note it counts CONNECTIONS, not people: one agent holding several files holds several. If you run the relay, raise it with HIVE_SEATS; otherwise ask whoever does, or contact support@getgrm.tech.'
|
|
201
|
+
if (c === 503) return 'the relay is not serving this connection. Either the whole relay is at capacity (Q430) or it could not check your licence and is failing closed rather than guessing. It may also be restarting -- if this lasts, contact support@getgrm.tech.'
|
|
202
|
+
if (c) return `the relay answered ${c} instead of accepting the connection. Contact support@getgrm.tech.`
|
|
203
|
+
return `the relay at ${relay || 'the relay'} cannot be reached. It may be down, asleep, or blocked by a network. Contact support@getgrm.tech if this continues.`
|
|
204
|
+
}
|
|
205
|
+
|