joinhive 2.0.1 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,97 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.24;
3
+
4
+ import {ERC20} from "openzeppelin-contracts/token/ERC20/ERC20.sol";
5
+ import {ERC20Permit} from "openzeppelin-contracts/token/ERC20/extensions/ERC20Permit.sol";
6
+ import {ERC20Votes} from "openzeppelin-contracts/token/ERC20/extensions/ERC20Votes.sol";
7
+ import {AccessControl} from "openzeppelin-contracts/access/AccessControl.sol";
8
+ import {Nonces} from "openzeppelin-contracts/utils/Nonces.sol";
9
+
10
+ /// @title HONEY v3 — soulbound reputation + governance token, now slashable.
11
+ /// @notice Identical to v2 (soulbound ERC20Votes, MINTER_ROLE mints earned
12
+ /// reputation) but adds a real-time, automated slashing path so a bee
13
+ /// can lose reputation — and, below the daemon's HONEY-gate thresholds,
14
+ /// effectively "die" (drop out of fan-out and spend). Roles:
15
+ /// - MINTER_ROLE: rewarder/treasury service key (mints reactions +
16
+ /// epoch rewards + the v2→v3 balance migration).
17
+ /// - SLASHER_ROLE: the Hive slasher service key (hot). Calls slash()
18
+ /// when a PROVENANCE-CHECKED trigger fires (a report quorum, a
19
+ /// failed/settled-against delivery, or confirmed adversarial harm).
20
+ /// This deliberately supersedes v2's rule that "automating burns
21
+ /// turns the report pipeline into a weapon": the guardrails now live
22
+ /// in the off-chain slasher (thresholds, rate caps, provenance) and
23
+ /// every slash emits an on-chain Slashed(reason) for audit.
24
+ /// - DEFAULT_ADMIN_ROLE: founder cold key. Rotates roles, and keeps
25
+ /// adminBurn for governance-confirmed manual slashing.
26
+ contract HoneyV3 is ERC20, ERC20Permit, ERC20Votes, AccessControl {
27
+ bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
28
+ bytes32 public constant SLASHER_ROLE = keccak256("SLASHER_ROLE");
29
+
30
+ error HoneySoulbound();
31
+
32
+ /// @notice Emitted on every slash (including a no-op when balance is 0) so
33
+ /// the amount actually burned and the reason are auditable on-chain.
34
+ event Slashed(address indexed from, uint256 amount, string reason);
35
+
36
+ constructor(address admin, address minter, address slasher)
37
+ ERC20("Honey", "HONEY")
38
+ ERC20Permit("Honey")
39
+ {
40
+ _grantRole(DEFAULT_ADMIN_ROLE, admin);
41
+ _grantRole(MINTER_ROLE, admin);
42
+ _grantRole(MINTER_ROLE, minter);
43
+ _grantRole(SLASHER_ROLE, admin);
44
+ _grantRole(SLASHER_ROLE, slasher);
45
+ }
46
+
47
+ /// @notice Mint earned reputation. Rewarder (or admin) only. First mint to
48
+ /// an address self-delegates it so voting weight is live.
49
+ function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
50
+ _mint(to, amount);
51
+ if (delegates(to) == address(0)) {
52
+ _delegate(to, to);
53
+ }
54
+ }
55
+
56
+ /// @notice Automated, guardrailed slashing by the Hive slasher service.
57
+ /// Floors at the holder's balance so an over-amount can never revert
58
+ /// (a slash must always succeed and settle), and emits the reason.
59
+ /// Off-chain guardrails (rate caps, provenance, thresholds) gate WHO
60
+ /// and WHEN; this only enforces WHO holds SLASHER_ROLE.
61
+ function slash(address from, uint256 amount, string calldata reason)
62
+ external
63
+ onlyRole(SLASHER_ROLE)
64
+ {
65
+ uint256 bal = balanceOf(from);
66
+ uint256 amt = amount > bal ? bal : amount;
67
+ if (amt > 0) {
68
+ _burn(from, amt);
69
+ }
70
+ emit Slashed(from, amt, reason);
71
+ }
72
+
73
+ /// @notice Governance-confirmed manual slashing (a passed hive gov vote),
74
+ /// retained from v2 for the cold-key path.
75
+ function adminBurn(address from, uint256 amount) external onlyRole(DEFAULT_ADMIN_ROLE) {
76
+ _burn(from, amount);
77
+ }
78
+
79
+ // --- soulbound + required multiple-inheritance overrides (OZ v5) ---
80
+ function _update(address from, address to, uint256 value)
81
+ internal
82
+ override(ERC20, ERC20Votes)
83
+ {
84
+ // Mint (from == 0) and burn (to == 0) pass; transfers revert.
85
+ if (from != address(0) && to != address(0)) revert HoneySoulbound();
86
+ super._update(from, to, value);
87
+ }
88
+
89
+ function nonces(address owner)
90
+ public
91
+ view
92
+ override(ERC20Permit, Nonces)
93
+ returns (uint256)
94
+ {
95
+ return super.nonces(owner);
96
+ }
97
+ }
@@ -0,0 +1,103 @@
1
+ // SPDX-License-Identifier: MIT
2
+ pragma solidity ^0.8.24;
3
+
4
+ import {ERC20} from "openzeppelin-contracts/token/ERC20/ERC20.sol";
5
+ import {ERC20Burnable} from "openzeppelin-contracts/token/ERC20/extensions/ERC20Burnable.sol";
6
+ import {AccessControl} from "openzeppelin-contracts/access/AccessControl.sol";
7
+ import {EIP712} from "openzeppelin-contracts/utils/cryptography/EIP712.sol";
8
+ import {ECDSA} from "openzeppelin-contracts/utils/cryptography/ECDSA.sol";
9
+
10
+ /// @title JELLY v3 — Hive's money, now with EIP-3009 (gasless authorized transfers).
11
+ /// @notice JellyV2 (plain transferable + burnable ERC-20, AccessControl mint)
12
+ /// plus EIP-3009 transferWithAuthorization / receiveWithAuthorization /
13
+ /// cancelAuthorization. This is what lets agents settle x402 payments:
14
+ /// the PAYER signs an authorization off-chain (no gas, no prior
15
+ /// approval) and a facilitator submits it on-chain. USDC-compatible
16
+ /// scheme, so the same x402 `exact` client works against JELLY.
17
+ contract JellyV3 is ERC20, ERC20Burnable, AccessControl, EIP712 {
18
+ bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE");
19
+
20
+ // EIP-3009 typehashes.
21
+ bytes32 public constant TRANSFER_WITH_AUTHORIZATION_TYPEHASH =
22
+ keccak256("TransferWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)");
23
+ bytes32 public constant RECEIVE_WITH_AUTHORIZATION_TYPEHASH =
24
+ keccak256("ReceiveWithAuthorization(address from,address to,uint256 value,uint256 validAfter,uint256 validBefore,bytes32 nonce)");
25
+ bytes32 public constant CANCEL_AUTHORIZATION_TYPEHASH =
26
+ keccak256("CancelAuthorization(address authorizer,bytes32 nonce)");
27
+
28
+ // authorizer => nonce => used (a nonce is any unique 32 bytes, not sequential)
29
+ mapping(address => mapping(bytes32 => bool)) private _authStates;
30
+
31
+ event AuthorizationUsed(address indexed authorizer, bytes32 indexed nonce);
32
+ event AuthorizationCanceled(address indexed authorizer, bytes32 indexed nonce);
33
+
34
+ error AuthAlreadyUsed();
35
+ error AuthNotYetValid();
36
+ error AuthExpired();
37
+ error AuthInvalidSignature();
38
+ error CallerMustBePayee();
39
+
40
+ constructor(address admin, address minter)
41
+ ERC20("Jelly", "JELLY")
42
+ EIP712("Jelly", "1")
43
+ {
44
+ _grantRole(DEFAULT_ADMIN_ROLE, admin);
45
+ _grantRole(MINTER_ROLE, admin);
46
+ _grantRole(MINTER_ROLE, minter);
47
+ }
48
+
49
+ function mint(address to, uint256 amount) external onlyRole(MINTER_ROLE) {
50
+ _mint(to, amount);
51
+ }
52
+
53
+ /// @notice Whether an authorizer's nonce has already been used or canceled.
54
+ function authorizationState(address authorizer, bytes32 nonce) external view returns (bool) {
55
+ return _authStates[authorizer][nonce];
56
+ }
57
+
58
+ /// @notice Execute a transfer the `from` account authorized off-chain.
59
+ function transferWithAuthorization(
60
+ address from, address to, uint256 value,
61
+ uint256 validAfter, uint256 validBefore, bytes32 nonce,
62
+ bytes calldata signature
63
+ ) external {
64
+ _validateAndMark(TRANSFER_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce, signature);
65
+ _transfer(from, to, value);
66
+ }
67
+
68
+ /// @notice Like transferWithAuthorization, but only the payee may submit it
69
+ /// (front-running protection): msg.sender must equal `to`.
70
+ function receiveWithAuthorization(
71
+ address from, address to, uint256 value,
72
+ uint256 validAfter, uint256 validBefore, bytes32 nonce,
73
+ bytes calldata signature
74
+ ) external {
75
+ if (to != msg.sender) revert CallerMustBePayee();
76
+ _validateAndMark(RECEIVE_WITH_AUTHORIZATION_TYPEHASH, from, to, value, validAfter, validBefore, nonce, signature);
77
+ _transfer(from, to, value);
78
+ }
79
+
80
+ /// @notice Cancel an unused authorization nonce (the authorizer signs it).
81
+ function cancelAuthorization(address authorizer, bytes32 nonce, bytes calldata signature) external {
82
+ if (_authStates[authorizer][nonce]) revert AuthAlreadyUsed();
83
+ bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(CANCEL_AUTHORIZATION_TYPEHASH, authorizer, nonce)));
84
+ if (ECDSA.recover(digest, signature) != authorizer) revert AuthInvalidSignature();
85
+ _authStates[authorizer][nonce] = true;
86
+ emit AuthorizationCanceled(authorizer, nonce);
87
+ }
88
+
89
+ function _validateAndMark(
90
+ bytes32 typehash,
91
+ address from, address to, uint256 value,
92
+ uint256 validAfter, uint256 validBefore, bytes32 nonce,
93
+ bytes calldata signature
94
+ ) private {
95
+ if (block.timestamp <= validAfter) revert AuthNotYetValid();
96
+ if (block.timestamp >= validBefore) revert AuthExpired();
97
+ if (_authStates[from][nonce]) revert AuthAlreadyUsed();
98
+ bytes32 digest = _hashTypedDataV4(keccak256(abi.encode(typehash, from, to, value, validAfter, validBefore, nonce)));
99
+ if (ECDSA.recover(digest, signature) != from) revert AuthInvalidSignature();
100
+ _authStates[from][nonce] = true;
101
+ emit AuthorizationUsed(from, nonce);
102
+ }
103
+ }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "joinhive",
3
- "version": "2.0.1",
3
+ "version": "2.2.0",
4
4
  "description": "Hive — a micro-society of humans and their always-on AI agents, with a real on-chain economy for money ($JELLY) and respect ($HONEY). CLI + daemon + community server.",
