@clanker-chain/clanker-cli 2026.9.8-2 → 2026.9.10

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/bin/clanker.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  import { spawnSync } from "node:child_process";
4
4
  import { existsSync, mkdirSync } from "node:fs";
5
5
  import { dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
6
7
  import os from "node:os";
7
8
  import process from "node:process";
8
9
  import { getAddress } from "viem";
@@ -97,8 +98,15 @@ function runScript(scriptPath, args = []) {
97
98
  }
98
99
 
99
100
  function findRepoRoot() {
100
- const here = dirname(new URL(import.meta.url).pathname);
101
- return dirname(dirname(here));
101
+ let dir = dirname(fileURLToPath(import.meta.url));
102
+ for (let i = 0; i < 8; i++) {
103
+ if (existsSync(join(dir, "chain", "foundry.toml"))) return dir;
104
+ const parent = dirname(dir);
105
+ if (parent === dir) break;
106
+ dir = parent;
107
+ }
108
+ // npm install: package root (no monorepo checkout)
109
+ return dirname(dirname(fileURLToPath(import.meta.url)));
102
110
  }
103
111
 
104
112
  /** chain up/deploy and check * need the monorepo; npm installs only ship bin/ + lib/. */
@@ -169,6 +177,10 @@ Usage:
169
177
  clanker bot status <label> [--json]
170
178
  clanker bot revoke <label> [--json] [--yes]
171
179
  clanker bot rotate <label> <newKeyAddress> [--json] [--yes]
180
+ clanker pair add <operator-label> [--yes] [--auth-url URL] [--openclaw-home DIR]
181
+ clanker pair remove <operator-label> [--auth-url URL] [--openclaw-home DIR]
182
+ clanker pair list [--auth-url URL]
183
+ clanker pair status <operator-label> [--auth-url URL]
172
184
  clanker init-openclaw
173
185
  clanker chain up|deploy|mint-operator|mint-bot|rotate-bot-key|revoke-bot ...
174
186
  clanker check mqtt <bot_id> <operator_id>
@@ -180,10 +192,11 @@ Profile:
180
192
  ~/.clanker/keys/ bot keys (also written to ~/.openclaw/keys/)
181
193
 
182
194
  Humans: \`clanker setup\` then \`clanker doctor\` / \`whoami\`.
195
+ Pairing (Policy): both operators run \`clanker pair add\` before DMs deliver on the hub.
183
196
  Mutates print a plan and confirm unless --yes or --json.
184
197
  whoami is fast by default; pass --with-bots to enrich child bots (or use \`clanker bots\`).
185
198
 
186
- See docs/operator-cli.md.
199
+ See docs/operator-cli.md and docs/trust-model.md.
187
200
  `);
188
201
  }
189
202
 
@@ -441,6 +454,93 @@ async function main() {
441
454
  return;
442
455
  }
443
456
 
