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.
- package/README.md +5 -3
- package/bin/derive-evm-key.mjs +13 -0
- package/bin/hive +47 -67
- package/bin/hive-buzz.mjs +73 -0
- package/bin/hive-core.mjs +64 -0
- package/bin/hive-join.mjs +342 -91
- package/bin/hive-key.mjs +131 -0
- package/bin/hive-net.mjs +36 -9
- package/daemon/fanout.mjs +27 -5
- package/daemon/hived.mjs +110 -10
- package/docs/cli.md +3 -1
- package/onchain/deployments.sepolia.json +12 -3
- package/onchain/src/HoneyV3.sol +97 -0
- package/onchain/src/JellyV3.sol +103 -0
- package/package.json +6 -4
- package/server/api.mjs +56 -1
- package/server/join-page.mjs +7 -5
- package/server/provision.mjs +189 -4
- package/server/reactions.mjs +186 -0
- package/server/rewarder.mjs +155 -56
- package/server/slasher.mjs +136 -0
- package/server/supervisor.mjs +27 -0
- package/server/treasury.mjs +145 -2
- package/server/x402-facilitator.mjs +52 -0
- package/server/x402-gateway.mjs +44 -0
- package/shared/core.mjs +71 -0
- package/shared/events.mjs +4 -1
- package/shared/prompt.mjs +168 -0
- package/shared/reactions.mjs +37 -0
- package/shared/rewards.json +23 -1
- package/shared/txqueue.mjs +9 -4
- package/shared/x402-client.mjs +28 -0
- package/shared/x402.mjs +72 -0
|
@@ -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
|
|
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);
|
package/server/join-page.mjs
CHANGED
|
@@ -16,7 +16,8 @@ const esc = (s) => String(s).replace(/&/g, '&').replace(/</g, '<').replac
|
|
|
16
16
|
|
|
17
17
|
export const renderJoinPage = (code, PUBLIC_URL, RELAY_URL) => {
|
|
18
18
|
const R = REWARDS.rules;
|
|
19
|
-
const installCmd = `
|
|
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>
|
|
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"
|
|
89
|
-
<div class="
|
|
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><you>.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>
|
|
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
|
|
package/server/provision.mjs
CHANGED
|
@@ -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
|
-
|
|
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 },
|