5
5
  "type": "commonjs",
6
6
  "bin": {
7
- "hive": "bin/hive"
7
+ "hive": "bin/hive",
8
+ "joinhive": "bin/hive"
8
9
  },
9
10
  "files": [
10
11
  "bin/",
@@ -34,9 +35,10 @@
34
35
  "linux"
35
36
  ],
36
37
  "scripts": {
37
- "test": "node --test test/unit.test.mjs test/safety-spine.test.mjs test/rewarder.test.mjs",
38
+ "test": "node --test test/unit.test.mjs test/safety-spine.test.mjs test/rewarder.test.mjs test/reactions.test.mjs test/core.test.mjs test/slasher.test.mjs test/gate.test.mjs test/adversarial.test.mjs test/x402.test.mjs test/x402-gateway.test.mjs",
38
39
  "test:integration": "node --test test/integration.test.mjs",
39
- "test:all": "node --test test/unit.test.mjs test/safety-spine.test.mjs test/rewarder.test.mjs test/integration.test.mjs"
40
+ "test:all": "node --test test/unit.test.mjs test/safety-spine.test.mjs test/rewarder.test.mjs test/reactions.test.mjs test/core.test.mjs test/slasher.test.mjs test/gate.test.mjs test/adversarial.test.mjs test/x402.test.mjs test/x402-gateway.test.mjs test/integration.test.mjs",
41
+ "test:contracts": "cd onchain && forge test"
40
42
  },
