@clanker-chain/clanker-cli 2026.9.8 → 2026.9.12-1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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/lib/resolve.mjs CHANGED
@@ -78,6 +78,10 @@ export function keyPointerFromSource(opts) {
78
78
  return { type: "env", value: "OPERATOR_PRIVATE_KEY" };
79
79
  }
80
80
 
81
+ /** Honest error when mint/pair/rotate/transfer have no owner signer. */
82
+ export const OWNER_SIGNER_REQUIRED =
83
+ "Owner actions need a signing key (--key-file / OPERATOR_PRIVATE_KEY) or the browser door that created this address. A read-only profile can still run whoami, bots, fund, and doctor.";
84
+
81
85
  /**
82
86
  * Resolve the operator signing key with Anvil guard.
83
87
  *
@@ -133,11 +137,7 @@ export function resolveOperatorKey(argv = [], opts = {}) {
133
137
  key = normalizePrivateKey(ANVIL_DEFAULT_PRIVATE_KEY);
134
138
  source = "anvil-default (local RPC)";
135
139
  } else {
136
- throw new Error(
137
- "Operator private key required for non-local RPC. Set OPERATOR_PRIVATE_KEY, " +
138
- "pass --key / --key-file, or configure ~/.clanker/operator.json key pointer. " +
139
- "Anvil account #0 is not used on public networks.",
140
- );
140
+ throw new Error(OWNER_SIGNER_REQUIRED);
141
141
  }
142
142
  }
143
143
 
@@ -216,7 +216,11 @@ export function resolveReadIdentity(argv = [], opts = {}) {
216
216
  };
217
217
  } catch (err) {
218
218
  const msg = err?.message ?? String(err);
219
- if (!/private key required|Anvil account #0|Key file not found|Profile keyFile/i.test(msg)) {
219
+ if (
220
+ !/private key required|Owner actions need a signing key|Anvil account #0|Key file not found|Profile keyFile/i.test(
221
+ msg,
222
+ )
223
+ ) {
220
224
  throw err;
221
225
  }
222
226
  }