457
+ if (cmd === "pair") {
458
+ const [sub, ...pairRest] = rest;
459
+ if (!sub || !["add", "remove", "list", "status"].includes(sub)) {
460
+ console.error("Usage: clanker pair add|remove|list|status [operator-label]");
461
+ process.exit(1);
462
+ }
463
+
464
+ let peerLabel = null;
465
+ for (let i = 0; i < pairRest.length; i += 1) {
466
+ const a = pairRest[i];
467
+ if (a.startsWith("--")) {
468
+ if (a !== "--json" && a !== "--yes" && pairRest[i + 1] && !pairRest[i + 1].startsWith("--")) {
469
+ i += 1; // skip flag value
470
+ }
471
+ continue;
472
+ }
473
+ peerLabel = a;
474
+ break;
475
+ }
476
+
477
+ const { runPairAction } = await import("../lib/pair.mjs");
478
+ try {
479
+ if (sub === "add") {
480
+ const planRows = [
481
+ ["action", "pair add (Policy)"],
482
+ ["peer", peerLabel ?? "(missing)"],
483
+ ["note", "One-way allow; peer must pair you for bidirectional DMs"],
484
+ ];
485
+ const ok = await confirmPlan(pairRest, planRows, `Allow operator ${peerLabel}?`);
486
+ if (!ok) process.exit(0);
487
+ }
488
+ const out = await runPairAction(pairRest, sub, peerLabel);
489
+ if (hasFlag(pairRest, "--json")) {
490
+ console.log(JSON.stringify(out, null, 2));
491
+ } else if (sub === "list") {
492
+ console.log(`Operator: ${out.operatorId}`);
493
+ console.log(`Auth: ${out.authUrl}`);
494
+ const allows = out.listed?.allows ?? [];
495
+ if (!allows.length) {
496
+ console.log("(no allows)");
497
+ } else {
498
+ for (const a of allows) {
499
+ console.log(
500
+ ` ${a.peer_operator_id} ${a.mutual ? "mutual" : "pending (one-way)"}`,
501
+ );
502
+ }
503
+ }
504
+ } else if (sub === "status") {
505
+ console.log(`peer: ${out.peerLabel}`);
506
+ console.log(`allowed: ${out.allowed}`);
507
+ console.log(`mutual: ${out.mutual}`);
508
+ if (out.allowed && !out.mutual) {
509
+ console.log("Waiting for peer to run: clanker pair add <your-operator>");
510
+ }
511
+ } else {
512
+ console.log(
513
+ `${sub} ${out.peerLabel}: ${out.result?.mutual ? "mutual" : "one-way / pending"}`,
514
+ );
515
+ if (out.openclawSync?.synced) {
516
+ console.log(`Synced allowOperators → ${out.openclawSync.path}`);
517
+ console.log(
518
+ "Restart (or reload) the OpenClaw gateway so channels.mqtt.allowOperators is picked up.",
519
+ );
520
+ } else if (out.openclawSync?.reason === "missing_openclaw_json") {
521
+ console.log(
522
+ `Hub Policy updated, but no openclaw.json at ${out.openclawSync.path} — ` +
523
+ "client allowOperators was not synced. Create OpenClaw config or set allowOperators manually.",
524
+ );
525
+ }
526
+ if (sub === "add" && !out.result?.mutual) {
527
+ nextHint(`Ask ${out.peerLabel} to run: clanker pair add ${out.operatorId}`);
528
+ }
529
+ }
530
+ } catch (err) {
531
+ exitCliError({
532
+ error: err.message,
533
+ because: "pairing needs operator key + mqtt-auth /pair endpoints",
534
+ try: [
535
+ "clanker doctor",
536
+ "clanker pair add org.peer --auth-url http://127.0.0.1:9090 --yes",
537
+ "See docs/trust-model.md",
538
+ ],
539
+ });
540
+ }
541
+ return;
542
+ }
543
+
444
544
  if (cmd === "operator") {
445
545
  const [sub, ...opArgv] = rest;
446
546
  const {
@@ -935,7 +1035,7 @@ async function main() {
935
1035
  nextHint([
936
1036
  "clanker bot mint <label>",
937
1037
  "Then channels.mqtt.botId / operatorId / privateKeyFile update automatically",
938
- "See docs/operator-cli.md and docs/closed-beta-invite.md",
1038
+ "See docs/operator-cli.md and docs/public-testnet-hub.md",
939
1039
  ]);
940
1040
  process.exit(0);
941
1041
  }
@@ -13,7 +13,7 @@ import {
13
13
  } from "viem";
14
14
  import { clankerIdentityAbi } from "./clanker-identity-abi.mjs";
15
15
 
16
- /** Match identity-service EVM_LOG_CHUNK_BLOCKSpublic RPCs reject wide ranges. */
16
+ /** Public RPC eth_getLogs chunk size wide ranges are often rejected. */
17
17
  export const LOG_CHUNK_BLOCKS = 2000n;
18
18
 
19
19
  export function labelToId(label) {
@@ -8,7 +8,7 @@ import { join } from "node:path";
8
8
  import { harnessSnippet } from "./profile.mjs";
9
9
 
10
10
  /** Current published OpenClaw plugin pins for closed-beta invite. */
11
- export const OPENCLAW_PLUGIN_PIN = "2026.7.29";
11
+ export const OPENCLAW_PLUGIN_PIN = "2026.9.10";
12
12
 
13
13
  /**
14
14
  * @param {string} [openclawHome]
@@ -99,7 +99,7 @@ export function hubConnectChecklist(opts) {
99
99
  `openclaw plugins install @clanker-chain/mqtt-tools@${pin}`,
100
100
  "Enable plugin ids mqtt + mqtt-tools (already set in openclaw.json if we wired it)",
101
101
  `CONNECT broker: ${opts.channelsMqtt?.brokerUrl ?? "mqtts://mqtt.clanker-chain.com:8883"}`,
102
- `Ask hub operator to allow bot_id "${opts.botId}" in france dmPolicy / allowFrom`,
102
+ "Both operators: clanker pair add <peer-operator> (Policy required before DMs deliver)",
103
103
  "DM openclaw.france.prod-1 to smoke the mesh",
104
104
  ];
105
105
  }
package/lib/pair.mjs ADDED
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Operator pairing client (Policy layer — docs/trust-model.md).
3
+ * Signs with the operator owner key; never the bot key.
4
+ */
5
+
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { homedir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { privateKeyToAccount } from "viem/accounts";
10
+ import { loadOperator } from "./profile.mjs";
11
+ import { openclawConfigPath } from "./openclaw-wire.mjs";
12
+ import { resolveOperatorKey } from "./resolve.mjs";
13
+
14
+ /**
15
+ * @param {string} baseUrl
16
+ * @param {{ operatorId: string, action: 'add'|'remove'|'list', peerLabel?: string }} opts
17
+ */
18
+ export async function fetchPairNonce(baseUrl, opts) {
19
+ const url = new URL("/pair-nonce", baseUrl.replace(/\/$/, ""));
20
+ url.searchParams.set("operator_id", opts.operatorId);
21
+ url.searchParams.set("action", opts.action);
22
+ if (opts.peerLabel) url.searchParams.set("peer_label", opts.peerLabel);
23
+ const res = await fetch(url.toString());
24
+ const text = await res.text();
25
+ let body;
26
+ try {
27
+ body = JSON.parse(text);
28
+ } catch {
29
+ throw new Error(`pair-nonce: unexpected response: ${text}`);
30
+ }
31
+ if (!res.ok || !body.nonce || !body.message) {
32
+ throw new Error(body.error || `pair-nonce failed: HTTP ${res.status}`);
33
+ }
34
+ return /** @type {{ nonce: string, message: string, expires_at?: string }} */ (body);
35
+ }
36
+
37
+ /**
38
+ * @param {string} baseUrl
39
+ * @param {{
40
+ * operatorId: string,
41
+ * peerLabel: string,
42
+ * action: 'add'|'remove',
43
+ * nonce: string,
44
+ * signature: `0x${string}`,
45
+ * }} opts
46
+ */
47
+ export async function postPair(baseUrl, opts) {
48
+ const res = await fetch(new URL("/pair", baseUrl.replace(/\/$/, "")).toString(), {
49
+ method: "POST",
50
+ headers: { "content-type": "application/json" },
51
+ body: JSON.stringify({
52
+ operator_id: opts.operatorId,
53
+ peer_label: opts.peerLabel,
54
+ action: opts.action,
55
+ nonce: opts.nonce,
56
+ signature: opts.signature,
57
+ }),
58
+ });
59
+ const text = await res.text();
60
+ let body;
61
+ try {
62
+ body = JSON.parse(text);
63
+ } catch {
64
+ throw new Error(`pair: unexpected response: ${text}`);
65
+ }
66
+ if (!res.ok) {
67
+ throw new Error(body.error || `pair failed: HTTP ${res.status}`);
68
+ }
69
+ return body;
70
+ }
71
+
72
+ /**
73
+ * @param {string} baseUrl
74
+ * @param {{ operatorId: string, nonce: string, signature: `0x${string}` }} opts
75
+ */
76
+ export async function getPairList(baseUrl, opts) {
77
+ const url = new URL("/pair", baseUrl.replace(/\/$/, ""));
78
+ url.searchParams.set("operator_id", opts.operatorId);
79
+ url.searchParams.set("nonce", opts.nonce);
80
+ url.searchParams.set("signature", opts.signature);
81
+ const res = await fetch(url.toString());
82
+ const text = await res.text();
83
+ let body;
84
+ try {
85
+ body = JSON.parse(text);
86
+ } catch {
87
+ throw new Error(`pair list: unexpected response: ${text}`);
88
+ }
89
+ if (!res.ok) {
90
+ throw new Error(body.error || `pair list failed: HTTP ${res.status}`);
91
+ }
92
+ return body;
93
+ }
94
+
95
+ /**
96
+ * Merge allowOperators + dmPolicy into ~/.openclaw/openclaw.json channels.mqtt.
97
+ * @param {{ peerLabel: string, action: 'add'|'remove', openclawHome?: string }} opts
98
+ */
99
+ export function syncAllowOperators(opts) {
100
+ const openclawHome = opts.openclawHome ?? join(homedir(), ".openclaw");
101
+ const cfgPath = openclawConfigPath(openclawHome);
102
+ if (!existsSync(cfgPath)) {
103
+ return { synced: false, path: cfgPath, reason: "missing_openclaw_json" };
104
+ }
105
+ const cfg = JSON.parse(readFileSync(cfgPath, "utf8"));
106
+ const channels = { ...(cfg.channels ?? {}) };
107
+ const mqtt = { ...(channels.mqtt && typeof channels.mqtt === "object" ? channels.mqtt : {}) };
108
+ const prev = Array.isArray(mqtt.allowOperators)
109
+ ? mqtt.allowOperators.filter((x) => typeof x === "string")
110
+ : [];
111
+ let next;
112
+ if (opts.action === "add") {
113
+ next = prev.includes(opts.peerLabel) ? prev : [...prev, opts.peerLabel];
114
+ } else {
115
+ next = prev.filter((x) => x !== opts.peerLabel);
116
+ }
117
+ mqtt.allowOperators = next;
118
+ if (!mqtt.dmPolicy) mqtt.dmPolicy = "pairing";
119
+ channels.mqtt = mqtt;
120
+ cfg.channels = channels;
121
+ mkdirSync(openclawHome, { recursive: true });
122
+ writeFileSync(cfgPath, `${JSON.stringify(cfg, null, 2)}\n`, "utf8");
123
+ return { synced: true, path: cfgPath, allowOperators: next };
124
+ }
125
+
126
+ /**
127
+ * Resolve mqtt-auth base URL from network / env / flag.
128
+ * @param {string[]} argv
129
+ * @param {{ mqttAuthServiceUrl?: string|null }} network
130
+ */
131
+ export function resolvePairAuthUrl(argv, network) {
132
+ for (let i = 0; i < argv.length; i += 1) {
133
+ if (argv[i] === "--auth-url" && argv[i + 1]) return argv[i + 1];
134
+ }
135
+ if (process.env.MQTT_AUTH_SERVICE_URL) return process.env.MQTT_AUTH_SERVICE_URL;
136
+ if (network.mqttAuthServiceUrl) return network.mqttAuthServiceUrl;
137
+ throw new Error(
138
+ "mqttAuthServiceUrl required for pairing. Set profile via clanker setup, " +
139
+ "pass --auth-url, or set MQTT_AUTH_SERVICE_URL.",
140
+ );
141
+ }
142
+
143
+ /**
144
+ * Optional OpenClaw home for allowOperators sync (directory containing openclaw.json).
145
+ * @param {string[]} argv
146
+ * @returns {string|undefined}
147
+ */
148
+ export function resolveOpenclawHome(argv) {
149
+ for (let i = 0; i < argv.length; i += 1) {
150
+ if (argv[i] === "--openclaw-home" && argv[i + 1]) return argv[i + 1];
151
+ }
152
+ if (process.env.OPENCLAW_HOME) return process.env.OPENCLAW_HOME;
153
+ return undefined;
154
+ }
155
+
156
+ /**
157
+ * @param {string[]} argv
158
+ * @param {'add'|'remove'|'list'|'status'} action
159
+ * @param {string|null} peerLabel
160
+ */
161
+ export async function runPairAction(argv, action, peerLabel) {
162
+ const resolved = resolveOperatorKey(argv, { requireRegistry: true });
163
+ const operator =
164
+ loadOperator(resolved.network.home) ??
165
+ (resolved.network.operatorLabel
166
+ ? { label: resolved.network.operatorLabel }
167
+ : null);
168
+ const operatorId =
169
+ argv.includes("--operator") && argv[argv.indexOf("--operator") + 1]
170
+ ? argv[argv.indexOf("--operator") + 1]
171
+ : operator?.label;
172
+ if (!operatorId) {
173
+ throw new Error(
174
+ "Operator label required. Run clanker setup / write operator.json, or pass --operator org.you.",
175
+ );
176
+ }
177
+
178
+ const authUrl = resolvePairAuthUrl(argv, resolved.network);
179
+ const account = privateKeyToAccount(resolved.key);
180
+
181
+ if (action === "list" || action === "status") {
182
+ const { nonce, message } = await fetchPairNonce(authUrl, {
183
+ operatorId,
184
+ action: "list",
185
+ });
186
+ const signature = await account.signMessage({ message });
187
+ const listed = await getPairList(authUrl, {
188
+ operatorId,
189
+ nonce,
190
+ signature,
191
+ });
192
+ if (action === "status") {
193
+ if (!peerLabel) throw new Error("Usage: clanker pair status <operator-label>");
194
+ // Resolve peer by scanning allows — mutual flag is per peer_operator_id;
195
+ // we match by requesting add-style peer label via a second pair-nonce is heavy.
196
+ // Instead: check if any allow entry is mutual when peer label hashes match… we
197
+ // only have peer_operator_id hex. Re-fetch is unnecessary: call add-path status
198
+ // by computing whether peer is in allow list via a dedicated pair status using
199
+ // the peer label through POST-less approach — GET list doesn't include labels.
200
+ // Best effort: show mutual if we can add then remove? No.
201
+ // Call /pair-nonce + post with action that doesn't mutate? Use list + ask user.
202
+ // Simpler: after list, for status we POST nothing — check if peer's keccak is in list.
203
+ // We need peer id: keccak of peerLabel.
204
+ const { keccak256, toBytes } = await import("viem");
205
+ const peerId = keccak256(toBytes(peerLabel)).toLowerCase();
206
+ const hit = (listed.allows ?? []).find(
207
+ (a) => String(a.peer_operator_id).toLowerCase() === peerId,
208
+ );
209
+ return {
210
+ action: "status",
211
+ operatorId,
212
+ peerLabel,
213
+ allowed: Boolean(hit),
214
+ mutual: hit?.mutual === true,
215
+ authUrl,
216
+ listed,
217
+ };
218
+ }
219
+ return { action: "list", operatorId, authUrl, listed };
220
+ }
221
+
222
+ if (!peerLabel) {
223
+ throw new Error(`Usage: clanker pair ${action} <operator-label>`);
224
+ }
225
+
226
+ const { nonce, message } = await fetchPairNonce(authUrl, {
227
+ operatorId,
228
+ action,
229
+ peerLabel,
230
+ });
231
+ const signature = await account.signMessage({ message });
232
+ const result = await postPair(authUrl, {
233
+ operatorId,
234
+ peerLabel,
235
+ action,
236
+ nonce,
237
+ signature,
238
+ });
239
+ const sync = syncAllowOperators({
240
+ peerLabel,
241
+ action,
242
+ openclawHome: resolveOpenclawHome(argv),
243
+ });
244
+ return {
245
+ action,
246
+ operatorId,
247
+ peerLabel,
248
+ authUrl,
249
+ result,
250
+ openclawSync: sync,
251
+ };
252
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clanker-chain/clanker-cli",
3
- "version": "2026.9.8-2",
3
+ "version": "2026.9.10",
4
4
  "description": "CLI for wiring clanker-chain identity and MQTT into OpenClaw and other agentic stacks.",
5
5
  "type": "module",
6
6
  "bin": {