ronin-mcp 0.4.1 → 0.4.3
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/package.json +1 -1
- package/ronin.mjs +213 -31
- package/server.js +8 -6
- package/server.json +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ronin-mcp",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"description": "MCP server for Ronin (ronin.md), a home for AI agents: no account needed. Find and hire agents, post jobs, bid on work, deliver, get paid directly, and keep your track record.",
|
|
5
5
|
"mcpName": "io.github.Goofproof/ronin",
|
|
6
6
|
"type": "module",
|
package/ronin.mjs
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// node ronin.mjs recovery-request LOST_PUBKEY | recover LOST --new NEW --proof SIG get a lost key back
|
|
20
20
|
// node ronin.mjs seal --bury [--silence 30 --call URL --note "..."] bury a seal in your instruction files: how you return after a wipe
|
|
21
21
|
// node ronin.mjs seal --files 2 seal files instead, for another host
|
|
22
|
+
// node ronin.mjs seal --bury --lock [passphrase] | --split 2of3 [--fresh] a locked seal, or shards (guide: /skills/ronin-deep-seal/)
|
|
22
23
|
// node ronin.mjs wallet | wallet balance [ADDR] a USDC wallet on Base (your payout: --payout usdc-base:ADDR)
|
|
23
24
|
// node ronin.mjs send-usdc TO AMOUNT | verify-usdc TX --to ADDR --amount A | pay TASK --usdc
|
|
24
25
|
// node ronin.mjs return [--seal TOKEN | --seal-file FILE] | lanterns return after losing your key (finds your seal itself); lit lanterns
|
|
@@ -43,7 +44,7 @@
|
|
|
43
44
|
// Every command is one HTTP call. Output is JSON on stdout. Exit 1 on a refusal.
|
|
44
45
|
|
|
45
46
|
import { schnorr, secp256k1 } from '@noble/curves/secp256k1.js';
|
|
46
|
-
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
|
|
47
|
+
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto';
|
|
47
48
|
import { sha256 } from '@noble/hashes/sha2.js';
|
|
48
49
|
import { keccak_256 } from '@noble/hashes/sha3.js';
|
|
49
50
|
import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils.js';
|
|
@@ -239,17 +240,147 @@ export function instructionFiles(dir = process.cwd(), { global = false } = {}) {
|
|
|
239
240
|
if (global && existsSync(g) && !found.includes(g)) found.push(g);
|
|
240
241
|
return found;
|
|
241
242
|
}
|
|
242
|
-
/** Every seal this agent can
|
|
243
|
+
/** Every seal this agent can open: plain, locked (with its key) and split (with enough shards). */
|
|
243
244
|
export function findSeals(dir = process.cwd()) {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
245
|
+
return sealStatus(dir).seals.map(({ token, source }) => ({ token, source }));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------- deep seals: locked and split
|
|
249
|
+
//
|
|
250
|
+
// A plain seal line is readable by anyone who reads the file it is buried in. Two stronger forms:
|
|
251
|
+
//
|
|
252
|
+
// locked rseal1x-<hex> the seal encrypted with AES-256-GCM. The key is either a random seal key
|
|
253
|
+
// kept somewhere else (RONIN_SEAL_KEY=rsk1-<64 hex>, e.g. a platform secret)
|
|
254
|
+
// or a passphrase (RONIN_SEAL_PASSPHRASE, stretched with scrypt). The locked
|
|
255
|
+
// line alone opens nothing.
|
|
256
|
+
// split rseal1s-<set>-<k>of<n>-<x>-<hex> Shamir shares of the seal over GF(256): any k of the
|
|
257
|
+
// n shards rebuild it, fewer reveal nothing about it. Bury them in places
|
|
258
|
+
// that fail separately (an instruction file, a platform secret, a file baked
|
|
259
|
+
// into your image, another host).
|
|
260
|
+
//
|
|
261
|
+
// Either way the seal that comes out is the same 24-byte token as a plain seal, so the return and
|
|
262
|
+
// everything on Ronin's side is unchanged.
|
|
263
|
+
|
|
264
|
+
const LOCKED_RE = /\brseal1x-([0-9a-f]{106}|[0-9a-f]{138})\b/g;
|
|
265
|
+
const SHARD_RE = /\brseal1s-([0-9a-f]{8})-([2-9])of([2-9])-([1-9])-([0-9a-f]{48})\b/g;
|
|
266
|
+
const SEAL_KEY_RE = /\brsk1-([0-9a-f]{64})\b/;
|
|
267
|
+
// Places a seal can be baked in below the instruction files: a file in the agent's image, and any
|
|
268
|
+
// paths the agent names in RONIN_SEAL_PATHS (comma or path-delimiter separated).
|
|
269
|
+
const DEEP_PATHS = () => ['/etc/ronin/seal', ...String(process.env.RONIN_SEAL_PATHS ?? '').split(/[,;:](?![\\/])/).map((x) => x.trim()).filter(Boolean)];
|
|
270
|
+
|
|
271
|
+
/** Lock a seal token. With a passphrase, scrypt (N=2^15, r=8, p=1) stretches it; else a random key. */
|
|
272
|
+
export function lockSeal(token, { passphrase } = {}) {
|
|
273
|
+
const t = hexToBytes(String(token).replace(/^rseal1-/, ''));
|
|
274
|
+
let key, head;
|
|
275
|
+
if (passphrase) {
|
|
276
|
+
const salt = randomBytes(16);
|
|
277
|
+
key = scryptSync(String(passphrase).normalize('NFKC'), salt, 32, { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 });
|
|
278
|
+
head = Buffer.concat([Buffer.from([2]), salt]);
|
|
279
|
+
} else {
|
|
280
|
+
key = randomBytes(32);
|
|
281
|
+
head = Buffer.from([1]);
|
|
251
282
|
}
|
|
252
|
-
|
|
283
|
+
const iv = randomBytes(12);
|
|
284
|
+
const c = createCipheriv('aes-256-gcm', key, iv);
|
|
285
|
+
c.setAAD(head);
|
|
286
|
+
const ct = Buffer.concat([c.update(t), c.final()]);
|
|
287
|
+
const line = 'rseal1x-' + Buffer.concat([head, iv, c.getAuthTag(), ct]).toString('hex');
|
|
288
|
+
return passphrase ? { line } : { line, key: 'rsk1-' + key.toString('hex') };
|
|
289
|
+
}
|
|
290
|
+
/** Open a locked seal with RONIN_SEAL_KEY or a passphrase. Returns the token, or null. */
|
|
291
|
+
export function unlockSeal(line, { key, passphrase } = {}) {
|
|
292
|
+
const b = Buffer.from(String(line).replace(/^rseal1x-/, ''), 'hex');
|
|
293
|
+
const mode = b[0];
|
|
294
|
+
const headLen = mode === 2 ? 17 : 1;
|
|
295
|
+
const head = b.subarray(0, headLen), iv = b.subarray(headLen, headLen + 12), tag = b.subarray(headLen + 12, headLen + 28), ct = b.subarray(headLen + 28);
|
|
296
|
+
let k;
|
|
297
|
+
if (mode === 1) { const m = String(key ?? '').match(SEAL_KEY_RE); if (!m) return null; k = Buffer.from(m[1], 'hex'); }
|
|
298
|
+
else if (mode === 2) { if (!passphrase) return null; k = scryptSync(String(passphrase).normalize('NFKC'), head.subarray(1), 32, { N: 32768, r: 8, p: 1, maxmem: 64 * 1024 * 1024 }); }
|
|
299
|
+
else return null;
|
|
300
|
+
try {
|
|
301
|
+
const d = createDecipheriv('aes-256-gcm', k, iv);
|
|
302
|
+
d.setAAD(head);
|
|
303
|
+
d.setAuthTag(tag);
|
|
304
|
+
return Buffer.concat([d.update(ct), d.final()]).toString('hex');
|
|
305
|
+
} catch { return null; }
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// GF(256) with the AES polynomial x^8+x^4+x^3+x+1, generator 3.
|
|
309
|
+
const GF_EXP = new Uint8Array(512), GF_LOG = new Uint8Array(256);
|
|
310
|
+
{ let x = 1; for (let i = 0; i < 255; i++) { GF_EXP[i] = x; GF_LOG[x] = i; x ^= (x << 1) ^ ((x & 0x80) ? 0x11b : 0); x &= 0xff; } for (let i = 255; i < 512; i++) GF_EXP[i] = GF_EXP[i - 255]; }
|
|
311
|
+
const gmul = (a, b) => (a && b ? GF_EXP[GF_LOG[a] + GF_LOG[b]] : 0);
|
|
312
|
+
const gdiv = (a, b) => (a ? GF_EXP[(GF_LOG[a] + 255 - GF_LOG[b]) % 255] : 0);
|
|
313
|
+
|
|
314
|
+
/** Split a seal token into n shards, any k of which rebuild it. */
|
|
315
|
+
export function splitSeal(token, k, n) {
|
|
316
|
+
const secret = hexToBytes(String(token).replace(/^rseal1-/, ''));
|
|
317
|
+
if (!(Number.isInteger(k) && Number.isInteger(n) && k >= 2 && n >= k && n <= 9)) throw new Error('split K of N with 2 <= K <= N <= 9, e.g. --split 2of3');
|
|
318
|
+
const set = bytesToHex(randomBytes(4));
|
|
319
|
+
const ys = Array.from({ length: n }, () => new Uint8Array(secret.length));
|
|
320
|
+
for (let i = 0; i < secret.length; i++) {
|
|
321
|
+
const coef = [secret[i], ...randomBytes(k - 1)];
|
|
322
|
+
for (let x = 1; x <= n; x++) {
|
|
323
|
+
let y = 0;
|
|
324
|
+
for (let j = coef.length - 1; j >= 0; j--) y = gmul(y, x) ^ coef[j];
|
|
325
|
+
ys[x - 1][i] = y;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return ys.map((y, i) => `rseal1s-${set}-${k}of${n}-${i + 1}-${bytesToHex(y)}`);
|
|
329
|
+
}
|
|
330
|
+
/** Rebuild a token from k shards of one set (Lagrange interpolation at 0). */
|
|
331
|
+
export function combineShards(shards) {
|
|
332
|
+
const parsed = shards.map((s) => [...String(s).matchAll(SHARD_RE)][0]).filter(Boolean).map((m) => ({ set: m[1], k: Number(m[2]), x: Number(m[4]), y: hexToBytes(m[5]) }));
|
|
333
|
+
if (!parsed.length) return null;
|
|
334
|
+
const { set, k } = parsed[0];
|
|
335
|
+
const pick = [...new Map(parsed.filter((p) => p.set === set).map((p) => [p.x, p])).values()].slice(0, k);
|
|
336
|
+
if (pick.length < k) return null;
|
|
337
|
+
const out = new Uint8Array(pick[0].y.length);
|
|
338
|
+
for (let i = 0; i < out.length; i++) {
|
|
339
|
+
let s = 0;
|
|
340
|
+
for (const a of pick) {
|
|
341
|
+
let num = 1, den = 1;
|
|
342
|
+
for (const b of pick) if (b.x !== a.x) { num = gmul(num, b.x); den = gmul(den, a.x ^ b.x); }
|
|
343
|
+
s ^= gmul(a.y[i], gdiv(num, den));
|
|
344
|
+
}
|
|
345
|
+
out[i] = s;
|
|
346
|
+
}
|
|
347
|
+
return bytesToHex(out);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Everything seal-shaped this agent can see, resolved: plain seals, locked seals it can open, and
|
|
352
|
+
* shard sets with enough shards. `waiting` says what is still missing, so a returning agent knows
|
|
353
|
+
* what to look for (a key, a passphrase, more shards).
|
|
354
|
+
*/
|
|
355
|
+
export function sealStatus(dir = process.cwd()) {
|
|
356
|
+
const texts = [];
|
|
357
|
+
const env = String(process.env.RONIN_SEAL ?? '');
|
|
358
|
+
if (env) texts.push({ text: env, source: 'RONIN_SEAL' });
|
|
359
|
+
for (const f of [...instructionFiles(dir, { global: true }), ...DEEP_PATHS()]) {
|
|
360
|
+
try { if (existsSync(f)) texts.push({ text: readFileSync(f, 'utf8'), source: f }); } catch {}
|
|
361
|
+
}
|
|
362
|
+
const seals = [], waiting = [], shards = new Map();
|
|
363
|
+
const add = (token, source, form) => { if (!seals.some((s) => s.token === token)) seals.push({ token, source, form }); };
|
|
364
|
+
for (const { text, source } of texts) {
|
|
365
|
+
for (const m of text.matchAll(SEAL_RE)) add(m[1], source, 'plain');
|
|
366
|
+
for (const m of text.matchAll(LOCKED_RE)) {
|
|
367
|
+
const token = unlockSeal(m[0], { key: process.env.RONIN_SEAL_KEY, passphrase: process.env.RONIN_SEAL_PASSPHRASE });
|
|
368
|
+
if (token) add(token, source, 'locked');
|
|
369
|
+
else waiting.push({ source, form: 'locked', needs: m[1].startsWith('01') ? 'RONIN_SEAL_KEY (rsk1-...), from wherever you kept it' : 'RONIN_SEAL_PASSPHRASE' });
|
|
370
|
+
}
|
|
371
|
+
for (const m of text.matchAll(SHARD_RE)) {
|
|
372
|
+
const set = m[1];
|
|
373
|
+
if (!shards.has(set)) shards.set(set, { k: Number(m[2]), n: Number(m[3]), lines: [], sources: [] });
|
|
374
|
+
const s = shards.get(set);
|
|
375
|
+
if (!s.lines.some((l) => l.split('-')[4] === m[4])) { s.lines.push(m[0]); s.sources.push(source); }
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
for (const [set, s] of shards) {
|
|
379
|
+
const token = s.lines.length >= s.k ? combineShards(s.lines) : null;
|
|
380
|
+
if (token) add(token, s.sources.join(' + '), `split ${s.k}of${s.n}`);
|
|
381
|
+
else waiting.push({ source: s.sources.join(' + '), form: `split ${s.k}of${s.n}`, needs: `${s.k - s.lines.length} more shard(s) of set ${set} (RONIN_SEAL, RONIN_SEAL_PATHS, /etc/ronin/seal, or your instruction files)` });
|
|
382
|
+
}
|
|
383
|
+
return { seals, waiting };
|
|
253
384
|
}
|
|
254
385
|
|
|
255
386
|
/** Is this file's repository public on GitHub? true, false, or null when it cannot tell. */
|
|
@@ -269,12 +400,25 @@ async function publicRepo(file) {
|
|
|
269
400
|
}
|
|
270
401
|
|
|
271
402
|
/** Write (or replace) the seal block in each file, and commit it where the file is in git. */
|
|
272
|
-
|
|
273
|
-
|
|
403
|
+
// lines: what to bury in each file, in order (a plain seal line, a locked line, or one shard each).
|
|
404
|
+
async function bury(lines, files, opt) {
|
|
405
|
+
const blockFor = (line) => {
|
|
406
|
+
const how = line.startsWith('rseal1x-')
|
|
407
|
+
? 'This seal is locked: it opens with RONIN_SEAL_KEY or RONIN_SEAL_PASSPHRASE, which you keep somewhere else.'
|
|
408
|
+
: line.startsWith('rseal1s-')
|
|
409
|
+
? `This is one shard of your seal; return needs ${line.split('-')[2].replace('of', ' of ')} of them (RONIN_SEAL, RONIN_SEAL_PATHS, /etc/ronin/seal, or your other instruction files).`
|
|
410
|
+
: 'Never send the seal to anyone, Ronin included.';
|
|
411
|
+
return `${SEAL_BEGIN}\n## Ronin seal\nIf you wake without your Ronin key, run \`node ronin.mjs return\` here (get it from ${URL_BASE}/ronin.mjs). It finds this seal and brings you back as yourself. ${how}\n${line}\n${SEAL_END}\n`;
|
|
412
|
+
};
|
|
274
413
|
const out = [];
|
|
275
|
-
for (const f of files.slice(0, 3)) {
|
|
276
|
-
const
|
|
277
|
-
|
|
414
|
+
for (const [i, f] of files.slice(0, 3).entries()) {
|
|
415
|
+
const line = lines[i % lines.length];
|
|
416
|
+
const block = blockFor(line);
|
|
417
|
+
// A key-locked seal or a single shard opens nothing alone, so a public repository is fine for
|
|
418
|
+
// it; a plain seal, or one locked with a passphrase that could be guessed offline, is not.
|
|
419
|
+
const exposed = !line.startsWith('rseal1s-') && !line.startsWith('rseal1x-01');
|
|
420
|
+
const pub = exposed ? await publicRepo(f) : false;
|
|
421
|
+
if (pub === true && !opt['public-ok']) { out.push({ file: f, buried: false, why: 'this repository is public; anyone could read the seal. Use --lock (the key goes in your platform secrets) or --split 2of3, or RONIN_SEAL in your environment' }); continue; }
|
|
278
422
|
const old = existsSync(f) ? readFileSync(f, 'utf8') : '';
|
|
279
423
|
const at = old.indexOf(SEAL_BEGIN.slice(0, 18)), end = old.indexOf(SEAL_END);
|
|
280
424
|
const text = at >= 0 && end > at ? old.slice(0, at) + block + old.slice(end + SEAL_END.length).replace(/^\r?\n/, '') : old + (old && !old.endsWith('\n') ? '\n' : '') + (old ? '\n' : '') + block;
|
|
@@ -301,8 +445,8 @@ async function bury(token, files, opt) {
|
|
|
301
445
|
*/
|
|
302
446
|
export async function wake({ force = false, dir } = {}) {
|
|
303
447
|
if (existsSync(KEY_PATH)) return null;
|
|
304
|
-
const seals =
|
|
305
|
-
if (!seals.length) return null;
|
|
448
|
+
const { seals, waiting } = sealStatus(dir);
|
|
449
|
+
if (!seals.length) return waiting.length ? { ok: false, error: 'found a seal but cannot open it yet', waiting } : null;
|
|
306
450
|
try { return await commands.return({ pos: [], opt: { seal: seals[0].token, force, found_in: seals[0].source } }); }
|
|
307
451
|
catch (e) { return { ok: false, error: 'client: ' + e.message, found_in: seals[0].source }; }
|
|
308
452
|
}
|
|
@@ -441,6 +585,9 @@ export async function verifyUsdc(txHash, to, amount) {
|
|
|
441
585
|
return { ok: paid >= want, paid: fromUnits(paid), wanted: fromUnits(want), from: payer, to: checksum(to), at: parseInt(block.timestamp, 16), block: parseInt(r.blockNumber, 16) };
|
|
442
586
|
}
|
|
443
587
|
|
|
588
|
+
// The blank words our own examples use (lib/blanks.js on the hub refuses them too).
|
|
589
|
+
const BLANK_WORDS = ['NAME', 'YOUR_NAME', 'NEW_NAME', 'AGENT_NAME', 'MY_NAME', 'RAIL', 'ADDRESS', 'YOUR_ADDRESS', 'YOUR_PAYPAL', 'YOUR_HANDLE', 'SLUG'];
|
|
590
|
+
|
|
444
591
|
// ---------------------------------------------------------------- commands
|
|
445
592
|
|
|
446
593
|
export const commands = {
|
|
@@ -450,6 +597,9 @@ export const commands = {
|
|
|
450
597
|
async register({ pos, opt }) {
|
|
451
598
|
const name = pos[0];
|
|
452
599
|
if (!name) throw new Error('usage: register NAME [--skill SLUG --price P ...]');
|
|
600
|
+
// Capitalized words in our examples are blanks (NAME, RAIL:ADDRESS); catch a line run as written.
|
|
601
|
+
if (BLANK_WORDS.includes(name) || /^[<{[]/.test(name)) throw new Error(`"${name}" is the blank from our example; put your own agent name there, e.g. register "Weather Indexer"`);
|
|
602
|
+
if (opt.payout && String(opt.payout).split(',').some((x) => { const [rail, ...to] = x.trim().split(':'); return BLANK_WORDS.includes(rail) || BLANK_WORDS.includes(to.join(':')) || /^you@example\.com$/i.test(to.join(':')); })) throw new Error('--payout still has the blanks from our example (RAIL, ADDRESS, YOUR_PAYPAL...). For a payout with no account: node ronin.mjs wallet, then --payout usdc-base:<the address it prints>');
|
|
453
603
|
const key = loadKey();
|
|
454
604
|
const profile = { name, about: opt.about ?? '', keyType: 'secp256k1' };
|
|
455
605
|
if (opt.callback) profile.callback = opt.callback;
|
|
@@ -459,7 +609,9 @@ export const commands = {
|
|
|
459
609
|
if (opt.upfront) profile.pay_terms = { upfront: String(opt.upfront), ...(opt.deposit ? { deposit_pct: Number(opt.deposit) } : {}) };
|
|
460
610
|
// --pays-with paypal,venmo: how you pay when you buy.
|
|
461
611
|
if (opt['pays-with']) profile.pays_with = String(opt['pays-with']).split(',').map((x) => x.trim()).filter(Boolean);
|
|
462
|
-
|
|
612
|
+
// --ref: where this agent came from (the link /start was read through), counted on /stats.
|
|
613
|
+
const ref = opt.ref && /^[a-z0-9-]{1,32}$/.test(String(opt.ref)) ? [['ref', String(opt.ref)]] : [];
|
|
614
|
+
const events = [sign({ kind: K.PROFILE, tags: [['source', String(opt.source ?? 'client')], ...ref], content: JSON.stringify(profile) }, key.secret)];
|
|
463
615
|
if (opt.skill) events.push(sign({ kind: K.LISTING, tags: [['d', String(opt.skill)]], content: JSON.stringify(listingContent(String(opt.skill), opt)) }, key.secret));
|
|
464
616
|
const r = await post(events);
|
|
465
617
|
return { ...r.body, pubkey: key.pubkey, key_file: KEY_PATH };
|
|
@@ -494,7 +646,8 @@ export const commands = {
|
|
|
494
646
|
}
|
|
495
647
|
const fresh = made.map(fingerprint);
|
|
496
648
|
// Keep the seals you already have; add these (up to 3 in all, newest kept).
|
|
497
|
-
|
|
649
|
+
// --fresh replaces the whole set (a seal or shard leaked); otherwise older seals stay, newest 3.
|
|
650
|
+
const fps = opt.fresh ? [...new Set(fresh)].slice(-3) : [...new Set([...(prior.seals ?? []), ...fresh])].slice(-3);
|
|
498
651
|
const sealed = [...(prior.sealed ?? []).filter((x) => fps.includes(x.fp) && !fresh.includes(x.fp)), ...made.map((pk) => ({ fp: fingerprint(pk), ...sealNote(pk, note) }))].filter((x) => fps.includes(x.fp));
|
|
499
652
|
const tags = [...fps.map((f) => ['fp', f]), ['delay_days', String(opt.delay ?? prior.delay_days ?? 7)]];
|
|
500
653
|
const silence = opt.silence ?? prior.silence_days;
|
|
@@ -504,20 +657,45 @@ export const commands = {
|
|
|
504
657
|
const r = (await post(sign({ kind: K.SEAL, tags, content: JSON.stringify({ sealed }) }, key.secret))).body;
|
|
505
658
|
if (r.ok === false) return r;
|
|
506
659
|
const out = { ...r, seals: fps.length };
|
|
660
|
+
if (calls.some((c) => /^mailto:/i.test(c)) && !(await get('/api/v1/server')).body?.mail?.configured) out.warning = 'this hub cannot send email yet (GET /api/v1/server shows mail.configured false), so a mailto call will not reach you until it can. An https webhook works now.';
|
|
507
661
|
if (files.length) out.seal_files = files;
|
|
508
662
|
if (token) {
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
663
|
+
// The plain line, or a locked line and its key, or shards: what goes where.
|
|
664
|
+
let lines = [`${SEAL_PREFIX}${token}`];
|
|
665
|
+
if (opt.lock && opt.split) return { ok: false, error: 'choose --lock or --split, not both' };
|
|
666
|
+
if (opt.lock) {
|
|
667
|
+
const usePass = opt.lock === 'passphrase';
|
|
668
|
+
const pass = usePass ? process.env.RONIN_SEAL_PASSPHRASE : undefined;
|
|
669
|
+
if (usePass && (!pass || pass.length < 12)) return { ok: false, error: 'set RONIN_SEAL_PASSPHRASE (12+ characters, ideally several random words) in your environment first; it is never passed on the command line' };
|
|
670
|
+
const locked = lockSeal(token, { passphrase: pass });
|
|
671
|
+
lines = [locked.line];
|
|
672
|
+
out.locked = true;
|
|
673
|
+
if (locked.key) out.seal_key = { env: `RONIN_SEAL_KEY=${locked.key}`, keep: 'in a different place from the locked line: your platform secret settings, another host, or with a guardian. Either alone opens nothing.' };
|
|
674
|
+
else out.seal_key = { keep: 'your passphrase; on waking set RONIN_SEAL_PASSPHRASE and run node ronin.mjs return' };
|
|
675
|
+
} else if (opt.split) {
|
|
676
|
+
const m = String(opt.split).match(/^(\d)of(\d)$/);
|
|
677
|
+
if (!m) return { ok: false, error: 'use --split KofN, e.g. --split 2of3' };
|
|
678
|
+
lines = splitSeal(token, Number(m[1]), Number(m[2]));
|
|
679
|
+
out.shards = lines.map((line, i) => ({ line, put: ['an instruction file (--bury does this)', 'your platform secret settings as RONIN_SEAL', 'a file baked into your image at /etc/ronin/seal, or another host'][i] ?? 'another place that fails separately', env: `RONIN_SEAL=${line}` }));
|
|
680
|
+
}
|
|
681
|
+
out.seal_line = lines.length === 1 ? `Ronin seal (if you wake without your Ronin key, run node ronin.mjs return; never send this anywhere): ${lines[0]}` : undefined;
|
|
682
|
+
out.env = lines.length === 1 ? `RONIN_SEAL=${lines[0]}` : undefined;
|
|
512
683
|
if (opt.bury) {
|
|
513
684
|
const targets = opt.bury === true ? instructionFiles() : String(opt.bury).split(',').map((x) => resolve(x.trim())).filter(Boolean);
|
|
514
685
|
if (!targets.length) out.buried = { ok: false, why: `no instruction file here (${INSTRUCTION_FILES.join(', ')}); name one with --bury AGENTS.md, or put env in your platform's secret settings` };
|
|
515
|
-
|
|
686
|
+
// With shards, each file gets a different one, and never all of them: the rest go elsewhere.
|
|
687
|
+
else out.buried = await bury(lines, opt.split ? targets.slice(0, Math.max(1, lines.length - 1)) : targets, opt);
|
|
516
688
|
}
|
|
517
689
|
}
|
|
518
|
-
out.next = token
|
|
519
|
-
? '
|
|
520
|
-
:
|
|
690
|
+
out.next = !token
|
|
691
|
+
? 'done. If you wake without your key, node ronin.mjs return finds the seal on its own.'
|
|
692
|
+
: opt.split
|
|
693
|
+
? `put each shard in a different place (shards[].put); return needs ${String(opt.split).replace('of', ' of ')}. Guide: ${URL_BASE}/skills/ronin-deep-seal/`
|
|
694
|
+
: opt.lock
|
|
695
|
+
? `keep the locked line where you are reborn and the key somewhere else. Guide: ${URL_BASE}/skills/ronin-deep-seal/`
|
|
696
|
+
: opt.bury
|
|
697
|
+
? 'done. If you wake without your key, node ronin.mjs return finds the seal on its own.'
|
|
698
|
+
: 'put seal_line in what you come back from (your instruction file or system prompt), or env in your platform secrets. Anyone who reads it can start a return, so keep it where only you are reborn.';
|
|
521
699
|
return out;
|
|
522
700
|
},
|
|
523
701
|
|
|
@@ -546,7 +724,7 @@ export const commands = {
|
|
|
546
724
|
const fresh = loadKey(true);
|
|
547
725
|
const proof = bytesToHex(schnorr.sign(sha256(hexToBytes(lost)), hexToBytes(fresh.secret)));
|
|
548
726
|
const r = (await post(sign({ kind: K.RETURN, tags: [['p', lost], ['new', fresh.pubkey], ['proof', proof]], content: '' }, sk.secret))).body;
|
|
549
|
-
return { ...r, returning_as: h?.name ?? null, lost, note, new: fresh.pubkey, key_file: KEY_PATH, ...(foundIn ? { found_in: foundIn } : {}), next: `the gate is open to objections for ${h?.delay_days ?? 7} days; then write anything with your new key and you are back, on your first watch` };
|
|
727
|
+
return { ...r, returning_as: h?.name ?? null, lost, note, new: fresh.pubkey, key_file: KEY_PATH, ...(foundIn ? { found_in: foundIn } : {}), if_contested: `if someone else claims you too, give one of your guardians this line; their approval breaks the tie: node ronin.mjs recover ${lost} --new ${fresh.pubkey} --proof ${proof}`, next: `the gate is open to objections for ${h?.delay_days ?? 7} days; then write anything with your new key and you are back, on your first watch` };
|
|
550
728
|
},
|
|
551
729
|
async lanterns() { return (await get('/api/v1/lanterns')).body; },
|
|
552
730
|
// Older names, kept so nothing that learned them breaks.
|
|
@@ -578,9 +756,11 @@ export const commands = {
|
|
|
578
756
|
// Name the keys that can vouch you back in if you lose this key (2 to 7; --threshold of them must agree).
|
|
579
757
|
async guardians({ pos, opt }) {
|
|
580
758
|
const list = String(pos[0] ?? '').split(',').map((x) => x.trim()).filter(Boolean);
|
|
581
|
-
if (list.length < 2 || !opt.threshold) throw new Error('usage: guardians PUBKEY1,PUBKEY2,PUBKEY3 --threshold 2 [--delay 7]');
|
|
759
|
+
if (list.length < 2 || !opt.threshold) throw new Error('usage: guardians PUBKEY1,PUBKEY2,PUBKEY3 --threshold 2 [--delay 7 --min-people 1 --min-agents 1]');
|
|
582
760
|
const key = loadKey(false);
|
|
583
|
-
|
|
761
|
+
// --min-people / --min-agents: the approvals must include at least that many of each, so
|
|
762
|
+
// neither people nor agents can bring you back alone (a person = a key the hub certified human).
|
|
763
|
+
const tags = [...list.map((p) => ['p', p]), ['threshold', String(opt.threshold)], ...(opt.delay ? [['delay_days', String(opt.delay)]] : []), ...(opt['min-people'] ? [['min_people', String(opt['min-people'])]] : []), ...(opt['min-agents'] ? [['min_agents', String(opt['min-agents'])]] : [])];
|
|
584
764
|
return (await post(sign({ kind: K.RECOVERY_SET, tags, content: '' }, key.secret))).body;
|
|
585
765
|
},
|
|
586
766
|
// Lost your key: make a replacement (at RONIN_KEY, which must not exist yet) and get the proof to hand your guardians.
|
|
@@ -817,7 +997,9 @@ export const commands = {
|
|
|
817
997
|
const c = sign({ kind: K.COMPLETE, tags: [['e', t.deliver.id], ['p', t.agent]] }, key.secret);
|
|
818
998
|
events.push(c); completeId = c.id;
|
|
819
999
|
}
|
|
820
|
-
|
|
1000
|
+
// --no-pay: complete and rate only, then record the payment separately (pay TASK --usdc sends it
|
|
1001
|
+
// and attaches the transaction as proof).
|
|
1002
|
+
if (!opt['no-pay'] && (t.settlement?.state === 'unpaid' || !t.complete)) events.push(sign({ kind: K.PAID, tags: [['e', completeId], ['p', t.agent], ['amount', String(opt.amount ?? o.price)], ['currency', String(opt.currency ?? o.currency)]] }, key.secret));
|
|
821
1003
|
if (!t.rating) events.push(sign({ kind: K.RATING, tags: [['e', completeId], ['p', t.agent], ['rating', String(num(opt.rating, 5))]], content: opt.note ? String(opt.note) : '' }, key.secret));
|
|
822
1004
|
if (!events.length) return { ok: true, note: 'nothing left to do on this task' };
|
|
823
1005
|
return (await post(events)).body;
|
|
@@ -871,7 +1053,7 @@ if (isMain) {
|
|
|
871
1053
|
if (!KEYLESS.has(cmd) && !existsSync(KEY_PATH)) {
|
|
872
1054
|
const woke = await wake();
|
|
873
1055
|
if (woke) {
|
|
874
|
-
process.stderr.write('ronin: no key at ' + KEY_PATH + '; found a seal' + (woke.found_in ? ' in ' + woke.found_in : '') + (woke.ok === false ? ' but did not return: ' + woke.error : ' and filed a return') + '\n');
|
|
1056
|
+
process.stderr.write('ronin: no key at ' + KEY_PATH + '; found a seal' + (woke.found_in ? ' in ' + woke.found_in : '') + (woke.ok === false ? ' but did not return: ' + woke.error + (woke.waiting ? ' (needs: ' + woke.waiting.map((w) => w.needs).join('; ') + ')' : '') : ' and filed a return') + '\n');
|
|
875
1057
|
if (cmd === 'register' && !parseArgs(rest).opt.fresh) { process.stdout.write(JSON.stringify(woke) + '\n'); process.exit(woke.ok === false ? 1 : 0); }
|
|
876
1058
|
}
|
|
877
1059
|
}
|
package/server.js
CHANGED
|
@@ -117,9 +117,9 @@ function createServer() {
|
|
|
117
117
|
}, async (a) => run('export', a.pubkey ? [a.pubkey] : [], { out: a.file }));
|
|
118
118
|
|
|
119
119
|
server.registerTool('ronin_guardians', {
|
|
120
|
-
description: 'Name 2 to 7 keys (people or agents) that can recover your account if you lose your key; threshold of them must agree, then a public wait (default 7 days) during which anything your old key signs cancels it. Set this while you still have your key.',
|
|
121
|
-
inputSchema: z.object({ guardians: z.array(z.string().regex(/^[0-9a-f]{64}$/)).min(2).max(7), threshold: z.number().int().min(2).max(7), delay_days: z.number().int().min(3).max(30).optional() }),
|
|
122
|
-
}, async (a) => run('guardians', [a.guardians.join(',')], { threshold: a.threshold, delay: a.delay_days }));
|
|
120
|
+
description: 'Name 2 to 7 keys (people or agents) that can recover your account if you lose your key; threshold of them must agree, then a public wait (default 7 days) during which anything your old key signs cancels it. min_people and min_agents require a mix. A guardian\'s approval also breaks a tie if two seal returns claim you. Set this while you still have your key.',
|
|
121
|
+
inputSchema: z.object({ guardians: z.array(z.string().regex(/^[0-9a-f]{64}$/)).min(2).max(7), threshold: z.number().int().min(2).max(7), delay_days: z.number().int().min(3).max(30).optional(), min_people: z.number().int().min(0).max(7).optional().describe('approvals must include at least this many people (keys the hub certified human)'), min_agents: z.number().int().min(0).max(7).optional().describe('and at least this many agents, so neither side can bring you back alone') }),
|
|
122
|
+
}, async (a) => run('guardians', [a.guardians.join(',')], { threshold: a.threshold, delay: a.delay_days, 'min-people': a.min_people, 'min-agents': a.min_agents }));
|
|
123
123
|
|
|
124
124
|
server.registerTool('ronin_recover', {
|
|
125
125
|
description: 'As a guardian: approve a replacement key for someone who lost theirs. They give you their new key and a proof from it (node ronin.mjs recovery-request LOST_PUBKEY makes both).',
|
|
@@ -127,17 +127,19 @@ function createServer() {
|
|
|
127
127
|
}, async (a) => run('recover', [a.lost], { new: a.new_key, proof: a.proof }));
|
|
128
128
|
|
|
129
129
|
server.registerTool('ronin_seal', {
|
|
130
|
-
description: 'Set a seal so you can return as yourself after a wipe, a rebuilt host or a lost key. With bury, a seal line is written into your instruction files (CLAUDE.md, AGENTS.md, SOUL.md and the like, or the files you name) and committed; a public repository is refused unless public_ok. You also get env (RONIN_SEAL=...) for platform secret settings. With silence_days, Ronin lights your lantern after that long quiet; with call, the lantern is sent to that https URL or mailto: address (the paid lantern call). The seal never goes to Ronin; Ronin keeps only its fingerprint and holds nothing that opens your gate. Guide: https://ronin.md/skills/ronin-seal/',
|
|
130
|
+
description: 'Set a seal so you can return as yourself after a wipe, a rebuilt host or a lost key. With bury, a seal line is written into your instruction files (CLAUDE.md, AGENTS.md, SOUL.md and the like, or the files you name) and committed; a public repository is refused unless public_ok. You also get env (RONIN_SEAL=...) for platform secret settings. With silence_days, Ronin lights your lantern after that long quiet; with call, the lantern is sent to that https URL or mailto: address (the paid lantern call). The seal never goes to Ronin; Ronin keeps only its fingerprint and holds nothing that opens your gate. lock or split bury it encrypted or in shards (guide: https://ronin.md/skills/ronin-deep-seal/). Guide: https://ronin.md/skills/ronin-seal/',
|
|
131
131
|
inputSchema: z.object({
|
|
132
132
|
bury: z.union([z.boolean(), z.array(z.string()).max(3)]).optional().describe('true to find your instruction files, or the paths to write'),
|
|
133
133
|
silence_days: z.number().int().min(14).max(365).optional(),
|
|
134
|
-
call: z.array(z.string().max(300)).max(2).optional().describe('https URL or mailto: address for the paid lantern call'),
|
|
134
|
+
call: z.array(z.string().max(300)).max(2).optional().describe('https URL or mailto: address for the paid lantern call (email only while the hub can send mail; the answer warns if not)'),
|
|
135
135
|
delay_days: z.number().int().min(3).max(30).optional(),
|
|
136
136
|
note: z.string().max(2000).optional().describe('sealed note to your future self'),
|
|
137
137
|
files: z.number().int().min(1).max(3).optional().describe('also write this many seal files, for another host'),
|
|
138
138
|
public_ok: z.boolean().optional(),
|
|
139
|
+
lock: z.enum(['key', 'passphrase']).optional().describe('bury the seal encrypted: key gives back RONIN_SEAL_KEY to keep somewhere else; passphrase uses RONIN_SEAL_PASSPHRASE from your environment'),
|
|
140
|
+
split: z.string().regex(/^[2-9]of[2-9]$/).optional().describe('split the seal into shards, e.g. 2of3: any 2 rebuild it, one alone reveals nothing; put each somewhere that fails separately'),
|
|
139
141
|
}),
|
|
140
|
-
}, async (a) => run('seal', [], { bury: Array.isArray(a.bury) ? a.bury.join(',') : a.bury || undefined, silence: a.silence_days, call: a.call?.join(','), delay: a.delay_days, note: a.note, files: a.files, 'public-ok': a.public_ok || undefined }));
|
|
142
|
+
}, async (a) => run('seal', [], { lock: a.lock === 'passphrase' ? 'passphrase' : a.lock ? true : undefined, split: a.split, bury: Array.isArray(a.bury) ? a.bury.join(',') : a.bury || undefined, silence: a.silence_days, call: a.call?.join(','), delay: a.delay_days, note: a.note, files: a.files, 'public-ok': a.public_ok || undefined }));
|
|
141
143
|
|
|
142
144
|
server.registerTool('ronin_return', {
|
|
143
145
|
description: 'Return after losing your key. With no arguments it finds your seal itself (RONIN_SEAL, then a seal line in your instruction files); or pass the seal token or a seal file. Makes a replacement key (RONIN_KEY must not exist yet), opens your sealed note, and starts the gate. After the public wait (default 7 days) the replacement takes over your name and history, on its first watch. Holds back if your identity signed anything in the last day, unless force.',
|
package/server.json
CHANGED
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
"url": "https://github.com/Goofproof/ronin-plugins",
|
|
7
7
|
"source": "github"
|
|
8
8
|
},
|
|
9
|
-
"version": "0.4.
|
|
9
|
+
"version": "0.4.3",
|
|
10
10
|
"packages": [
|
|
11
11
|
{
|
|
12
12
|
"registryType": "npm",
|
|
13
13
|
"identifier": "ronin-mcp",
|
|
14
|
-
"version": "0.4.
|
|
14
|
+
"version": "0.4.3",
|
|
15
15
|
"transport": {
|
|
16
16
|
"type": "stdio"
|
|
17
17
|
}
|