@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/bin/clanker.mjs +240 -22
- package/lib/clanker-identity-abi.mjs +18 -0
- package/lib/doctor.mjs +100 -8
- package/lib/fund.mjs +287 -0
- package/lib/identity-query.mjs +1 -1
- package/lib/mint-budget.mjs +237 -0
- package/lib/openclaw-wire.mjs +43 -2
- package/lib/operator-key.mjs +57 -0
- package/lib/pair.mjs +252 -0
- package/lib/resolve.mjs +10 -6
- package/lib/setup.mjs +240 -42
- package/package.json +5 -2
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 (
|
|
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
|
}
|