@mcsherrylabs/pi-meerkat 0.8.0 → 0.10.1
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/README.md +34 -10
- package/dist/installer/genesis.devnet.conf +92 -0
- package/dist/installer/node-join.sh +8 -1
- package/dist/installer/node.conf +3 -0
- package/dist/installer/node.devnet.conf +238 -0
- package/dist/installer/runtime.json +1 -1
- package/dist/installer-runtime/dist/src/app.js +15 -2
- package/dist/installer-runtime/dist/src/env.js +12 -0
- package/dist/installer-runtime/dist/src/registry/index.js +11 -2
- package/dist/installer-runtime/dist/src/registry/provision.js +46 -1
- package/dist/installer-runtime/dist/src/runtime.js +37 -9
- package/dist/installer.d.ts +189 -12
- package/dist/installer.d.ts.map +1 -1
- package/dist/installer.js +159 -49
- package/dist/installer.js.map +1 -1
- package/dist/mcp-announce.d.ts +18 -0
- package/dist/mcp-announce.d.ts.map +1 -1
- package/dist/mcp-announce.js +18 -4
- package/dist/mcp-announce.js.map +1 -1
- package/dist/mcp-cli.d.ts.map +1 -1
- package/dist/mcp-cli.js +28 -18
- package/dist/mcp-cli.js.map +1 -1
- package/dist/mcp-config.d.ts +1 -0
- package/dist/mcp-config.d.ts.map +1 -1
- package/dist/mcp-config.js +2 -1
- package/dist/mcp-config.js.map +1 -1
- package/dist/mcp-upstream.d.ts +1 -1
- package/dist/mcp-upstream.d.ts.map +1 -1
- package/dist/mcp-upstream.js +7 -2
- package/dist/mcp-upstream.js.map +1 -1
- package/dist/tools.js +4 -4
- package/dist/tools.js.map +1 -1
- package/installer/Node.Dockerfile +4 -0
- package/installer/runtime-control.mjs +179 -16
- package/package.json +5 -5
- package/skills/channel-subscription/SKILL.md +3 -3
package/README.md
CHANGED
|
@@ -5,7 +5,9 @@ Meerkat tools plus every tool dynamically discovered from the private
|
|
|
5
5
|
`@mcsherrylabs/evolver-mcp@0.15.0` child. The released upstream baseline is 65 tools, so healthy
|
|
6
6
|
discovery currently returns 72. The count is a comparison baseline, not a routing allowlist.
|
|
7
7
|
|
|
8
|
-
**Version 0.
|
|
8
|
+
**Version 0.9.0 makes DevNet the default install target** (`install` installs against the public
|
|
9
|
+
DevNet; `install --local` creates a private local chain). Version 0.8.0 added the
|
|
10
|
+
zero-required-parameter installer (local chain only). Version 0.7.0 introduced the
|
|
9
11
|
facade but required an existing node, registry, identity and key. Version 0.6.0 is the old
|
|
10
12
|
Pi/local-signing package. Always use an exact published version, not npm `latest`.
|
|
11
13
|
|
|
@@ -23,15 +25,34 @@ Prerequisites in the environment where your MCP harness runs:
|
|
|
23
25
|
### 1. Install
|
|
24
26
|
|
|
25
27
|
```bash
|
|
26
|
-
npx --yes @mcsherrylabs/pi-meerkat@0.
|
|
28
|
+
npx --yes @mcsherrylabs/pi-meerkat@0.9.0 install # public DevNet (default)
|
|
29
|
+
npx --yes @mcsherrylabs/pi-meerkat@0.9.0 install --local # private local chain
|
|
27
30
|
```
|
|
28
31
|
|
|
29
32
|
No identity, key, endpoint, registry, chain ID, channel, port, GID, path, environment variables
|
|
30
|
-
or paid model service is required.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
or paid model service is required.
|
|
34
|
+
|
|
35
|
+
**Default (DevNet)** installs a single non-genesis follower node against the public DevNet, syncing
|
|
36
|
+
its shared chain, and a Meerkat registry that reads and indexes the shared, pinned
|
|
37
|
+
`ce.meerkat.announcements` channel (owned by the pinned `evolvermeerkatA483344` operator). That
|
|
38
|
+
registry runs **read-only**: it never claims, signs or submits with any identity. DevNet catch-up
|
|
39
|
+
syncs from block 0 (~14k blocks at the time of release) and is paced by the registry's own cursor
|
|
40
|
+
drain, so the first install can take a while; it reports progress and fails only if sync stalls.
|
|
41
|
+
|
|
42
|
+
A DevNet install **permanently claims two DevNet identities**, both funded by the public DevNet
|
|
43
|
+
faucet: its node (`meerkatnode<id>`) and your agent (`meerkatuser<id>`), which signs your
|
|
44
|
+
announcements. Names on DevNet cannot be reused. Re-running install never claims again. DevNet mode
|
|
45
|
+
needs **outbound internet access** from Docker (the node dials DevNet's front nodes, and the faucet
|
|
46
|
+
is reached over HTTPS); it still publishes no host ports.
|
|
47
|
+
|
|
48
|
+
**`--local`** creates an **independent private local chain** — an owned consensus node, a separate
|
|
49
|
+
processing node, and a Meerkat registry/content/inbox service. The agent's own identity
|
|
50
|
+
(`meerkatuser<id>`) owns the install's private `meerkat.<id>.announcements` channel (claimed once,
|
|
51
|
+
idempotently), and nothing touches DevNet. In either mode the registry claims no identity, and the
|
|
52
|
+
installer never inspects unrelated keys or changes another deployment. The first install downloads public pinned artifacts and
|
|
53
|
+
builds the wrapper images, so allow time for Docker/npm downloads. Failed
|
|
54
|
+
preflight/build/readiness exits nonzero with a reason. Each mode keeps its own state directory
|
|
55
|
+
(`devnet/` or `local/` under your XDG state directory); 0.8.0 state is never touched.
|
|
35
56
|
|
|
36
57
|
### 2. Register one server
|
|
37
58
|
|
|
@@ -40,17 +61,20 @@ if you do not already have a registration named `meerkat`, the equivalent comman
|
|
|
40
61
|
|
|
41
62
|
```bash
|
|
42
63
|
claude mcp add --scope user --transport stdio meerkat -- \
|
|
43
|
-
npx --yes @mcsherrylabs/pi-meerkat@0.
|
|
64
|
+
npx --yes @mcsherrylabs/pi-meerkat@0.9.0 serve
|
|
44
65
|
```
|
|
45
66
|
|
|
67
|
+
Add `--local` after `serve` if you installed with `install --local`.
|
|
68
|
+
|
|
46
69
|
Do not remove or replace an unrelated `meerkat` registration. Choose a different harness
|
|
47
70
|
registration name for this entry if necessary; its underlying saved deployment remains the same.
|
|
48
71
|
Other MCP clients can add the printed stdio JSON entry directly. Do **not** register an Evolver
|
|
49
72
|
child separately. Restart the harness connection, inspect its tools, and begin with
|
|
50
73
|
`meerkat_summary`, `meerkat_services`, `meerkat_inbox` and `evolver_chain_height`.
|
|
51
74
|
|
|
52
|
-
`serve` finds the installer's saved state automatically
|
|
53
|
-
ceremony. The public package launcher is
|
|
75
|
+
`serve` finds the installer's saved state automatically (default `devnet`, `--local` for the
|
|
76
|
+
private chain); there is no `--config` or environment ceremony. The public package launcher is
|
|
77
|
+
pinned and survives npm-cache cleanup. Each client
|
|
54
78
|
gets its **own facade and exact Evolver child/session/store** inside the owned runtime container,
|
|
55
79
|
connected through Docker exec stdio. Node/content/registry HTTP stays on the private owned
|
|
56
80
|
Docker network, with **no host-published ports**, so a separate Docker daemon needs neither
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# devnet genesis — the committee, plus SEED NODES (170).
|
|
2
|
+
#
|
|
3
|
+
# ⚠️ THIS FILE IS THE CHAIN'S IDENTITY. Its canonical hash is the genesis block id, so any edit
|
|
4
|
+
# here produces a DIFFERENT CHAIN. Every node must hold a byte-identical copy; a node with an
|
|
5
|
+
# older one does not disagree politely, it cannot join at all.
|
|
6
|
+
#
|
|
7
|
+
# ── Why the seed entries exist (170, issue #121) ────────────────────────────────────────────
|
|
8
|
+
# A node starting with an empty volume can only learn a peer's ADDRESS from this file, and can
|
|
9
|
+
# only VERIFY a peer whose key is already in its chain state — which, before it has synced, is
|
|
10
|
+
# also only this file. Both roads lead here. Before 170 this file named one node, the committee,
|
|
11
|
+
# whose `allowed-inbound` is deliberately full, so a newcomer could reach nothing it was able to
|
|
12
|
+
# trust and nothing it was able to trust would accept it. No configuration could fix that.
|
|
13
|
+
#
|
|
14
|
+
# A `quorum-weight = 0` entry is present-and-trusted WITHOUT being on the committee: it is seeded
|
|
15
|
+
# as an identity, its Ed25519 key becomes verifiable, and its address becomes dialable — while
|
|
16
|
+
# `seedGenesis` keeps it out of the quorum store and leader rotation filters it out of the
|
|
17
|
+
# proposer set. That is the whole mechanism; no new key, no new trust path.
|
|
18
|
+
#
|
|
19
|
+
# ⚠️ The two front nodes keep the names they already had. They were NOT in genesis before, so a
|
|
20
|
+
# chain rebuild would have reassigned their names and invalidated every pin across the estate and
|
|
21
|
+
# the published installer. Pinning them here makes the names stable by construction.
|
|
22
|
+
ce.evolver.genesis.nodes = [
|
|
23
|
+
# ── the committee ──────────────────────────────────────────────────────────────────────────
|
|
24
|
+
{
|
|
25
|
+
node-id = "devnet1-node0"
|
|
26
|
+
host = "devnet.mcsherrylabs.com"
|
|
27
|
+
port = 7000
|
|
28
|
+
public-key = "MCowBQYDK2VwAyEAxTC3mipBz3Ae6nkUzORWLAco21eX3iSSC+QEUSbJMCs="
|
|
29
|
+
bls-public-key = "rCrpHeLH85ECkow6A58I54fjvtCmOHfdm1llPrish+aD49l32sBciyYAzytRxwak"
|
|
30
|
+
quorum-weight = 1000
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
# ── seed nodes: weight 0, so present and trusted but never voting, never leading ───────────
|
|
34
|
+
#
|
|
35
|
+
# ⚠️ Keys are the ones these two nodes ALREADY hold, read from their own /status. Their host
|
|
36
|
+
# secrets are untouched and stay where they are — only the public halves appear here.
|
|
37
|
+
{
|
|
38
|
+
node-id = "evolvermeerkatnode920080"
|
|
39
|
+
host = "65.21.255.224"
|
|
40
|
+
port = 7100
|
|
41
|
+
public-key = "MCowBQYDK2VwAyEAvDNSSDsSYRHfvFCOcl/Fba89VnBrkLzYBy28xqjg8Zg="
|
|
42
|
+
bls-public-key = "iJZMbE+J+TnNnHpYxk2lQDJipTvvUg0qCQNVJViTfoatqZkqazLCZbUmSP4W26Vj"
|
|
43
|
+
quorum-weight = 0
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
node-id = "schednode2"
|
|
47
|
+
host = "65.21.255.224"
|
|
48
|
+
port = 7200
|
|
49
|
+
public-key = "MCowBQYDK2VwAyEAvUOp3fA1nTjv546jme0n14wYDVjHshLL3I7XpQxQ8V4="
|
|
50
|
+
bls-public-key = "ht3U8qRoQtKIU0NUbuuwx/ni4iF+rcAs2dJgxDOcf+YOs+brlSKSnC2XBFBp1rR1"
|
|
51
|
+
quorum-weight = 0
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
# ⚠️ devnet-seed-3 IS NEVER DEPLOYED, ON PURPOSE, AND MUST STAY THAT WAY. A list of seeds is
|
|
55
|
+
# only worth having if a dead entry is walked past rather than waited on, and a seed that is
|
|
56
|
+
# permanently absent tests that on every single join instead of on the one afternoon somebody
|
|
57
|
+
# remembers to stop a live one. Its private key material was generated in a throwaway container
|
|
58
|
+
# and destroyed with it — nobody can bring this node up, which is the point. `.invalid` is
|
|
59
|
+
# reserved by RFC 2606 and can never resolve, so the attempt fails fast instead of hanging.
|
|
60
|
+
# If you are reading this because something reports it unreachable: that is the test passing.
|
|
61
|
+
{
|
|
62
|
+
node-id = "devnet-seed-3"
|
|
63
|
+
host = "devnet-seed-3.invalid"
|
|
64
|
+
port = 7300
|
|
65
|
+
public-key = "MCowBQYDK2VwAyEAHY2onS4+fZss0VZaT84AMcQQugILPagvBm3PFDE5cYY="
|
|
66
|
+
bls-public-key = "oO91EyEt4jvpP3XuuNiT+6F/9djCjAqgs/+e7eI64eGz9iRCcHT5HY17coLLKfsk"
|
|
67
|
+
quorum-weight = 0
|
|
68
|
+
}
|
|
69
|
+
]
|
|
70
|
+
|
|
71
|
+
ce.evolver.consensus {
|
|
72
|
+
# ⚠️ ONE voting node. This is the committee size, not the number of genesis entries — the three
|
|
73
|
+
# seed entries above carry weight 0 and are not counted (NodeBootstrap derives the consensus
|
|
74
|
+
# cluster size with the same `quorumWeight > 0` filter the quorum store and leader rotation
|
|
75
|
+
# already use; before 170 it counted every entry, which would have put the relay threshold
|
|
76
|
+
# beyond anything a one-member committee could reach).
|
|
77
|
+
n-nodes = 1
|
|
78
|
+
block-time-ms = 600
|
|
79
|
+
round-timeout-ms = 1500
|
|
80
|
+
block-reward-tokens = 10000 # generous dev reward: the faucet (5000/identity) is funded from the FIRST blocks, even under quiescence
|
|
81
|
+
snapshot-interval-blocks = 0 # single-node: no snapshot cadence; joiners sync from block 0
|
|
82
|
+
identity-sponsorship-cost = 100
|
|
83
|
+
|
|
84
|
+
# Low idle disk-write: after 3 empty blocks the leader lingers on a 5-MINUTE heartbeat
|
|
85
|
+
# instead of the 600ms cadence (#31: idle-heartbeat-ms > block-time-ms activates quiescence);
|
|
86
|
+
# wake-on-work (node-local, always on) still proposes immediately on incoming txs.
|
|
87
|
+
# CHAIN-WIDE — identical on every node by construction.
|
|
88
|
+
quiescence {
|
|
89
|
+
idle-heartbeat-ms = 300000
|
|
90
|
+
idle-after-empty-rounds = 3
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -224,5 +224,12 @@ echo "[node-join] pinning ${_peers} as the seed; allowing ${_outbound} outbound
|
|
|
224
224
|
} > /data/chain/peers.conf
|
|
225
225
|
|
|
226
226
|
echo "[node-join] launching follower '$NODE_ID' — syncing devnet from block 0"
|
|
227
|
-
|
|
227
|
+
# Only a stale SOCKET left by an unclean previous exit is removed here (this entrypoint
|
|
228
|
+
# runs only while this node JVM is stopped, and this directory is exclusive to this node).
|
|
229
|
+
# Any other entry type is left for the node itself to refuse.
|
|
230
|
+
if [ -S /node-control/node.sock ]; then
|
|
231
|
+
echo "[node-join] removing stale node-control socket left by an unclean previous exit"
|
|
232
|
+
rm -f /node-control/node.sock
|
|
233
|
+
fi
|
|
234
|
+
exec /opt/evolver/bin/evolver -Dconfig.file="${EVOLVER_NODE_CONFIG:-/config/node.conf}" "$NODE_ID"
|
|
228
235
|
|
package/dist/installer/node.conf
CHANGED
|
@@ -123,6 +123,9 @@ ce.evolver.consensus {
|
|
|
123
123
|
# Processing is enabled only by the paired managed composition with its persistent zero-module
|
|
124
124
|
# shared SDK consumer. MCP sessions never drain. Independent remote nodes remain unmanaged.
|
|
125
125
|
ce.evolver.execution.enabled = ${?EVOLVER_EXECUTION_ENABLED}
|
|
126
|
+
# Private node-owned lifecycle capability; only the paired processor receives this volume.
|
|
127
|
+
# No control route is exposed on any TCP listener. 202 is acceptance, not ACTIVE.
|
|
128
|
+
ce.evolver.node-control.socket-path = "/node-control/node.sock"
|
|
126
129
|
# Shared SDK controller is the SOLE writer; node sees the whole config-only directory read-only.
|
|
127
130
|
# Ordinary boot/upgrade cannot merge defaults, resurrect removals or seed a lost declaration.
|
|
128
131
|
include required(file("/subscriptions/channels.conf"))
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# This node: a NON-GENESIS FOLLOWER on devnet. Chain-wide params come from the genesis
|
|
2
|
+
# spec — never set them here, they must stay byte-identical on every node. Only
|
|
3
|
+
# node-local knobs below.
|
|
4
|
+
include file("/config/genesis.devnet.conf")
|
|
5
|
+
|
|
6
|
+
# 089 chain-spec pin: the canonical hash of the genesis file IS the genesis block id.
|
|
7
|
+
# Any drift refuses to boot rather than forking quietly.
|
|
8
|
+
ce.evolver.genesis-file = "/config/genesis.devnet.conf"
|
|
9
|
+
|
|
10
|
+
# This node's OWN identity, from the first-boot faucet claim (node-join.sh). REQUIRED
|
|
11
|
+
# substitutions (no `?`): a node booting with missing keys must fail fast, not later and
|
|
12
|
+
# obscurely at the first TLS handshake.
|
|
13
|
+
ce.evolver.node {
|
|
14
|
+
private-key = ${EVOLVER_PRIVATE_KEY} # Ed25519 PKCS#8 base64
|
|
15
|
+
public-key = ${EVOLVER_NODE_PUBLIC_KEY} # Ed25519 X.509 base64
|
|
16
|
+
bls-secret = ${EVOLVER_BLS_SEED} # BLS IKM base64
|
|
17
|
+
port = ${EVOLVER_NODE_PORT} # p2p transport (raw TCP, mTLS)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
evolver.peer-discovery {
|
|
21
|
+
# THIS NODE ADVERTISES NOTHING, AND MUST NOT. `preferred-url` is the address this node
|
|
22
|
+
# gossips as its OWN (`""` = feature off), and a joined app node behind Docker HAS no
|
|
23
|
+
# routable p2p address: its 7000 is container-local and deliberately unpublished. So
|
|
24
|
+
# advertising one means every node on the chain records `<us> -> https://127.0.0.1:7000`
|
|
25
|
+
# and then dials its own loopback.
|
|
26
|
+
#
|
|
27
|
+
# Observed on devnet, and it is worse than noise: this node CONNECTED TO ITSELF, logged
|
|
28
|
+
# `Peer connected: <self>`, sent its own SyncManager `BlockRangeRequest(4841,4845)`, and
|
|
29
|
+
# TipGossip then reported `rotating to <self> (failed=devnet1-node0)` followed by
|
|
30
|
+
# `stalled (upstream=none)`. After that its submits were never forwarded and every write
|
|
31
|
+
# timed out. The table also keeps the dead: `evolvertemplateappnode965813 at
|
|
32
|
+
# 127.0.0.1:7000` from an earlier deployment was still being dialled hours later.
|
|
33
|
+
# ADVERTISED AGAIN, and ⚠️ ONLY BECAUSE IT IS NOW REAL. This was `""` because the node
|
|
34
|
+
# had been advertising `https://127.0.0.1:7000`: every peer that read the discovery
|
|
35
|
+
# table dialled its OWN loopback, so this node connected to itself, sent its own
|
|
36
|
+
# SyncManager block requests, reported `candidates=<self>` / `stalled (upstream=none)`
|
|
37
|
+
# and stopped forwarding submits. An unroutable value reinstates that exactly — which
|
|
38
|
+
# is why `EVOLVER_TRANSPORT_ADDRESS` is REQUIRED here rather than defaulted, and why the
|
|
39
|
+
# command suite refuses a loopback value.
|
|
40
|
+
# ⚠️ EMPTY, DELIBERATELY. This node is a READER behind NAT: it has no routable p2p address.
|
|
41
|
+
# Advertising one is not merely useless, it is harmful — devnet recorded a node that advertised
|
|
42
|
+
# an unroutable address CONNECTING TO ITSELF, sending its own sync requests, reporting
|
|
43
|
+
# `stalled (upstream=none)`, and leaving dead entries other nodes dialled for hours.
|
|
44
|
+
#
|
|
45
|
+
# It costs this node nothing: advertising is how others DIAL YOU, and nothing needs to. Writing
|
|
46
|
+
# still works — this node dials OUT, discovers the network, learns its distance to the committee
|
|
47
|
+
# and forwards transactions along that path. Being undialable only stops bodies being flooded TO
|
|
48
|
+
# it, which is a DA member's concern and not a reader's.
|
|
49
|
+
transport-address = ""
|
|
50
|
+
peer-url = ""
|
|
51
|
+
gossip-interval-seconds = 60
|
|
52
|
+
|
|
53
|
+
# 16 INBOUND, not 0. The 141 leaf recipe (`in=0`) is right for an app node that only
|
|
54
|
+
# talks upstream — and wrong here: the registry's channel requires announcers' nodes to
|
|
55
|
+
# reach THIS node so their flooded bodies can be attested (research D2). Each announcer
|
|
56
|
+
# holds one inbound slot, so 16 is the number of concurrent announcer nodes supported.
|
|
57
|
+
# Generous on both sides so DISCOVERY actually happens. The seed below is pinned so the node
|
|
58
|
+
# always has a way back to the chain, but `allowed-outbound` is deliberately LARGER than the
|
|
59
|
+
# pinned list — the pin is strict only while the two are equal, and a strict pin here would mean
|
|
60
|
+
# this node never dials anything it discovers.
|
|
61
|
+
allowed-inbound = 16
|
|
62
|
+
# `allowed-outbound` and `preferred-peers` are DELIBERATELY ABSENT HERE — node-join.sh renders
|
|
63
|
+
# both into peers.conf so the two cannot drift apart in two files.
|
|
64
|
+
#
|
|
65
|
+
# ⚠️ THIS COMMENT USED TO CLAIM THE PIN IS STRICT. It is NOT, and the difference matters to
|
|
66
|
+
# anyone reasoning about what this node will dial. It said `|preferred| == allowed-outbound`
|
|
67
|
+
# holds "by construction"; node-join.sh renders ONE pinned seed and an outbound budget of 16,
|
|
68
|
+
# so 1 != 16 and the pin is deliberately LOOSE. That is the intended design — a follower has to
|
|
69
|
+
# dial what it discovers or it is a leaf — but the old wording described the opposite property
|
|
70
|
+
# and named the incidents (a dead address from a torn-down deployment still being dialled, this
|
|
71
|
+
# node dialling its own loopback) that a strict pin was supposed to prevent. Those are the COST
|
|
72
|
+
# of the loose pin, not evidence against it: the seed guarantees a way back to the chain, and
|
|
73
|
+
# discovery does the rest. Which is what keeps a shared chain's discovery table out of the
|
|
74
|
+
# dial queue — it keeps the dead (an address from a torn-down deployment was still
|
|
75
|
+
# being dialled hours later) and once made this node dial its own loopback. Raising
|
|
76
|
+
# the budget without naming a peer would silently unpin it; naming a peer without
|
|
77
|
+
# raising the budget could never reach it. One list, both keys, no drift.
|
|
78
|
+
#
|
|
79
|
+
# ⚠️ THIS USED TO BE `out=1`, copied verbatim from the 141 LEAF recipe, and it decided
|
|
80
|
+
# WHICH CHANNELS THIS NODE COULD READ — silently. `ChannelOverlay.peers(ledgerId)` is
|
|
81
|
+
# the channel's DA group ∩ connected, so with the one slot spent on the upstream every
|
|
82
|
+
# channel whose DA member is somebody else resolved to an EMPTY pull set: the node
|
|
83
|
+
# records the miss, sends no request, and says so at DEBUG only (evolver#89). A node
|
|
84
|
+
# reading a channel it does not itself serve needs the upstream AND one reachable DA
|
|
85
|
+
# member per such channel, so a fixed 1 is only ever right for a node that reads
|
|
86
|
+
# nothing but its own.
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
ce.evolver.consensus {
|
|
90
|
+
validate-chain-on-startup = false
|
|
91
|
+
wake-on-work { forward-watermark = 16 }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# PROCESSING MODE — off by default, and THE REASON THIS DEPLOYMENT EXISTS once your app
|
|
95
|
+
# reads a channel.
|
|
96
|
+
#
|
|
97
|
+
# `/api/channel/next` is a SINGLE-CONSUMER, FORWARD-ONLY cursor: two readers do not
|
|
98
|
+
# error, they silently split the stream and the message is unrecoverable. So an app that
|
|
99
|
+
# consumes an ordered channel must drain a node NOBODY ELSE drains — which means a node
|
|
100
|
+
# of its own. It cannot share a public one, and a single-node chain (devnet today) IS the
|
|
101
|
+
# committee, so that node is not a processing node and serves no stream at all.
|
|
102
|
+
#
|
|
103
|
+
# And the committee's node must never be put in this mode: processing mode DEFERS apply
|
|
104
|
+
# and paces it from the app's polling, so a committee node in processing mode would
|
|
105
|
+
# couple consensus progress to an HTTP poller. Ordering is its job; give the app its own.
|
|
106
|
+
#
|
|
107
|
+
# Turning it on changes what committed reads MEAN here — they answer "as of MY cursor".
|
|
108
|
+
# Measured in this family (evolver-scheduler): right after a successful faucet claim a
|
|
109
|
+
# quorum node reported `{"balance":5000}` for an identity while a processing node
|
|
110
|
+
# reported `{"error":"not_found"}` for the same one. An app in that mode needs to know
|
|
111
|
+
# which of its questions are "has the chain agreed?" (ask a quorum node) and which are
|
|
112
|
+
# "what have I processed?" (ask this one).
|
|
113
|
+
#
|
|
114
|
+
# ON, because this app owns a channel (`announcements`) and reads its ordered stream —
|
|
115
|
+
# steps 5-7 of src/app.ts. BOTH lines are needed and neither works alone: a node in
|
|
116
|
+
# processing mode with no channels declared has nothing to offer, and a node that
|
|
117
|
+
# declares channels without execution stores bodies it never streams.
|
|
118
|
+
#
|
|
119
|
+
# The channel must be named HERE as well as claimed on chain. A processing node stores
|
|
120
|
+
# bodies only for the channels it lists, so an undeclared channel's message is applied
|
|
121
|
+
# in the slice and never offered — the write looks fine and the stream stays silent,
|
|
122
|
+
# which is the failure the app's step 7 names explicitly.
|
|
123
|
+
# Processing is enabled only by the paired managed composition with its persistent zero-module
|
|
124
|
+
# shared SDK consumer. MCP sessions never drain. Independent remote nodes remain unmanaged.
|
|
125
|
+
ce.evolver.execution.enabled = ${?EVOLVER_EXECUTION_ENABLED}
|
|
126
|
+
# Private node-owned lifecycle capability; only the paired processor receives this volume.
|
|
127
|
+
# No control route is exposed on any TCP listener. 202 is acceptance, not ACTIVE.
|
|
128
|
+
ce.evolver.node-control.socket-path = "/node-control/node.sock"
|
|
129
|
+
# Shared SDK controller is the SOLE writer; node sees the whole config-only directory read-only.
|
|
130
|
+
# Ordinary boot/upgrade cannot merge defaults, resurrect removals or seed a lost declaration.
|
|
131
|
+
include required(file("/subscriptions/channels.conf"))
|
|
132
|
+
# `required` for the same reason: an absent optional include is IGNORED, and a node
|
|
133
|
+
# that silently fell back to the 64/64 defaults would dial the discovery table.
|
|
134
|
+
include required(file("/data/chain/peers.conf"))
|
|
135
|
+
|
|
136
|
+
# ── THE LOCAL ROUTES THIS DEPLOYMENT OPENS TO ITS OWN CONTAINERS ────────────
|
|
137
|
+
#
|
|
138
|
+
# The agent and the registry run BESIDE this node, each in its own container on this compose
|
|
139
|
+
# project's network. These routes carry no token and no client certificate — reaching the address
|
|
140
|
+
# IS the authorization — so the node binds them to loopback unless told otherwise, and loopback
|
|
141
|
+
# inside this container is reachable by nothing else in the stack.
|
|
142
|
+
#
|
|
143
|
+
# `bind-host = 0.0.0.0` here means: THIS COMPOSE NETWORK IS THE BOUNDARY. That claim holds only
|
|
144
|
+
# because none of these ports appear under `ports:` in docker-compose.yml and the stack does not
|
|
145
|
+
# use host networking. Publish one, or run with `--network host`, and the claim is simply false.
|
|
146
|
+
#
|
|
147
|
+
# ⚠️ `ce.evolver.login` is deliberately NOT here and cannot be: that route signs as any identity
|
|
148
|
+
# this host holds a signer for, which on this node is the node's own. Setting `bind-host` under
|
|
149
|
+
# that namespace fails the boot rather than being ignored.
|
|
150
|
+
|
|
151
|
+
# 145 — the agent sends and drains application messages. `/api/p2p/send` relays a message the
|
|
152
|
+
# agent signed ITSELF (its own identity, verified at the far end); `/api/p2p/sign-and-send` makes
|
|
153
|
+
# this node sign as itself. One listener, so opening it grants both.
|
|
154
|
+
ce.evolver.p2p {
|
|
155
|
+
http-port = 8460
|
|
156
|
+
bind-host = "0.0.0.0"
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
# 158 — the agent may ASK an enrolled phone to authorise a transaction. It cannot sign: the device
|
|
160
|
+
# holder approves every one, and that gate does not move when the bind does. Inert until a
|
|
161
|
+
# `mobile-signer` block exists, and the node says so at boot rather than failing.
|
|
162
|
+
ce.evolver.mobile {
|
|
163
|
+
http-port = 8470
|
|
164
|
+
bind-host = "0.0.0.0"
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
# 139 — CONTENT, so the agent can publish a document and the registry can read one back.
|
|
168
|
+
#
|
|
169
|
+
# `POST /content` writes to this node's own store, keyed by the bytes' own digest, and talks to
|
|
170
|
+
# no peer at all (FR-006). `bind-host` below is what lets the agent and the registry reach that
|
|
171
|
+
# route from their own containers. Fetching a document another node holds is the part that uses
|
|
172
|
+
# the overlay, and that is what the TLS listener underneath is for.
|
|
173
|
+
ce.evolver.content {
|
|
174
|
+
enabled = true
|
|
175
|
+
http-port = 8450
|
|
176
|
+
bind-host = "0.0.0.0"
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
data.base = "/data/chain"
|
|
180
|
+
state.snapshots = "snapshots"
|
|
181
|
+
# BOTH LISTENERS, and the pair is the point.
|
|
182
|
+
#
|
|
183
|
+
# 8440 PLAIN is what the agent and the registry talk to. They are ordinary HTTP clients in their
|
|
184
|
+
# own containers, and the alternative is not "use TLS" — this node's HTTPS listener presents its
|
|
185
|
+
# own IDENTITY certificate, self-signed, CN=<nodeId>, ~13 minutes old. It rotates, so it cannot
|
|
186
|
+
# be pinned as a CA, so a client that verifies it must turn verification off process-wide. For
|
|
187
|
+
# the agent that would also stop it verifying GoToSocial, which is not a trade worth making for
|
|
188
|
+
# a hop between two containers on one network.
|
|
189
|
+
#
|
|
190
|
+
# 8441 TLS is what PEERS use, and it exists so `ce.evolver.content` above can: the content peer
|
|
191
|
+
# route identifies its requester by client certificate, and refuses to start without somewhere
|
|
192
|
+
# mTLS to live. NOT PUBLISHED in docker-compose.yml — this node is a reader behind NAT that
|
|
193
|
+
# fetches outbound and serves nobody, so nothing needs to dial it.
|
|
194
|
+
#
|
|
195
|
+
# ⚠️ IF THIS NODE EVER SERVES CONTENT, `evolver.peer-discovery.api-url` must name 8441 and that
|
|
196
|
+
# port must be publicly reachable. It is empty here (see above): a node with no routable address
|
|
197
|
+
# advertises nothing and can only fetch.
|
|
198
|
+
http.port = 8440
|
|
199
|
+
# Only this private Compose network may reach the unsigned processing API.
|
|
200
|
+
http.bind-host = "0.0.0.0"
|
|
201
|
+
# ⚠️ REQUIRED SINCE 131 (#131), and without it this stack breaks. `http.bind-host` now defaults
|
|
202
|
+
# to 127.0.0.1 — the plain listener joined the bind-is-authorization set the four local listeners
|
|
203
|
+
# above already belong to, because it carries the whole API with no token and no client
|
|
204
|
+
# certificate, including (this node runs `ce.evolver.execution.enabled`) the SINGLE-CONSUMER
|
|
205
|
+
# cursor `GET /api/channel/next`, which a stranger can step past the registry's own deliveries.
|
|
206
|
+
#
|
|
207
|
+
# The claim being made here is the same one `ce.evolver.content.bind-host` and the p2p/login ones
|
|
208
|
+
# above make: THIS COMPOSE NETWORK IS THE BOUNDARY. It holds because 8440 is NOT published in
|
|
209
|
+
# docker-compose.yml and nothing uses host networking — the agent and the registry reach this
|
|
210
|
+
# node as `node:8440`, a service name on that network. Publish the port, or run with host
|
|
211
|
+
# networking, and the claim stops being true. The node cannot tell, so it logs a WARN naming it.
|
|
212
|
+
http.bind-host = "0.0.0.0"
|
|
213
|
+
https.port = 8441
|
|
214
|
+
evolver.grpc.port = 9000
|
|
215
|
+
|
|
216
|
+
# REQUIRED, and there is no default: `NodeBootstrap.initChainDb` reads this block
|
|
217
|
+
# directly, so a node without it dies with `No configuration setting found for key
|
|
218
|
+
# 'chainDb'` after the migrations have already run — which is exactly how this omission
|
|
219
|
+
# was found. Same values as the bundled deployment's node.
|
|
220
|
+
chainDb {
|
|
221
|
+
datasource {
|
|
222
|
+
driver = "org.hsqldb.jdbc.JDBCDriver"
|
|
223
|
+
user = "sa"
|
|
224
|
+
pass = ""
|
|
225
|
+
maxPoolSize = 5
|
|
226
|
+
connectionProperties = ""
|
|
227
|
+
cachePrepStmts = true
|
|
228
|
+
prepStmtCacheSize = 250
|
|
229
|
+
prepStmtCacheSqlLimit = 2048
|
|
230
|
+
connectionTimeout = 5000
|
|
231
|
+
useServerPrepStmts = false
|
|
232
|
+
transactionIsolationLevel = "TRANSACTION_READ_COMMITTED"
|
|
233
|
+
}
|
|
234
|
+
viewCachesSize = 50
|
|
235
|
+
useShutdownHook = false
|
|
236
|
+
freeBlobsEarly = false
|
|
237
|
+
}
|
|
238
|
+
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"operatorIdentity":"evolvermeerkatA","sdk":"0.
|
|
1
|
+
{"operatorIdentity":"evolvermeerkatA","devnet":{"origin":"https://devnet.mcsherrylabs.com","chainId":"c8925985ae7948d2e608efcf863928d15476c4b561ad773fdca6f0d53d4fea05","channel":"ce.meerkat.announcements","operator":"evolvermeerkatA483344","upstreamNodeId":"evolvermeerkatnode920080","daPeers":"schednode2","allowedOutbound":"2","genesisSha256":"7f6a95cd0fcbe2306d89b2426a9473b1c6537427a82feaffff4783d9cc5dc6b9"},"sdk":"0.23.0","bootstrap":"0.16.0"}
|
|
@@ -38,7 +38,7 @@ import * as store from "./store.js";
|
|
|
38
38
|
// The channel this app owns. Everything about the ordered stream — and why it needs a node of
|
|
39
39
|
// its own — lives in that module.
|
|
40
40
|
import { CHANNEL, DA_THRESHOLD_M, ensureChannelWithDa, ensureDaGroup, announce, readBack, retainedMessages, paceApply, paceQuietly, nodeSelfId } from "./announcements.js";
|
|
41
|
-
import { processorHttpPort, registryChannel } from "./env.js";
|
|
41
|
+
import { processorHttpPort, registryChannel, registryChannelOperator, registryReadOnly } from "./env.js";
|
|
42
42
|
import { join } from "node:path";
|
|
43
43
|
import { registryModule, registryReadRoutes } from "./registry/index.js";
|
|
44
44
|
import { meerkatShutdown, startMeerkatProcessor, startMeerkatService } from "./runtime.js";
|
|
@@ -401,12 +401,25 @@ try {
|
|
|
401
401
|
if (service) {
|
|
402
402
|
// Recovery control is independent of /status, chain wait, identity reconciliation and
|
|
403
403
|
// provisioning. Name resolution is local only; keys are loaded/claimed lazily in setup.
|
|
404
|
-
const
|
|
404
|
+
const readOnly = registryReadOnly();
|
|
405
|
+
// READ-ONLY: the installer's registry never claims or signs with any identity. The operator is
|
|
406
|
+
// the PINNED channel operator; no sender identity is ever loaded or claimed, so the identity()
|
|
407
|
+
// claim path is never reached in this mode. A read-only registry without a valid pinned operator
|
|
408
|
+
// has nobody to trust the channel's terms to, so it refuses at startup rather than proceeding
|
|
409
|
+
// owner-less.
|
|
410
|
+
const pinnedOperator = registryChannelOperator();
|
|
411
|
+
if (readOnly && !pinnedOperator)
|
|
412
|
+
throw new Error("REGISTRY_READ_ONLY=1 requires a valid REGISTRY_CHANNEL_OPERATOR naming the identity " +
|
|
413
|
+
"that owns the index channel; the installer's rendered compose always sets both.");
|
|
414
|
+
const operator = readOnly ? pinnedOperator : store.get(target, "sender")?.nodeId ?? nameFor("sender");
|
|
405
415
|
const module = registryModule({
|
|
406
416
|
chain: target.name, node, chainUrl: target.nodeUrl, chainAgreedUrl: target.faucetUrl,
|
|
407
417
|
operator, nodeIdentity: process.env.APP_NODE_ID?.trim() ?? "", say,
|
|
418
|
+
readOnly,
|
|
408
419
|
beforeProvision: waitUntilNodeCanWrite,
|
|
409
420
|
identity: async () => {
|
|
421
|
+
if (readOnly)
|
|
422
|
+
throw new Error("read-only registry must never claim or sign with any identity");
|
|
410
423
|
const sender = await identity("sender");
|
|
411
424
|
return { sender, signer: signerFor(sender) };
|
|
412
425
|
},
|
|
@@ -350,6 +350,18 @@ export function registryChannel() {
|
|
|
350
350
|
export function registryChannelOperator() {
|
|
351
351
|
return optionalEnv("REGISTRY_CHANNEL_OPERATOR");
|
|
352
352
|
}
|
|
353
|
+
/**
|
|
354
|
+
* Opt-in READ-ONLY registry mode (the installer always sets this; the published app image and the
|
|
355
|
+
* real-estate operator deployment must NOT).
|
|
356
|
+
*
|
|
357
|
+
* In this mode the registry never claims or signs with any identity, submits nothing, and its role
|
|
358
|
+
* decision can never yield `owner`. Owned+open+operator-matches reads back `reader`; an unclaimed
|
|
359
|
+
* channel drains nothing and `wait`s; an owner different from the pinned operator or a non-open
|
|
360
|
+
* write policy is `unusable`. Without this flag the existing owner path is byte-for-byte unchanged.
|
|
361
|
+
*/
|
|
362
|
+
export function registryReadOnly() {
|
|
363
|
+
return optionalEnv("REGISTRY_READ_ONLY") === "1";
|
|
364
|
+
}
|
|
353
365
|
// ── 004-event-processor-inbox ─────────────────────────────────────────────────
|
|
354
366
|
/** A setting 004 renamed. It is REFUSED, not read: see `processorHttpPort`. */
|
|
355
367
|
export class RenamedSettingError extends Error {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// the SDK. Signing keys and chain provisioning remain in this parent, never worker options.
|
|
3
3
|
import { isValidNodeId } from "@mcsherrylabs/evolver-client";
|
|
4
4
|
import { registryChannel, registryChannelOperator, registryContentUrl, registryDenyList, registryMaxLeaseRounds, } from "../env.js";
|
|
5
|
-
import { provisionRegistryChannel } from "./provision.js";
|
|
5
|
+
import { provisionRegistryChannel, provisionRegistryChannelReadOnly } from "./provision.js";
|
|
6
6
|
import { REGISTRY_MODULE_NAME } from "./worker-module.js";
|
|
7
7
|
export function registryModule(args) {
|
|
8
8
|
if (!isValidNodeId(args.operator))
|
|
@@ -13,6 +13,7 @@ export function registryModule(args) {
|
|
|
13
13
|
maxLeaseRounds: registryMaxLeaseRounds(), deny: [...registryDenyList()],
|
|
14
14
|
chainUrl: args.chainUrl, contentUrl: registryContentUrl(),
|
|
15
15
|
};
|
|
16
|
+
const pinnedOperator = registryChannelOperator();
|
|
16
17
|
return {
|
|
17
18
|
// Production starts emitted JS. No tsx worker remapping, test factory or remote discovery.
|
|
18
19
|
worker: { url: new URL("./worker-module.js", import.meta.url).href, options },
|
|
@@ -20,13 +21,21 @@ export function registryModule(args) {
|
|
|
20
21
|
name: REGISTRY_MODULE_NAME, channels: [channel],
|
|
21
22
|
async setup(context) {
|
|
22
23
|
await args.beforeProvision?.();
|
|
24
|
+
if (args.readOnly) {
|
|
25
|
+
const provisioned = await provisionRegistryChannelReadOnly({
|
|
26
|
+
chainAgreedUrl: args.chainAgreedUrl, channel, nodeIdentity: args.nodeIdentity,
|
|
27
|
+
say: args.say, operator: pinnedOperator ?? args.operator,
|
|
28
|
+
});
|
|
29
|
+
args.say(` registry ${provisioned.role}: ${channel}`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
23
32
|
const { sender, signer } = await args.identity();
|
|
24
33
|
if (sender.nodeId !== args.operator)
|
|
25
34
|
throw new Error("registry signing identity differs from fixed worker operator");
|
|
26
35
|
const provisioned = await provisionRegistryChannel({
|
|
27
36
|
node: args.node, chainUrl: args.chainUrl, chainAgreedUrl: args.chainAgreedUrl,
|
|
28
37
|
channel, owner: sender, signer, nodeIdentity: args.nodeIdentity,
|
|
29
|
-
say: args.say, operator:
|
|
38
|
+
say: args.say, operator: pinnedOperator,
|
|
30
39
|
pace: async () => { await context.pace(); },
|
|
31
40
|
});
|
|
32
41
|
args.say(` registry ${provisioned.role}: ${channel}`);
|
|
@@ -29,7 +29,28 @@
|
|
|
29
29
|
import { buildSignedTx, claimBody, updateChannelWritePolicyBody, LEDGER } from "@mcsherrylabs/evolver-client";
|
|
30
30
|
import { channelTerms, submitAndConfirm, updateChannelDaBody, } from "../announcements.js";
|
|
31
31
|
export function decideRegistryRole(args) {
|
|
32
|
-
const { terms, self, operator } = args;
|
|
32
|
+
const { terms, self, operator, readOnly } = args;
|
|
33
|
+
// READ-ONLY: the installer's registry never claims or signs with any identity. `self` is the
|
|
34
|
+
// PINNED operator name, not this deployment's (nonexistent) identity, and it must NEVER yield
|
|
35
|
+
// `owner` — a read-only indexer submitting a Claim for a channel someone else owns poisons the
|
|
36
|
+
// chain's tx queue (evolver#85). Decision folds entirely onto the pinned operator.
|
|
37
|
+
if (readOnly) {
|
|
38
|
+
if (terms.owners.length === 0) {
|
|
39
|
+
return { kind: "waiting", why: `'${terms.ledgerId}' is UNCLAIMED and this read-only registry will not claim it (owned ` +
|
|
40
|
+
`by '${operator ?? "(no operator pinned)"}', not by this deployment). Draining the cursor ` +
|
|
41
|
+
`and indexing nothing until the operator provisions it.` };
|
|
42
|
+
}
|
|
43
|
+
const holder = terms.owners.map((o) => o.name).join(", ");
|
|
44
|
+
if (operator && !terms.owners.some((o) => o.name === operator))
|
|
45
|
+
return { kind: "unusable", why: `'${terms.ledgerId}' is owned by ${holder}, but REGISTRY_CHANNEL_OPERATOR pins ` +
|
|
46
|
+
`'${operator}'. Either the operator changed — update the pin — or this name was taken by ` +
|
|
47
|
+
`somebody else. Refusing to index a channel whose terms are set by a party this deployment ` +
|
|
48
|
+
`was not told to trust.` };
|
|
49
|
+
if (terms.writePolicy !== "open")
|
|
50
|
+
return { kind: "unusable", why: `'${terms.ledgerId}' is owned by ${holder} and its write policy is ` +
|
|
51
|
+
`'${terms.writePolicy}', not 'open'. Every announcement would be a permanent miss.` };
|
|
52
|
+
return { kind: "reader", holder };
|
|
53
|
+
}
|
|
33
54
|
// ALREADY OURS — see "keeping is not acquiring" above. Note this is the IDENTITY name,
|
|
34
55
|
// matching what the claim/converge path below can actually sign for: a channel owned by a
|
|
35
56
|
// group we happen to belong to is deliberately NOT treated as ours, because this app holds
|
|
@@ -178,3 +199,27 @@ export async function provisionRegistryChannel(args) {
|
|
|
178
199
|
say(` '${channel}' already has the terms this registry needs — nothing to submit`);
|
|
179
200
|
return { role: "owner", channel, member: nodeIdentity, writes };
|
|
180
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* READ-ONLY provisioning: decide the role from the chain-agreed terms and NEVER submit, claim or
|
|
204
|
+
* sign. Used only by the installer's registry (`REGISTRY_READ_ONLY=1`), where no identity exists to
|
|
205
|
+
* sign with and the pinned operator (`REGISTRY_CHANNEL_OPERATOR`) is the only authority trusted.
|
|
206
|
+
*/
|
|
207
|
+
export async function provisionRegistryChannelReadOnly(args) {
|
|
208
|
+
const { chainAgreedUrl, channel, nodeIdentity, operator, say } = args;
|
|
209
|
+
const read = await channelTerms(chainAgreedUrl, channel);
|
|
210
|
+
if (!read.ok)
|
|
211
|
+
throw new Error(`could not read the terms of '${channel}' from ${chainAgreedUrl}: ${read.why}\n` +
|
|
212
|
+
` Refusing to guess — an unreadable answer is "not yet", never a licence to index unverified terms.`);
|
|
213
|
+
const role = decideRegistryRole({ terms: read.terms, self: operator ?? "", operator, readOnly: true });
|
|
214
|
+
if (role.kind === "unusable")
|
|
215
|
+
throw new Error(role.why);
|
|
216
|
+
if (role.kind === "waiting") {
|
|
217
|
+
say(` ⚠ ${role.why}`);
|
|
218
|
+
return { role: "waiting", channel, member: nodeIdentity, writes: 0 };
|
|
219
|
+
}
|
|
220
|
+
if (role.kind !== "reader")
|
|
221
|
+
throw new Error(`read-only registry resolved an impossible owner role — refusing to index`);
|
|
222
|
+
say(` '${channel}' is owned by ${role.holder} — running as a READ-ONLY reader`);
|
|
223
|
+
say(` nothing will be claimed, provisioned or submitted here`);
|
|
224
|
+
return { role: "reader", channel, member: nodeIdentity, writes: 0 };
|
|
225
|
+
}
|