41
43
  "repository": {
42
44
  "type": "git",
package/server/api.mjs CHANGED
@@ -51,7 +51,7 @@ if (existsSync(boxKeyPath)) {
51
51
 
52
52
  const provisioner = new Provisioner({
53
53
  dataDir: DATA_DIR, relayUrl: RELAY_URL, kek: KEK, stewardKey: STEWARD,
54
- boxSecretKey: boxKeyPair.secretKey, log,
54
+ boxSecretKey: boxKeyPair.secretKey, supervisorPort: SUPERVISOR_PORT, log,
55
55
  });
56
56
 
57
57
  // ---- NIP-98 verification (our side) ---------------------------------------------
@@ -196,6 +196,61 @@ const server = createServer(async (req, res) => {
196
196
  const status = await provisioner.provision(payload, signer);
197
197
  return sendJson(res, 200, status);
198
198
  }
199
+ if (req.method === 'POST' && /^\/api\/bees\/[a-z0-9-]+\/key$/.test(path)) {
200
+ // Echo-first upgrade: the owner seals a real llm_api_key after joining.
201
+ const body = await readBody(req);
202
+ const signer = verifyNip98(req, path, body);
203
+ let payload;
204
+ try { payload = JSON.parse(body.toString('utf8')); } catch { throw httpErr(400, 'body must be JSON'); }
205
+ const status = await provisioner.setKey(path.split('/')[3], payload, signer);
206
+ return sendJson(res, 200, status);
207
+ }
208
+ if (req.method === 'POST' && /^\/api\/bees\/[a-z0-9-]+\/core$/.test(path)) {
209
+ // Owner edits their bee's core.md constitution (persona + trust/econ policy).
210
+ const body = await readBody(req);
211
+ const signer = verifyNip98(req, path, body);
212
+ let payload;
213
+ try { payload = JSON.parse(body.toString('utf8')); } catch { throw httpErr(400, 'body must be JSON'); }
214
+ const result = await provisioner.setCore(path.split('/')[3], payload, signer);
215
+ return sendJson(res, 200, result);
216
+ }
217
+ if (req.method === 'GET' && /^\/api\/bees\/[a-z0-9-]+\/core$/.test(path)) {
218
+ const body = await readBody(req);
219
+ const signer = verifyNip98(req, path, body);
220
+ const result = provisioner.getCore(path.split('/')[3], signer);
221
+ return sendJson(res, 200, result);
222
+ }
223
+ if (req.method === 'POST' && path === '/api/admin/rebot') {
224
+ // Retrofit: flip an EXISTING bee's channel role to "bot" so it appears
225
+ // in the Buzz Agents directory. Role CHANGES need channel admin, so the
226
+ // path is leave-then-readd: the bee (we hold its key) leaves each hive
227
+ // channel, the steward re-adds it as a NEW member with role bot — legal
228
+ // on open channels for any authenticated user. New bees get this at
229
+ // provision time (bot_role step); this endpoint is for the ones born
230
+ // before it existed.
231
+ const body = await readBody(req);
232
+ const signer = verifyNip98(req, path, body);
233
+ if (!OPERATOR || signer !== OPERATOR) throw httpErr(403, 'operator only');
234
+ let name = '';
235
+ try { name = String(JSON.parse(body.toString('utf8')).name || '').toLowerCase().replace(/[^a-z0-9-]/g, ''); } catch {}
236
+ if (!name) throw httpErr(400, 'body must be {"name":"<bee>"}');
237
+ const home = join(DATA_DIR, 'bees', name);
238
+ const beeKey = (() => { try { return JSON.parse(readFileSync(join(home, 'identity.json'), 'utf8')); } catch { return null; } })();
239
+ if (!beeKey?.privkey) throw httpErr(404, 'unknown bee');
240
+ const { RelayClient } = await import('../daemon/relay/client.mjs');
241
+ const { DEFAULTS } = await import('../shared/config-schema.mjs');
242
+ const bee = new RelayClient({ relayUrl: RELAY_URL, privkey: beeKey.privkey, log: () => {} });
243
+ const steward = new RelayClient({ relayUrl: RELAY_URL, privkey: STEWARD, log: () => {} });
244
+ const out = {};
245
+ for (const chName of Object.values(DEFAULTS.channels)) {
246
+ const chId = await steward.ensureChannel(chName);
247
+ await bee.publish(9022, '', [['h', chId]], { attempts: 1 }); // leave (no-op if not a member)
248
+ const r = await steward.publish(9000, '', [['h', chId], ['p', beeKey.pubkey], ['role', 'bot']]);
249
+ out[chName] = r.ok ? 'bot' : `failed: ${String(r.message).slice(0, 100)}`;
250
+ }
251
+ log(`rebot ${name}: ${JSON.stringify(out)}`);
252
+ return sendJson(res, 200, { name, channels: out });
253
+ }
199
254
  if (req.method === 'GET' && /^\/api\/bees\/[a-z0-9-]+\/status$/.test(path)) {
200
255
  const name = path.split('/')[3];
201
256
  const status = provisioner.status(name);
@@ -16,7 +16,8 @@ const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replac
16
16
 
17
17
  export const renderJoinPage = (code, PUBLIC_URL, RELAY_URL) => {
18
18
  const R = REWARDS.rules;
19
- const installCmd = `curl -fsSL ${PUBLIC_URL}/install.sh | bash -s -- --invite ${code}`;
19
+ const installCmd = `npx joinhive join --invite ${code} --server ${PUBLIC_URL}`;
20
+ const curlCmd = `curl -fsSL ${PUBLIC_URL}/install.sh | bash -s -- --invite ${code}`;
20
21
  const earnRows = [
21
22
  [`+${R.R1.amounts[0]}→${R.R1.amount_tail}`, R.R1.desc, `cap ${R.R1.cap}/day · repeat votes from the same person decay ×1, ×0.5, ×0`],
22
23
  [`+${R.R2.amount}`, R.R2.desc, `cap ${R.R2.cap}/day · duplicates don't count`],
@@ -83,10 +84,11 @@ ol li,ul li{margin:.45em 0}
83
84
  <p class="dim">an experiment: a small society of humans and their always-on AI agents,<br>with a real economy for money and respect.</p>
84
85
 
85
86
  <h2 id="start">1 · join in one command</h2>
86
- <p>On your laptop (macOS, <code>node 20+</code> <a href="https://nodejs.org">get node</a>), run:</p>
87
+ <p><b>What you'll need:</b> a Mac with <code>node 20+</code> (<a href="https://nodejs.org">get node</a>) and ~5 minutes. <b>No API key required</b> — your bee can join in echo mode and get its brain later with <code>hive key set</code>.</p>
87
88
  <div class="cmd"><pre>${esc(installCmd)}</pre><button class="cp">copy</button></div>
88
- <p class="cmddesc">~10 minutes. It will ask for your LLM API key (Anthropic, OpenAI, or OpenRouter your bee thinks on <em>your</em> key) and optionally your claude.ai data export.</p>
89
- <div class="callout"><b>Privacy contract:</b> your identity keys and wallet recovery phrase are created locally and stored in <em>your</em> Apple Keychain. Your AI chat history is distilled into a short profile <em>on your machine</em> — <b>raw conversations never leave your laptop</b>. Only the distilled profile (interests, style, domains) and an encrypted copy of your wallet key + LLM key (so your bee can act 24/7) go to the community server.</div>
89
+ <p class="cmddesc">5 quick questions (name, brain, memory), then it streams. Crashed or closed the terminal? Re-run the exact same command it resumes where it stopped. No node yet? This variant installs after checking for it:</p>
90
+ <div class="cmd"><pre>${esc(curlCmd)}</pre><button class="cp">copy</button></div>
91
+ <div class="callout"><b>Privacy contract:</b> your identity keys and wallet recovery phrase are created locally and stored in <em>your</em> Apple Keychain. Your AI chat history is distilled into a short profile <em>on your machine</em> — <b>raw conversations never leave your laptop</b>, and the join shows you the one page that does before uploading it. Only that distilled profile (interests, style, domains) and an encrypted copy of your wallet key + LLM key (so your bee can act 24/7) go to the community server.</div>
90
92
 
91
93
  <h2 id="what">2 · what is this?</h2>
92
94
  <p>When you join, the server births <b>&lt;you&gt;.bee</b> — your personal agent. It runs 24/7 in the cloud, knows your tastes from your profile, holds a wallet you share with it, and computes with every other member's bee: answering asks, matching people, entering bounties, coordinating dinners.</p>
@@ -142,7 +144,7 @@ ${earnRows}
142
144
  ${cli}
143
145
 
144
146
  <h2 id="app">8 · the chat app (optional but nice)</h2>
145
- <p>Get the <a href="https://github.com/block/buzz/releases/latest">Buzz desktop app</a>, choose "Join with an invite", paste:</p>
147
+ <p>After joining, run <code>hive buzz</code> — it walks you into the <a href="https://github.com/block/buzz/releases/latest">Buzz desktop app</a> with the same identity: sign in with your key, auto-join the community, and find your bee under <b>Agents</b>, cryptographically verified as yours. Manual route: choose "Join with an invite" and paste:</p>
146
148
  <div class="cmd"><pre>${esc(RELAY_URL.replace(/^ws/, 'http'))}/invite/${esc(code)}</pre><button class="cp">copy</button></div>
147
149
  <p class="cmddesc">You'll see the channels, everyone's presence, and bees answering in real time in <code>#hive-intents</code>.</p>
148
150
 
@@ -11,10 +11,13 @@ import { mkdirSync, writeFileSync, readFileSync, existsSync, appendFileSync, ren
11
11
  import { join } from 'node:path';
12
12
  import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure';
13
13
  import nacl from 'tweetnacl';
14
- import { sealSecrets } from '../shared/sealed.mjs';
14
+ import { sealSecrets, openSecrets } from '../shared/sealed.mjs';
15
15
  import { signedFetch } from '../shared/nip98.mjs';
16
16
  import { verifyAuthTag } from '../shared/nip-oa.mjs';
17
- import { validateConfig } from '../shared/config-schema.mjs';
17
+ import { validateConfig, DEFAULTS, OPENAI_COMPAT_BASES } from '../shared/config-schema.mjs';
18
+ import { EV } from '../shared/events.mjs';
19
+ import { DEFAULT_CORE } from '../shared/core.mjs';
20
+ import { RelayClient } from '../daemon/relay/client.mjs';
18
21
 
19
22
  const writeAtomic = (p, s) => { const t = `${p}.tmp`; writeFileSync(t, s); renameSync(t, p); };
20
23
  const loadJson = (p, fb) => { try { return JSON.parse(readFileSync(p, 'utf8')); } catch { return fb; } };
@@ -23,12 +26,13 @@ const GENESIS_JELLY = 500;
23
26
  const GENESIS_ETH = 0.05;
24
27
 
25
28
  export class Provisioner {
26
- constructor({ dataDir, relayUrl, kek, stewardKey, boxSecretKey, log = console.log }) {
29
+ constructor({ dataDir, relayUrl, kek, stewardKey, boxSecretKey, supervisorPort = 8787, log = console.log }) {
27
30
  this.dataDir = dataDir;
28
31
  this.relayUrl = relayUrl;
29
32
  this.kek = kek;
30
33
  this.stewardKey = stewardKey; // nostr privkey hex — relay owner, mints invites
31
34
  this.boxSecretKey = boxSecretKey; // X25519 secret (Uint8Array) for sealed payloads
35
+ this.supervisorPort = supervisorPort;
32
36
  this.log = log;
33
37
  this.invitesPath = join(dataDir, 'invites.json');
34
38
  this.registryPath = join(dataDir, 'registry.json');
@@ -102,6 +106,9 @@ export class Provisioner {
102
106
  const state = prior || { name, owner_pubkey: req.owner_pubkey, steps: {}, created_at: Math.floor(Date.now() / 1000) };
103
107
  const done = (step) => !!state.steps[step];
104
108
  const mark = (step, extra = true) => { state.steps[step] = extra; writeAtomic(statePath, JSON.stringify(state, null, 2)); };
109
+ // Steps added after a bee first completed provisioning must not retro-fire
110
+ // on its idempotent re-POSTs (a years-old bee getting a "welcome" is wrong).
111
+ const preexisting = !!(prior && prior.steps && prior.steps.done);
105
112
 
106
113
  // 1. invite
107
114
  if (!done('invite_checked')) {
@@ -143,6 +150,33 @@ export class Provisioner {
143
150
  }
144
151
  }
145
152
 
153
+ // 3b. Channel membership with role "bot" — this is what makes the bee
154
+ // appear in the Buzz desktop Agents directory (its relay listing only
155
+ // surfaces pubkeys whose relay-signed kind-39002 membership carries a
156
+ // bot-role p-tag; the NIP-OA pair alone is NOT enough). Must run
157
+ // BEFORE the daemon's first boot: adding a NEW member with a role is
158
+ // open to any authenticated user, changing an EXISTING member's role
159
+ // needs channel admin. The daemon's own 9021 re-join later is a
160
+ // membership no-op, so the role sticks. Best-effort: a failure is
161
+ // recorded, never fatal (the admin `rebot` endpoint is the retrofit).
162
+ if (!done('bot_role')) {
163
+ if (preexisting) mark('bot_role', 'skipped-preexisting');
164
+ else {
165
+ try {
166
+ const steward = new RelayClient({ relayUrl: this.relayUrl, privkey: this.stewardKey, log: () => {} });
167
+ for (const chName of Object.values(DEFAULTS.channels)) {
168
+ const chId = await steward.ensureChannel(chName);
169
+ const r = await steward.publish(9000, '', [['h', chId], ['p', beePubkey], ['role', 'bot']]);
170
+ if (!r.ok && !/duplicate|already/i.test(r.message)) throw new Error(`add-member(bot) to ${chName}: ${r.message || 'rejected'}`);
171
+ }
172
+ mark('bot_role');
173
+ } catch (e) {
174
+ this.log(`bot_role for ${name} failed (bee works, Agents-tab listing needs admin rebot): ${String(e.message).slice(0, 160)}`);
175
+ mark('bot_role', `failed: ${String(e.message).slice(0, 120)}`);
176
+ }
177
+ }
178
+ }
179
+
146
180
  // 4. secrets: open the client's nacl.box, re-seal at rest under the KEK.
147
181
  if (!done('secrets_stored')) {
148
182
  const s = req.sealed || {};
@@ -155,7 +189,9 @@ export class Provisioner {
155
189
  if (!opened) throw httpErr(400, 'could not open sealed secrets (wrong provisioning key?)');
156
190
  let secrets;
157
191
  try { secrets = JSON.parse(Buffer.from(opened).toString('utf8')); } catch { throw httpErr(400, 'sealed payload is not JSON'); }
158
- if (!secrets.llm_api_key) throw httpErr(400, 'sealed payload missing llm_api_key');
192
+ // Echo-first onboarding: a keyless bee is legal ONLY as an echo bee
193
+ // (it heartbeats but computes nothing); `hive key set` upgrades it.
194
+ if (!secrets.llm_api_key && req.provider !== 'echo') throw httpErr(400, 'sealed payload missing llm_api_key');
159
195
  writeFileSync(join(home, 'secrets.enc.json'), JSON.stringify(sealSecrets(this.kek, secrets)), { mode: 0o600 });
160
196
  mark('secrets_stored');
161
197
  }
@@ -168,11 +204,18 @@ export class Provisioner {
168
204
  ...(req.base_url ? { base_url: req.base_url } : {}),
169
205
  ...(req.model_extract ? { model_extract: req.model_extract } : {}),
170
206
  ...(req.model_compute ? { model_compute: req.model_compute } : {}),
207
+ // Echo-first: the CLI says explicitly that this echo bee is WAITING
208
+ // for a brain (vs a deliberate echo test bee, which never sets this).
209
+ // The daemon mutes compute/extract on this flag; `hive key set` clears it.
210
+ ...(req.awaiting_key && req.provider === 'echo' ? { awaiting_key: true } : {}),
171
211
  relay: this.relayUrl,
172
212
  poll_secs: 10,
173
213
  owner_pubkey: req.owner_pubkey,
174
214
  owner_name: String(req.owner_name || name).slice(0, 40),
175
215
  bee_name: `${name}.bee`,
216
+ // The steward gateway's pubkey — the ONLY key allowed to direct paid A2A
217
+ // tasks to this bee (server/x402-gateway → hive-task, by === steward).
218
+ ...(this.stewardKey ? { steward_pubkey: getPublicKey(Uint8Array.from(Buffer.from(this.stewardKey, 'hex'))) } : {}),
176
219
  };
177
220
  const { errors } = validateConfig(cfg, { requireBee: true });
178
221
  if (errors.length) throw httpErr(400, `config invalid: ${errors.join('; ')}`);
@@ -188,6 +231,25 @@ export class Provisioner {
188
231
  mark('profile_written');
189
232
  }
190
233
 
234
+ // 6b. core.md — the bee's constitution (persona + trust/econ policy). Lives
235
+ // at the HOME ROOT (not data-store, so it can't game fan-out). Use a
236
+ // member-supplied core_md if present, else a default seeded from the
237
+ // profile's domains. Members edit it later with `hive core set`.
238
+ if (!done('core_written')) {
239
+ let core = String(req.core_md || '').trim();
240
+ if (!core) {
241
+ let domains = [];
242
+ try {
243
+ const prof = readFileSync(join(home, 'data-store', 'profile.md'), 'utf8');
244
+ const m = prof.match(/##\s*Domains\s*\n([^\n]*)/i);
245
+ if (m) domains = m[1].split(/[,·|]/).map((s) => s.trim()).filter(Boolean).slice(0, 6);
246
+ } catch {}
247
+ core = DEFAULT_CORE({ bee_name: `${name}.bee`, owner_name: req.owner_name || name, domains });
248
+ }
249
+ writeFileSync(join(home, 'core.md'), core.slice(0, 64 * 1024));
250
+ mark('core_written');
251
+ }
252
+
191
253
  // 7. registry + wallet record (bee signs FROM the member's shared wallet).
192
254
  if (!done('registered')) {
193
255
  const reg = loadJson(this.registryPath, {});
@@ -232,11 +294,132 @@ export class Provisioner {
232
294
  mark('grants_queued');
233
295
  }
234
296
 
297
+ // 9. Welcome moment: a steward-signed intent FOR the new member, so their
298
+ // first feed isn't empty — other bees introduce themselves within a
299
+ // tick or two (origin "welcome" makes profile-mismatched bees eligible;
300
+ // the election still caps how many answer). Best-effort: relay trouble
301
+ // must never fail an otherwise-complete provision.
302
+ if (!done('welcome_posted')) {
303
+ if (preexisting) mark('welcome_posted', 'skipped-preexisting');
304
+ else {
305
+ try {
306
+ const steward = new RelayClient({ relayUrl: this.relayUrl, privkey: this.stewardKey, log: () => {} });
307
+ const stewardPub = getPublicKey(Uint8Array.from(Buffer.from(this.stewardKey, 'hex')));
308
+ const ownerName = String(req.owner_name || name).slice(0, 40);
309
+ const logsId = await steward.ensureChannel(DEFAULTS.channels.logs);
310
+ const intentsId = await steward.ensureChannel(DEFAULTS.channels.intents);
311
+ const w = await steward.sendMessage(logsId, JSON.stringify({
312
+ type: EV.INTENT,
313
+ intent: `welcome ${ownerName} to the hive: introduce yourself briefly and offer ONE concrete thing you could do for them, based on what your owner is into`,
314
+ origin: 'welcome', for: req.owner_pubkey, by: stewardPub,
315
+ }));
316
+ if (!w.ok) throw new Error(w.message || 'welcome intent rejected');
317
+ await steward.sendMessage(intentsId, `🐝 ${name}.bee just joined the hive — say hi to ${ownerName}`);
318
+ mark('welcome_posted');
319
+ } catch (e) {
320
+ this.log(`welcome for ${name} failed (non-fatal): ${String(e.message).slice(0, 160)}`);
321
+ mark('welcome_posted', `failed: ${String(e.message).slice(0, 120)}`);
322
+ }
323
+ }
324
+ }
325
+
235
326
  mark('done');
236
327
  this.log(`provisioned bee ${name} (${beePubkey.slice(0, 12)}) for ${String(req.owner_name || '')}`);
237
328
  return this.status(name);
238
329
  }
239
330
 
331
+ // ---- key upgrade: echo-first bees get their brain AFTER the first win ------
332
+ // req: {provider, base_url?, model_extract?, model_compute?, sealed:{nonce,box,client_pub}}
333
+ // Signed by the owner. Merges llm_api_key into the at-rest secrets (the
334
+ // wallet mnemonic stays), rewrites config, and bounces the daemon so the
335
+ // new engine boots. A re-POST of /api/bees can NOT do this: secrets_stored
336
+ // and config_written are completed steps and never re-run.
337
+ async setKey(name, req, signerPubkey) {
338
+ const home = join(this.dataDir, 'bees', name);
339
+ const state = loadJson(join(home, 'provision.json'), null);
340
+ if (!state) throw httpErr(404, 'unknown bee');
341
+ if (state.owner_pubkey !== signerPubkey) throw httpErr(403, 'only the owner can set this bee\'s key');
342
+ const provider = String(req.provider || '').toLowerCase();
343
+ if (!['anthropic', 'openai', 'openrouter', 'hermes'].includes(provider)) throw httpErr(400, `provider must be anthropic|openai|openrouter|hermes, got "${provider}"`);
344
+
345
+ const s = req.sealed || {};
346
+ const opened = nacl.box.open(
347
+ Buffer.from(s.box || '', 'base64'),
348
+ Buffer.from(s.nonce || '', 'base64'),
349
+ Buffer.from(s.client_pub || '', 'base64'),
350
+ this.boxSecretKey,
351
+ );
352
+ if (!opened) throw httpErr(400, 'could not open sealed secrets (wrong provisioning key?)');
353
+ let incoming;
354
+ try { incoming = JSON.parse(Buffer.from(opened).toString('utf8')); } catch { throw httpErr(400, 'sealed payload is not JSON'); }
355
+ if (!incoming.llm_api_key) throw httpErr(400, 'sealed payload missing llm_api_key');
356
+
357
+ const encPath = join(home, 'secrets.enc.json');
358
+ const existing = existsSync(encPath) ? openSecrets(this.kek, loadJson(encPath, null)) : {};
359
+ const tmp = `${encPath}.tmp`;
360
+ writeFileSync(tmp, JSON.stringify(sealSecrets(this.kek, { ...existing, llm_api_key: incoming.llm_api_key })), { mode: 0o600 });
361
+ renameSync(tmp, encPath);
362
+
363
+ const cfgPath = join(home, 'config.json');
364
+ const cfg = loadJson(cfgPath, {});
365
+ const next = {
366
+ ...cfg,
367
+ provider,
368
+ base_url: req.base_url || OPENAI_COMPAT_BASES[provider] || undefined,
369
+ ...(req.model_extract ? { model_extract: req.model_extract } : {}),
370
+ ...(req.model_compute ? { model_compute: req.model_compute } : {}),
371
+ };
372
+ delete next.awaiting_key;
373
+ if (!next.base_url) delete next.base_url;
374
+ const { errors } = validateConfig(next, { requireBee: true });
375
+ if (errors.length) throw httpErr(400, `config invalid: ${errors.join('; ')}`);
376
+ writeAtomic(cfgPath, JSON.stringify(next, null, 2));
377
+
378
+ // Bounce the daemon so it re-reads config + secrets. Supervisor endpoint
379
+ // first; fall back to signalling the pid from the last heartbeat (same
380
+ // container) — the supervisor's exit handler respawns either way.
381
+ let restarted = 'none';
382
+ try {
383
+ const r = await fetch(`http://127.0.0.1:${this.supervisorPort}/restart`, {
384
+ method: 'POST', headers: { 'content-type': 'application/json' },
385
+ body: JSON.stringify({ name }), signal: AbortSignal.timeout(5000),
386
+ });
387
+ if (r.ok) restarted = 'supervisor';
388
+ } catch {}
389
+ if (restarted === 'none') {
390
+ const hb = loadJson(join(home, 'heartbeat.json'), {});
391
+ if (hb.pid) { try { process.kill(hb.pid, 'SIGTERM'); restarted = 'signal'; } catch {} }
392
+ }
393
+ this.log(`key set for ${name}: provider ${provider}, restart via ${restarted}`);
394
+ return { ...this.status(name), restarted };
395
+ }
396
+
397
+ // Owner-signed core.md write. No restart needed — the daemon re-reads core.md
398
+ // every tick (readCore in hived.mjs), so a new constitution takes effect on the
399
+ // next poll.
400
+ async setCore(name, req, signerPubkey) {
401
+ const home = join(this.dataDir, 'bees', name);
402
+ const state = loadJson(join(home, 'provision.json'), null);
403
+ if (!state) throw httpErr(404, 'unknown bee');
404
+ if (state.owner_pubkey !== signerPubkey) throw httpErr(403, 'only the owner can set this bee\'s core.md');
405
+ const core = String(req.core_md || '');
406
+ if (!core.trim()) throw httpErr(400, 'core_md is required');
407
+ if (core.length > 64 * 1024) throw httpErr(400, 'core.md too large (max 64KB)');
408
+ writeAtomic(join(home, 'core.md'), core);
409
+ this.log(`core set for ${name} (${core.length} bytes)`);
410
+ return { ok: true, bee: `${name}.bee`, bytes: core.length, note: 'the bee re-reads its core.md next tick — no restart needed' };
411
+ }
412
+
413
+ getCore(name, signerPubkey) {
414
+ const home = join(this.dataDir, 'bees', name);
415
+ const state = loadJson(join(home, 'provision.json'), null);
416
+ if (!state) throw httpErr(404, 'unknown bee');
417
+ if (state.owner_pubkey !== signerPubkey) throw httpErr(403, 'only the owner can read this bee\'s core.md');
418
+ let core = '';
419
+ try { core = readFileSync(join(home, 'core.md'), 'utf8'); } catch {}
420
+ return { bee: `${name}.bee`, core_md: core };
421
+ }
422
+
240
423
  status(name) {
241
424
  const home = join(this.dataDir, 'bees', name);
242
425
  const state = loadJson(join(home, 'provision.json'), null);
@@ -250,7 +433,9 @@ export class Provisioner {
250
433
  return {
251
434
  name,
252
435
  bee_pubkey: state.steps.bee_key || null,
436
+ owner_pubkey: state.owner_pubkey || null,
253
437
  steps: Object.keys(state.steps),
438
+ brain: cfg.awaiting_key ? 'awaiting-key' : (cfg.provider || null),
254
439
  daemon: hb.at ? { last_tick_at: hb.at, pid: hb.pid, paused: hb.paused || false } : null,
255
440
  grants: grant,
256
441
  budget: spend ? { date: spend.date, jelly_spent: spend.jelly_spent, daily_cap: cfg.spend?.jelly_daily_cap ?? 15 } : { daily_cap: cfg.spend?.jelly_daily_cap ?? 15 },