botanary 0.3.0 → 0.4.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/README.md +134 -1
- package/dist/bin/botanary.js +5 -2
- package/dist/bin/botanary.js.map +1 -1
- package/dist/src/app-url.js +19 -2
- package/dist/src/app-url.js.map +1 -1
- package/dist/src/cli.js +15 -0
- package/dist/src/cli.js.map +1 -1
- package/dist/src/commands/account.js +171 -0
- package/dist/src/commands/account.js.map +1 -0
- package/dist/src/commands/authority.js +634 -0
- package/dist/src/commands/authority.js.map +1 -0
- package/dist/src/commands/services.js +434 -0
- package/dist/src/commands/services.js.map +1 -0
- package/dist/src/commands/session-writes.js +196 -0
- package/dist/src/commands/session-writes.js.map +1 -0
- package/dist/src/commands/sign.js +306 -0
- package/dist/src/commands/sign.js.map +1 -0
- package/dist/src/commands/wallet.js +548 -30
- package/dist/src/commands/wallet.js.map +1 -1
- package/dist/src/commands/watch.js +259 -0
- package/dist/src/commands/watch.js.map +1 -0
- package/dist/src/help/examples.js +521 -2
- package/dist/src/help/examples.js.map +1 -1
- package/dist/src/package-version.js +53 -0
- package/dist/src/package-version.js.map +1 -0
- package/package.json +11 -12
- package/dist/package.json +0 -48
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { submitIntent, describeSignOutcome } from './sign.js';
|
|
3
|
+
import { group } from '../help/groups.js';
|
|
4
|
+
import { printResult } from '../render/print.js';
|
|
5
|
+
import { step } from '../progress.js';
|
|
6
|
+
import { columns, dash, section, shortAddress } from '../render/kv.js';
|
|
7
|
+
import { colors } from '../render/colors.js';
|
|
8
|
+
import { confirmOrThrow, dryRunPayload, renderReview } from '../review.js';
|
|
9
|
+
import { invalidArgs, notLoggedIn } from '../errors.js';
|
|
10
|
+
/** Same duplication as `account.ts`/`tokens.ts` - see `sign.ts`'s own comment on why a shared helper
|
|
11
|
+
* never gets pulled back out of sign.ts itself. */
|
|
12
|
+
async function requireWalletToken(ctx) {
|
|
13
|
+
const session = await ctx.runtime.walletSession();
|
|
14
|
+
if (!session)
|
|
15
|
+
throw notLoggedIn();
|
|
16
|
+
return session.sessionToken;
|
|
17
|
+
}
|
|
18
|
+
async function reviewAndSubmit(ctx, title, rows, warnings, kind, params) {
|
|
19
|
+
if (ctx.dryRun) {
|
|
20
|
+
printResult(ctx, dryRunPayload(title, rows, warnings), () => renderReview('Dry run - nothing will be sent', rows, warnings));
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
await confirmOrThrow(ctx, title, rows, warnings);
|
|
24
|
+
const outcome = await submitIntent(ctx, kind, params);
|
|
25
|
+
printResult(ctx, outcome.data, () => describeSignOutcome(outcome));
|
|
26
|
+
}
|
|
27
|
+
/** Same escape hatch `agent request --calls-file` established, and `wallet.ts`'s own `readJsonFile` -
|
|
28
|
+
* duplicated per `sign.ts`'s own comment on why a shared helper never gets pulled back out. */
|
|
29
|
+
async function readJsonFile(path, flag) {
|
|
30
|
+
let raw;
|
|
31
|
+
try {
|
|
32
|
+
raw = await readFile(path, 'utf8');
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
throw invalidArgs(`Could not read ${flag} "${path}": ${e instanceof Error ? e.message : String(e)}`);
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(raw);
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
throw invalidArgs(`${flag} "${path}" is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function findTopLevel(program, name) {
|
|
45
|
+
const cmd = program.commands.find((c) => c.name() === name);
|
|
46
|
+
if (!cmd) {
|
|
47
|
+
throw new Error(`authority.ts expected a top-level "${name}" command to already be registered - check registration order in cli.ts.`);
|
|
48
|
+
}
|
|
49
|
+
return cmd;
|
|
50
|
+
}
|
|
51
|
+
function renderGuardians(payload) {
|
|
52
|
+
const list = (Array.isArray(payload) ? payload : []);
|
|
53
|
+
if (!list.length) {
|
|
54
|
+
return `${section('Recovery guardians')}\n${colors.muted(' None installed - `botanary recovery install` to set up social recovery.')}`;
|
|
55
|
+
}
|
|
56
|
+
const rows = list.map((g) => [dash(g.id), dash(g.name), dash(g.kind), g.primary ? 'yes' : 'no', dash(g.addedAt)]);
|
|
57
|
+
return columns(['ID', 'NAME', 'KIND', 'PRIMARY', 'ADDED'], rows).length
|
|
58
|
+
? [section('Recovery guardians'), columns(['ID', 'NAME', 'KIND', 'PRIMARY', 'ADDED'], rows)].join('\n')
|
|
59
|
+
: '';
|
|
60
|
+
}
|
|
61
|
+
function renderDevices(payload) {
|
|
62
|
+
const root = (payload ?? {});
|
|
63
|
+
const devices = Array.isArray(root.devices) ? root.devices : [];
|
|
64
|
+
const rows = devices.map((d) => [shortAddress(d.address), dash(d.label), d.isCurrentSigner ? 'yes' : 'no']);
|
|
65
|
+
return [
|
|
66
|
+
section('Devices'),
|
|
67
|
+
` threshold ${dash(root.threshold)} of ${dash(root.deviceCount)}${root.deployed ? '' : ' (counterfactual)'}`,
|
|
68
|
+
devices.length ? columns(['ADDRESS', 'LABEL', 'THIS DEVICE'], rows) : colors.muted(' No devices enrolled.'),
|
|
69
|
+
].join('\n');
|
|
70
|
+
}
|
|
71
|
+
function renderSigners(payload) {
|
|
72
|
+
const list = (Array.isArray(payload) ? payload : []);
|
|
73
|
+
if (!list.length) {
|
|
74
|
+
return `${section('Signers')}\n${colors.muted(' None enrolled yet.')}`;
|
|
75
|
+
}
|
|
76
|
+
const rows = list.map((s) => [String(s.index ?? '-'), dash(s.scheme), dash(s.address ?? s.credentialId)]);
|
|
77
|
+
return [section('Signers'), columns(['INDEX', 'SCHEME', 'ADDRESS / CREDENTIAL'], rows)].join('\n');
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Recovery, signers, devices, panic, and the owner's verdict on an agent's request - every authority
|
|
81
|
+
* action the web app exposes outside the account/mandate groups already covered elsewhere. Every write
|
|
82
|
+
* here is a `submitIntent` call: an owner-signed op parked at `POST /sign-requests`, never a build route,
|
|
83
|
+
* never a local signature.
|
|
84
|
+
*
|
|
85
|
+
* `agents approve`/`agents deny` attach onto the `agents` Command `wallet.ts` already registers (its base
|
|
86
|
+
* action lists connected agents and pending requests) - registration order in cli.ts must put
|
|
87
|
+
* `registerWalletCommands` before `registerAuthorityCommands`.
|
|
88
|
+
*/
|
|
89
|
+
export function registerAuthorityCommands(program) {
|
|
90
|
+
// ---- recovery ------------------------------------------------------------------------------------
|
|
91
|
+
const recovery = group(program.command('recovery').description('Social recovery: guardians and the emergency signer-set replacement'), 'Wallet');
|
|
92
|
+
recovery
|
|
93
|
+
.command('status')
|
|
94
|
+
.description('The live guardian set on this account (GET /recovery/guardians)')
|
|
95
|
+
.action(async () => {
|
|
96
|
+
const ctx = program.opts().__ctx;
|
|
97
|
+
const token = await requireWalletToken(ctx);
|
|
98
|
+
const guardians = await step(ctx, 'Reading recovery guardians', () => ctx.runtime.api.get('/v1/recovery/guardians', token));
|
|
99
|
+
printResult(ctx, guardians, () => renderGuardians(guardians) || `${section('Recovery guardians')}\n${colors.muted(' None installed.')}`);
|
|
100
|
+
});
|
|
101
|
+
recovery
|
|
102
|
+
.command('install')
|
|
103
|
+
.description('Install social recovery with a guardian set and an M-of-N threshold')
|
|
104
|
+
.requiredOption('--guardian <address...>', 'A guardian EVM address - repeat for each (at least 2)')
|
|
105
|
+
.option('--threshold <n>', 'How many guardians must approve a recovery - at least 2')
|
|
106
|
+
.option('--account-id <accountId>', 'Specific account of yours - omit for your default')
|
|
107
|
+
.action(async (opts) => {
|
|
108
|
+
const ctx = program.opts().__ctx;
|
|
109
|
+
await requireWalletToken(ctx);
|
|
110
|
+
if (!opts.guardian || opts.guardian.length < 2) {
|
|
111
|
+
throw invalidArgs('At least 2 --guardian addresses are required.', 'A single guardian could rotate the account unilaterally.');
|
|
112
|
+
}
|
|
113
|
+
const threshold = opts.threshold === undefined ? undefined : Number(opts.threshold);
|
|
114
|
+
if (threshold !== undefined && (!Number.isInteger(threshold) || threshold < 2)) {
|
|
115
|
+
throw invalidArgs(`--threshold must be an integer of at least 2, got ${JSON.stringify(opts.threshold)}.`);
|
|
116
|
+
}
|
|
117
|
+
const rows = [
|
|
118
|
+
{ label: 'guardians', value: opts.guardian.join(', ') },
|
|
119
|
+
...(threshold !== undefined ? [{ label: 'threshold', value: String(threshold) }] : []),
|
|
120
|
+
{ label: 'signed by', value: 'you, in your browser' },
|
|
121
|
+
];
|
|
122
|
+
await reviewAndSubmit(ctx, 'Review this recovery install', rows, [], 'recovery.install', {
|
|
123
|
+
guardians: opts.guardian,
|
|
124
|
+
...(threshold !== undefined ? { threshold } : {}),
|
|
125
|
+
...(opts.accountId ? { accountId: opts.accountId } : {}),
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
const guardian = recovery.command('guardian').description('Add or remove one recovery guardian');
|
|
129
|
+
guardian
|
|
130
|
+
.command('add')
|
|
131
|
+
.description('Add a recovery guardian')
|
|
132
|
+
.requiredOption('--name <name>', 'A label for this guardian, e.g. "iPhone 15"')
|
|
133
|
+
.requiredOption('--kind <kind>', 'device or contact')
|
|
134
|
+
.requiredOption('--address <address>', 'The guardian\'s EVM address')
|
|
135
|
+
.option('--account-id <accountId>', 'Specific account of yours - omit for your default')
|
|
136
|
+
.action(async (opts) => {
|
|
137
|
+
const ctx = program.opts().__ctx;
|
|
138
|
+
await requireWalletToken(ctx);
|
|
139
|
+
if (opts.kind !== 'device' && opts.kind !== 'contact') {
|
|
140
|
+
throw invalidArgs(`--kind must be "device" or "contact", got ${JSON.stringify(opts.kind)}.`);
|
|
141
|
+
}
|
|
142
|
+
const rows = [
|
|
143
|
+
{ label: 'name', value: opts.name },
|
|
144
|
+
{ label: 'kind', value: opts.kind },
|
|
145
|
+
{ label: 'address', value: opts.address },
|
|
146
|
+
{ label: 'signed by', value: 'you, in your browser' },
|
|
147
|
+
];
|
|
148
|
+
await reviewAndSubmit(ctx, 'Review this guardian add', rows, [], 'recovery.guardian.add', {
|
|
149
|
+
name: opts.name,
|
|
150
|
+
guardianKind: opts.kind,
|
|
151
|
+
address: opts.address,
|
|
152
|
+
...(opts.accountId ? { accountId: opts.accountId } : {}),
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
guardian
|
|
156
|
+
.command('remove <guardianId>')
|
|
157
|
+
.description('Remove a recovery guardian')
|
|
158
|
+
.option('--account-id <accountId>', 'Specific account of yours - omit for your default')
|
|
159
|
+
.action(async (guardianId, opts) => {
|
|
160
|
+
const ctx = program.opts().__ctx;
|
|
161
|
+
await requireWalletToken(ctx);
|
|
162
|
+
const rows = [
|
|
163
|
+
{ label: 'guardian', value: guardianId },
|
|
164
|
+
{ label: 'signed by', value: 'you, in your browser' },
|
|
165
|
+
];
|
|
166
|
+
await reviewAndSubmit(ctx, 'Review this guardian removal', rows, [], 'recovery.guardian.remove', {
|
|
167
|
+
guardianId,
|
|
168
|
+
...(opts.accountId ? { accountId: opts.accountId } : {}),
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
recovery
|
|
172
|
+
.command('recover')
|
|
173
|
+
.description("Replace this account's whole signer set (the emergency lane - a guardian-signed rotation)")
|
|
174
|
+
.requiredOption('--new-signer <address...>', 'The complete new signer set - repeat for each')
|
|
175
|
+
.requiredOption('--new-threshold <n>', 'How many of the new signers must approve going forward')
|
|
176
|
+
.option('--op-count <n>', 'How many ops this recovery will produce - omit to let the backend size it')
|
|
177
|
+
.action(async (opts) => {
|
|
178
|
+
const ctx = program.opts().__ctx;
|
|
179
|
+
await requireWalletToken(ctx);
|
|
180
|
+
if (!opts.newSigner || opts.newSigner.length < 1) {
|
|
181
|
+
throw invalidArgs('At least one --new-signer address is required.');
|
|
182
|
+
}
|
|
183
|
+
const newThreshold = Number(opts.newThreshold);
|
|
184
|
+
if (!Number.isInteger(newThreshold) || newThreshold < 1) {
|
|
185
|
+
throw invalidArgs(`--new-threshold must be a positive integer, got ${JSON.stringify(opts.newThreshold)}.`);
|
|
186
|
+
}
|
|
187
|
+
const opCount = opts.opCount === undefined ? undefined : Number(opts.opCount);
|
|
188
|
+
if (opCount !== undefined && (!Number.isInteger(opCount) || opCount <= 0)) {
|
|
189
|
+
throw invalidArgs(`--op-count must be a positive integer, got ${JSON.stringify(opts.opCount)}.`);
|
|
190
|
+
}
|
|
191
|
+
const rows = [
|
|
192
|
+
{ label: 'new signers', value: opts.newSigner.join(', ') },
|
|
193
|
+
{ label: 'new threshold', value: String(newThreshold) },
|
|
194
|
+
{ label: 'signed by', value: 'a guardian, in the emergency recovery flow' },
|
|
195
|
+
];
|
|
196
|
+
const warnings = ['This replaces the whole signer set on this account.'];
|
|
197
|
+
await reviewAndSubmit(ctx, 'Review this recovery', rows, warnings, 'recovery.recover', {
|
|
198
|
+
newSigners: opts.newSigner,
|
|
199
|
+
newThreshold,
|
|
200
|
+
...(opCount !== undefined ? { opCount } : {}),
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
// ---- signers (BotanaryPolicyValidator's own signer registry) -------------------------------------
|
|
204
|
+
const signers = group(program.command('signers').description("This account's enrolled signers (the v1 authority core)"), 'Wallet');
|
|
205
|
+
signers
|
|
206
|
+
.command('list')
|
|
207
|
+
.description("Every enrolled signer, by index (GET /account/signers)")
|
|
208
|
+
.action(async () => {
|
|
209
|
+
const ctx = program.opts().__ctx;
|
|
210
|
+
const token = await requireWalletToken(ctx);
|
|
211
|
+
const list = await step(ctx, 'Reading signers', () => ctx.runtime.api.get('/v1/account/signers', token));
|
|
212
|
+
printResult(ctx, list, () => renderSigners(list));
|
|
213
|
+
});
|
|
214
|
+
signers
|
|
215
|
+
.command('add')
|
|
216
|
+
.description('Enrol a new ECDSA signer on this chain')
|
|
217
|
+
.requiredOption('--address <address>', 'The new signer\'s EVM address')
|
|
218
|
+
.option('--account-id <accountId>', 'Specific account of yours - omit for your default')
|
|
219
|
+
.action(async (opts) => {
|
|
220
|
+
const ctx = program.opts().__ctx;
|
|
221
|
+
await requireWalletToken(ctx);
|
|
222
|
+
const rows = [
|
|
223
|
+
{ label: 'address', value: opts.address },
|
|
224
|
+
{ label: 'signed by', value: 'you, in your browser' },
|
|
225
|
+
];
|
|
226
|
+
await reviewAndSubmit(ctx, 'Review this signer add', rows, [], 'policies.signer.add', {
|
|
227
|
+
address: opts.address,
|
|
228
|
+
...(opts.accountId ? { accountId: opts.accountId } : {}),
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
signers
|
|
232
|
+
.command('remove')
|
|
233
|
+
.description('Remove a signer from this chain, by index')
|
|
234
|
+
.requiredOption('--index <index>', 'The signer\'s index - see `botanary signers list`')
|
|
235
|
+
.option('--account-id <accountId>', 'Specific account of yours - omit for your default')
|
|
236
|
+
.action(async (opts) => {
|
|
237
|
+
const ctx = program.opts().__ctx;
|
|
238
|
+
await requireWalletToken(ctx);
|
|
239
|
+
const index = Number(opts.index);
|
|
240
|
+
if (!Number.isInteger(index) || index < 0) {
|
|
241
|
+
throw invalidArgs(`--index must be a non-negative integer, got ${JSON.stringify(opts.index)}.`);
|
|
242
|
+
}
|
|
243
|
+
const rows = [
|
|
244
|
+
{ label: 'index', value: String(index) },
|
|
245
|
+
{ label: 'signed by', value: 'you, in your browser' },
|
|
246
|
+
];
|
|
247
|
+
await reviewAndSubmit(ctx, 'Review this signer removal', rows, [], 'policies.signer.remove', {
|
|
248
|
+
index,
|
|
249
|
+
...(opts.accountId ? { accountId: opts.accountId } : {}),
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
signers
|
|
253
|
+
.command('threshold')
|
|
254
|
+
.description('Set the account-wide signer approval threshold')
|
|
255
|
+
.requiredOption('--threshold <n>', 'How many enrolled signers must approve')
|
|
256
|
+
.option('--account-id <accountId>', 'Specific account of yours - omit for your default')
|
|
257
|
+
.action(async (opts) => {
|
|
258
|
+
const ctx = program.opts().__ctx;
|
|
259
|
+
await requireWalletToken(ctx);
|
|
260
|
+
const threshold = Number(opts.threshold);
|
|
261
|
+
if (!Number.isInteger(threshold) || threshold < 1) {
|
|
262
|
+
throw invalidArgs(`--threshold must be a positive integer, got ${JSON.stringify(opts.threshold)}.`);
|
|
263
|
+
}
|
|
264
|
+
const rows = [
|
|
265
|
+
{ label: 'threshold', value: String(threshold) },
|
|
266
|
+
{ label: 'signed by', value: 'you, in your browser' },
|
|
267
|
+
];
|
|
268
|
+
await reviewAndSubmit(ctx, 'Review this threshold change', rows, [], 'policies.threshold', {
|
|
269
|
+
threshold,
|
|
270
|
+
...(opts.accountId ? { accountId: opts.accountId } : {}),
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
signers
|
|
274
|
+
.command('trust-anchor')
|
|
275
|
+
.description("Mirror an issuer's currently-active signing key onto this account")
|
|
276
|
+
.option('--issuer <issuer>', 'Only "google" has a live JWKS source today', 'google')
|
|
277
|
+
.option('--account-id <accountId>', 'Specific account of yours - omit for your default')
|
|
278
|
+
.action(async (opts) => {
|
|
279
|
+
const ctx = program.opts().__ctx;
|
|
280
|
+
await requireWalletToken(ctx);
|
|
281
|
+
if (opts.issuer !== undefined && opts.issuer !== 'google') {
|
|
282
|
+
throw invalidArgs(`--issuer must be "google" today, got ${JSON.stringify(opts.issuer)}.`);
|
|
283
|
+
}
|
|
284
|
+
const rows = [
|
|
285
|
+
{ label: 'issuer', value: opts.issuer ?? 'google' },
|
|
286
|
+
{ label: 'signed by', value: 'you, in your browser' },
|
|
287
|
+
];
|
|
288
|
+
await reviewAndSubmit(ctx, 'Review this trust anchor mirror', rows, [], 'policies.trust_anchor', {
|
|
289
|
+
...(opts.issuer ? { issuer: opts.issuer } : {}),
|
|
290
|
+
...(opts.accountId ? { accountId: opts.accountId } : {}),
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
// `signers guardian` - BotanaryPolicyValidator's OWN guardian concept (a threshold over enrolled
|
|
294
|
+
// signer indexes), NOT `recovery guardian` above (SocialRecovery guardians, a different contract and
|
|
295
|
+
// route behind the same English word - see `sign-kinds.ts`'s own comment on the distinction).
|
|
296
|
+
const signersGuardian = signers
|
|
297
|
+
.command('guardian')
|
|
298
|
+
.description("Set BotanaryPolicyValidator's own guardian threshold over a subset of enrolled signers");
|
|
299
|
+
signersGuardian
|
|
300
|
+
.command('set')
|
|
301
|
+
.description('Set the policy-validator guardian threshold over a subset of enrolled signer indexes')
|
|
302
|
+
.option('--signer-index <index...>', 'An enrolled signer index to include - repeat for each; omit with --threshold 0 to opt out')
|
|
303
|
+
.requiredOption('--threshold <n>', 'How many of the listed indexes must approve - 0 opts out')
|
|
304
|
+
.option('--account-id <accountId>', 'Specific account of yours - omit for your default')
|
|
305
|
+
.action(async (opts) => {
|
|
306
|
+
const ctx = program.opts().__ctx;
|
|
307
|
+
await requireWalletToken(ctx);
|
|
308
|
+
const signerIndexes = (opts.signerIndex ?? []).map((raw) => {
|
|
309
|
+
const n = Number(raw);
|
|
310
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
311
|
+
throw invalidArgs(`--signer-index must be a non-negative integer, got ${JSON.stringify(raw)}.`);
|
|
312
|
+
}
|
|
313
|
+
return n;
|
|
314
|
+
});
|
|
315
|
+
const threshold = Number(opts.threshold);
|
|
316
|
+
if (!Number.isInteger(threshold) || threshold < 0) {
|
|
317
|
+
throw invalidArgs(`--threshold must be a non-negative integer, got ${JSON.stringify(opts.threshold)}.`);
|
|
318
|
+
}
|
|
319
|
+
const rows = [
|
|
320
|
+
{ label: 'signer indexes', value: signerIndexes.length ? signerIndexes.join(', ') : 'none' },
|
|
321
|
+
{ label: 'threshold', value: String(threshold) },
|
|
322
|
+
{ label: 'signed by', value: 'you, in your browser' },
|
|
323
|
+
];
|
|
324
|
+
await reviewAndSubmit(ctx, 'Review this guardian threshold', rows, [], 'policies.guardian.set', {
|
|
325
|
+
signerIndexes,
|
|
326
|
+
threshold,
|
|
327
|
+
...(opts.accountId ? { accountId: opts.accountId } : {}),
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
const signersPolicy = signers
|
|
331
|
+
.command('policy')
|
|
332
|
+
.description('Write or remove a conditional signing policy at a slot (BotanaryPolicyValidator)');
|
|
333
|
+
signersPolicy
|
|
334
|
+
.command('set')
|
|
335
|
+
.description('Write (or overwrite) the conditional signing policy at a slot')
|
|
336
|
+
.requiredOption('--slot <n>', 'Policy slot index')
|
|
337
|
+
.requiredOption('--approvals <n>', 'How many approvals this policy requires')
|
|
338
|
+
.requiredOption('--delay-seconds <seconds>', 'Timelock delay, in seconds, before this policy takes effect')
|
|
339
|
+
.option('--when-file <path>', 'JSON array of condition objects: [{ field, op, argOffset, value }] - omit for none')
|
|
340
|
+
.option('--signer-index <index...>', 'A signer index this policy accepts - repeat for each')
|
|
341
|
+
.option('--signer-key-ref <hex32...>', 'A signer key reference (bytes32 hex) this policy accepts - repeat for each')
|
|
342
|
+
.option('--inactive', 'Write this policy inactive rather than active')
|
|
343
|
+
.option('--account-id <accountId>', 'Specific account of yours - omit for your default')
|
|
344
|
+
.action(async (opts) => {
|
|
345
|
+
const ctx = program.opts().__ctx;
|
|
346
|
+
await requireWalletToken(ctx);
|
|
347
|
+
const slot = Number(opts.slot);
|
|
348
|
+
if (!Number.isInteger(slot) || slot < 0)
|
|
349
|
+
throw invalidArgs(`--slot must be a non-negative integer, got ${JSON.stringify(opts.slot)}.`);
|
|
350
|
+
const approvals = Number(opts.approvals);
|
|
351
|
+
if (!Number.isInteger(approvals) || approvals < 1) {
|
|
352
|
+
throw invalidArgs(`--approvals must be a positive integer, got ${JSON.stringify(opts.approvals)}.`);
|
|
353
|
+
}
|
|
354
|
+
const delaySeconds = Number(opts.delaySeconds);
|
|
355
|
+
if (!Number.isInteger(delaySeconds) || delaySeconds < 0) {
|
|
356
|
+
throw invalidArgs(`--delay-seconds must be a non-negative integer, got ${JSON.stringify(opts.delaySeconds)}.`);
|
|
357
|
+
}
|
|
358
|
+
const when = opts.whenFile ? await readJsonFile(opts.whenFile, '--when-file') : undefined;
|
|
359
|
+
if (when !== undefined && !Array.isArray(when))
|
|
360
|
+
throw invalidArgs('--when-file must contain a JSON array.');
|
|
361
|
+
const signerIndexes = opts.signerIndex?.map((raw) => {
|
|
362
|
+
const n = Number(raw);
|
|
363
|
+
if (!Number.isInteger(n) || n < 0)
|
|
364
|
+
throw invalidArgs(`--signer-index must be a non-negative integer, got ${JSON.stringify(raw)}.`);
|
|
365
|
+
return n;
|
|
366
|
+
});
|
|
367
|
+
const rows = [
|
|
368
|
+
{ label: 'slot', value: String(slot) },
|
|
369
|
+
{ label: 'approvals', value: String(approvals) },
|
|
370
|
+
{ label: 'delay', value: `${delaySeconds}s` },
|
|
371
|
+
{ label: 'active', value: opts.inactive ? 'no' : 'yes' },
|
|
372
|
+
{ label: 'signed by', value: 'you, in your browser' },
|
|
373
|
+
];
|
|
374
|
+
await reviewAndSubmit(ctx, 'Review this policy write', rows, [], 'policies.policy.set', {
|
|
375
|
+
slot,
|
|
376
|
+
approvals,
|
|
377
|
+
delaySeconds,
|
|
378
|
+
active: !opts.inactive,
|
|
379
|
+
...(when !== undefined ? { when } : {}),
|
|
380
|
+
...(signerIndexes?.length ? { signerIndexes } : {}),
|
|
381
|
+
...(opts.signerKeyRef?.length ? { signerKeyRefs: opts.signerKeyRef } : {}),
|
|
382
|
+
...(opts.accountId ? { accountId: opts.accountId } : {}),
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
signersPolicy
|
|
386
|
+
.command('remove')
|
|
387
|
+
.description('Remove the conditional signing policy at a slot')
|
|
388
|
+
.requiredOption('--slot <n>', 'Policy slot index')
|
|
389
|
+
.option('--account-id <accountId>', 'Specific account of yours - omit for your default')
|
|
390
|
+
.action(async (opts) => {
|
|
391
|
+
const ctx = program.opts().__ctx;
|
|
392
|
+
await requireWalletToken(ctx);
|
|
393
|
+
const slot = Number(opts.slot);
|
|
394
|
+
if (!Number.isInteger(slot) || slot < 0)
|
|
395
|
+
throw invalidArgs(`--slot must be a non-negative integer, got ${JSON.stringify(opts.slot)}.`);
|
|
396
|
+
const rows = [
|
|
397
|
+
{ label: 'slot', value: String(slot) },
|
|
398
|
+
{ label: 'signed by', value: 'you, in your browser' },
|
|
399
|
+
];
|
|
400
|
+
await reviewAndSubmit(ctx, 'Review this policy removal', rows, [], 'policies.policy.remove', {
|
|
401
|
+
slot,
|
|
402
|
+
...(opts.accountId ? { accountId: opts.accountId } : {}),
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
signers
|
|
406
|
+
.command('condition-set')
|
|
407
|
+
.description('Add or remove a condition-set member (BotanaryPolicyValidator)')
|
|
408
|
+
.requiredOption('--set-id <hex32>', 'The condition set id, a 32-byte hex value')
|
|
409
|
+
.requiredOption('--member <hex32>', 'The member to add or remove, a 32-byte hex value')
|
|
410
|
+
.option('--not-member', 'Remove the member instead of adding it')
|
|
411
|
+
.option('--account-id <accountId>', 'Specific account of yours - omit for your default')
|
|
412
|
+
.action(async (opts) => {
|
|
413
|
+
const ctx = program.opts().__ctx;
|
|
414
|
+
await requireWalletToken(ctx);
|
|
415
|
+
const isMember = !opts.notMember;
|
|
416
|
+
const rows = [
|
|
417
|
+
{ label: 'set', value: opts.setId },
|
|
418
|
+
{ label: 'member', value: opts.member },
|
|
419
|
+
{ label: 'action', value: isMember ? 'add' : 'remove' },
|
|
420
|
+
{ label: 'signed by', value: 'you, in your browser' },
|
|
421
|
+
];
|
|
422
|
+
await reviewAndSubmit(ctx, 'Review this condition-set change', rows, [], 'policies.condition_set', {
|
|
423
|
+
setId: opts.setId,
|
|
424
|
+
member: opts.member,
|
|
425
|
+
isMember,
|
|
426
|
+
...(opts.accountId ? { accountId: opts.accountId } : {}),
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
signers
|
|
430
|
+
.command('migrate')
|
|
431
|
+
.description("Upgrade this account's authority core onto BotanaryPolicyValidator (step 1 of 2)")
|
|
432
|
+
.option('--account-id <accountId>', 'Specific account of yours - omit for your default')
|
|
433
|
+
.action(async (opts) => {
|
|
434
|
+
const ctx = program.opts().__ctx;
|
|
435
|
+
await requireWalletToken(ctx);
|
|
436
|
+
const rows = [
|
|
437
|
+
{ label: 'action', value: "upgrade this account's authority core (step 1 of 2)" },
|
|
438
|
+
{ label: 'signed by', value: 'you, in your browser' },
|
|
439
|
+
];
|
|
440
|
+
const warnings = [
|
|
441
|
+
'This is step 1 of 2. Step 2 (op-b) follows once this is signed, relayed and mined - not covered by this command yet.',
|
|
442
|
+
];
|
|
443
|
+
await reviewAndSubmit(ctx, 'Review this authority-core migration', rows, warnings, 'policies.migrate', {
|
|
444
|
+
...(opts.accountId ? { accountId: opts.accountId } : {}),
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
// ---- devices (OwnableValidator co-signer set) -----------------------------------------------------
|
|
448
|
+
const devices = group(program.command('devices').description('Co-signer devices on this account\'s OwnableValidator root'), 'Wallet');
|
|
449
|
+
devices
|
|
450
|
+
.command('list')
|
|
451
|
+
.description('The live device set and its M-of-N threshold (GET /account/devices)')
|
|
452
|
+
.action(async () => {
|
|
453
|
+
const ctx = program.opts().__ctx;
|
|
454
|
+
const token = await requireWalletToken(ctx);
|
|
455
|
+
const set = await step(ctx, 'Reading devices', () => ctx.runtime.api.get('/v1/account/devices', token));
|
|
456
|
+
printResult(ctx, set, () => renderDevices(set));
|
|
457
|
+
});
|
|
458
|
+
devices
|
|
459
|
+
.command('add')
|
|
460
|
+
.description('Add a device as a co-signer')
|
|
461
|
+
.requiredOption('--device <address>', 'The device\'s EVM address')
|
|
462
|
+
.option('--label <label>', 'A label for this device, e.g. "Work laptop"')
|
|
463
|
+
.option('--account-id <accountId>', 'Specific account of yours - omit for your default')
|
|
464
|
+
.action(async (opts) => {
|
|
465
|
+
const ctx = program.opts().__ctx;
|
|
466
|
+
await requireWalletToken(ctx);
|
|
467
|
+
const rows = [
|
|
468
|
+
{ label: 'device', value: opts.device },
|
|
469
|
+
...(opts.label ? [{ label: 'label', value: opts.label }] : []),
|
|
470
|
+
{ label: 'signed by', value: 'you, in your browser' },
|
|
471
|
+
];
|
|
472
|
+
await reviewAndSubmit(ctx, 'Review this device add', rows, [], 'device.add', {
|
|
473
|
+
device: opts.device,
|
|
474
|
+
...(opts.label ? { label: opts.label } : {}),
|
|
475
|
+
...(opts.accountId ? { accountId: opts.accountId } : {}),
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
devices
|
|
479
|
+
.command('remove')
|
|
480
|
+
.description('Remove a device from the co-signer set')
|
|
481
|
+
.requiredOption('--device <address>', 'The device\'s EVM address')
|
|
482
|
+
.option('--account-id <accountId>', 'Specific account of yours - omit for your default')
|
|
483
|
+
.action(async (opts) => {
|
|
484
|
+
const ctx = program.opts().__ctx;
|
|
485
|
+
await requireWalletToken(ctx);
|
|
486
|
+
const rows = [
|
|
487
|
+
{ label: 'device', value: opts.device },
|
|
488
|
+
{ label: 'signed by', value: 'you, in your browser' },
|
|
489
|
+
];
|
|
490
|
+
await reviewAndSubmit(ctx, 'Review this device removal', rows, [], 'device.remove', {
|
|
491
|
+
device: opts.device,
|
|
492
|
+
...(opts.accountId ? { accountId: opts.accountId } : {}),
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
devices
|
|
496
|
+
.command('threshold')
|
|
497
|
+
.description('Set the M-of-N device signing threshold')
|
|
498
|
+
.requiredOption('--threshold <n>', 'How many devices must sign to move money or change rules')
|
|
499
|
+
.option('--account-id <accountId>', 'Specific account of yours - omit for your default')
|
|
500
|
+
.action(async (opts) => {
|
|
501
|
+
const ctx = program.opts().__ctx;
|
|
502
|
+
await requireWalletToken(ctx);
|
|
503
|
+
const threshold = Number(opts.threshold);
|
|
504
|
+
if (!Number.isInteger(threshold) || threshold < 1) {
|
|
505
|
+
throw invalidArgs(`--threshold must be a positive integer, got ${JSON.stringify(opts.threshold)}.`);
|
|
506
|
+
}
|
|
507
|
+
const rows = [
|
|
508
|
+
{ label: 'threshold', value: String(threshold) },
|
|
509
|
+
{ label: 'signed by', value: 'you, in your browser' },
|
|
510
|
+
];
|
|
511
|
+
await reviewAndSubmit(ctx, 'Review this device threshold change', rows, [], 'device.threshold', {
|
|
512
|
+
threshold,
|
|
513
|
+
...(opts.accountId ? { accountId: opts.accountId } : {}),
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
// ---- panic -----------------------------------------------------------------------------------------
|
|
517
|
+
const panic = group(program.command('panic').description('The panic freeze lane: any one listed device can freeze the account, gas-abstractedly'), 'Wallet');
|
|
518
|
+
panic
|
|
519
|
+
.command('install')
|
|
520
|
+
.description('Install the panic freeze lane (a 1-of-N session over the listed device keys)')
|
|
521
|
+
.option('--device-key <address...>', 'A device key to authorize - repeat for each; omit to use the account\'s own signer set')
|
|
522
|
+
.option('--account-id <accountId>', 'Specific account of yours - omit for your default')
|
|
523
|
+
.action(async (opts) => {
|
|
524
|
+
const ctx = program.opts().__ctx;
|
|
525
|
+
await requireWalletToken(ctx);
|
|
526
|
+
const rows = [
|
|
527
|
+
{ label: 'device keys', value: opts.deviceKey?.length ? opts.deviceKey.join(', ') : "this account's own signer set" },
|
|
528
|
+
{ label: 'signed by', value: 'you, in your browser' },
|
|
529
|
+
];
|
|
530
|
+
await reviewAndSubmit(ctx, 'Review this panic-lane install', rows, [], 'panic.install', {
|
|
531
|
+
...(opts.deviceKey?.length ? { deviceKeys: opts.deviceKey } : {}),
|
|
532
|
+
...(opts.accountId ? { accountId: opts.accountId } : {}),
|
|
533
|
+
});
|
|
534
|
+
});
|
|
535
|
+
panic
|
|
536
|
+
.command('freeze')
|
|
537
|
+
.description('Freeze the account via the panic lane (any one listed device can sign)')
|
|
538
|
+
.option('--device-key <address...>', 'A device key to sign with - repeat for each; omit to use the account\'s own signer set')
|
|
539
|
+
.option('--account-id <accountId>', 'Specific account of yours - omit for your default')
|
|
540
|
+
.action(async (opts) => {
|
|
541
|
+
const ctx = program.opts().__ctx;
|
|
542
|
+
await requireWalletToken(ctx);
|
|
543
|
+
const rows = [
|
|
544
|
+
{ label: 'action', value: 'freeze this account via the panic lane' },
|
|
545
|
+
{ label: 'signed by', value: 'you, in your browser' },
|
|
546
|
+
];
|
|
547
|
+
const warnings = ['This is the kill-switch. No further transfers will be authorized until you unfreeze.'];
|
|
548
|
+
await reviewAndSubmit(ctx, 'Review this panic freeze', rows, warnings, 'panic.freeze', {
|
|
549
|
+
...(opts.deviceKey?.length ? { deviceKeys: opts.deviceKey } : {}),
|
|
550
|
+
...(opts.accountId ? { accountId: opts.accountId } : {}),
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
// ---- rules (AgentGuard owner-lane caps and allowlist) -----------------------------------------------
|
|
554
|
+
// Caps and allowlist are complex, arbitrarily-sized nested shapes - JSON files here, the same escape
|
|
555
|
+
// hatch `agent request --calls-file` already established, rather than a wall of flags. See the LIVE
|
|
556
|
+
// CAVEAT in the workspace CLAUDE.md: accounts created since 2026-07-30 deploy permanently hookless, so
|
|
557
|
+
// this still parks and signs, but `RulesService.buildUpdate` declines rather than build an op that
|
|
558
|
+
// cannot bind on those accounts.
|
|
559
|
+
const rules = group(program.command('rules').description("Owner-lane caps and allowlist on this account's AgentGuard hook"), 'Wallet');
|
|
560
|
+
rules
|
|
561
|
+
.command('update')
|
|
562
|
+
.description('Update account caps and/or the contract/recipient allowlist - each half is left unchanged if omitted')
|
|
563
|
+
.option('--caps-file <path>', 'JSON array of cap rules: [{ token: { symbol }, limit, period }]')
|
|
564
|
+
.option('--allowlist-file <path>', 'JSON allowlist: { contracts: [...], recipients: [...], denied: [...] }, each an array of { name?, address }')
|
|
565
|
+
.action(async (opts) => {
|
|
566
|
+
const ctx = program.opts().__ctx;
|
|
567
|
+
await requireWalletToken(ctx);
|
|
568
|
+
if (!opts.capsFile && !opts.allowlistFile) {
|
|
569
|
+
throw invalidArgs('Nothing to change.', 'Pass --caps-file, --allowlist-file, or both.');
|
|
570
|
+
}
|
|
571
|
+
const caps = opts.capsFile ? await readJsonFile(opts.capsFile, '--caps-file') : undefined;
|
|
572
|
+
if (caps !== undefined && !Array.isArray(caps))
|
|
573
|
+
throw invalidArgs('--caps-file must contain a JSON array.');
|
|
574
|
+
const allowlist = opts.allowlistFile ? await readJsonFile(opts.allowlistFile, '--allowlist-file') : undefined;
|
|
575
|
+
if (allowlist !== undefined && (typeof allowlist !== 'object' || allowlist === null || Array.isArray(allowlist))) {
|
|
576
|
+
throw invalidArgs('--allowlist-file must contain a single JSON object.');
|
|
577
|
+
}
|
|
578
|
+
const rows = [
|
|
579
|
+
{ label: 'caps', value: caps ? `${caps.length} rule(s)` : 'unchanged' },
|
|
580
|
+
{ label: 'allowlist', value: allowlist ? 'updated' : 'unchanged' },
|
|
581
|
+
{ label: 'signed by', value: 'you, in your browser' },
|
|
582
|
+
];
|
|
583
|
+
await reviewAndSubmit(ctx, 'Review this rules update', rows, [], 'policy.update', {
|
|
584
|
+
...(caps !== undefined ? { caps } : {}),
|
|
585
|
+
...(allowlist !== undefined ? { allowlist } : {}),
|
|
586
|
+
});
|
|
587
|
+
});
|
|
588
|
+
// ---- agents approve/deny - attach onto wallet.ts's own `agents` Command --------------------------
|
|
589
|
+
const agents = findTopLevel(program, 'agents');
|
|
590
|
+
agents
|
|
591
|
+
.command('approve <id>')
|
|
592
|
+
.description("Approve an agent's request - the owner signs; the agent can never approve its own request")
|
|
593
|
+
.action(async (id) => {
|
|
594
|
+
const ctx = program.opts().__ctx;
|
|
595
|
+
await requireWalletToken(ctx);
|
|
596
|
+
const rows = [
|
|
597
|
+
{ label: 'request', value: id },
|
|
598
|
+
{ label: 'signed by', value: 'you, in your browser' },
|
|
599
|
+
];
|
|
600
|
+
await reviewAndSubmit(ctx, 'Review this agent request', rows, [], 'agent.request.approve', { id });
|
|
601
|
+
});
|
|
602
|
+
agents
|
|
603
|
+
.command('deny <id>')
|
|
604
|
+
.description('There is no deny action - reads the request back and explains why')
|
|
605
|
+
.action(async (id) => {
|
|
606
|
+
const ctx = program.opts().__ctx;
|
|
607
|
+
const token = await requireWalletToken(ctx);
|
|
608
|
+
// The backend has NO decline/deny route for an agent request (`POST /agents/requests` /
|
|
609
|
+
// `GET /agents/requests` / `POST /agents/requests/{id}/approve` is the whole surface - see
|
|
610
|
+
// `AgentsController`), and the web app's own `AgentRequests.tsx` offers no deny action either: a
|
|
611
|
+
// request is either approved, or it expires on its own. Rather than fail with "unknown command"
|
|
612
|
+
// for something a person would reasonably expect, this reads the request back and says so plainly
|
|
613
|
+
// - a read, never a doomed write.
|
|
614
|
+
const requests = await step(ctx, 'Reading agent requests', () => ctx.runtime.api.get('/v1/agents/requests', token));
|
|
615
|
+
const list = Array.isArray(requests) ? requests : [];
|
|
616
|
+
const request = list.find((r) => r.id === id);
|
|
617
|
+
if (!request) {
|
|
618
|
+
throw invalidArgs(`No such agent request: ${id}.`, 'Run `botanary agents` to see open requests.');
|
|
619
|
+
}
|
|
620
|
+
const payload = {
|
|
621
|
+
request,
|
|
622
|
+
message: 'There is no deny action on this surface - an agent request is either approved or left to expire ' +
|
|
623
|
+
'on its own. Nothing was changed.',
|
|
624
|
+
};
|
|
625
|
+
printResult(ctx, payload, () => [
|
|
626
|
+
section(`Agent request ${id}`),
|
|
627
|
+
` status: ${dash(request.status)}`,
|
|
628
|
+
` expires: ${dash(request.expiresAt)}`,
|
|
629
|
+
'',
|
|
630
|
+
colors.muted(` ${payload.message}`),
|
|
631
|
+
].join('\n'));
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
//# sourceMappingURL=authority.js.map
|