run402 2.27.0 → 2.28.0
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 +11 -1
- package/cli.mjs +33 -6
- package/core-dist/allowance.js +24 -1
- package/core-dist/config.js +75 -3
- package/core-dist/profiles.js +196 -0
- package/lib/allowance.mjs +3 -3
- package/lib/config.mjs +16 -1
- package/lib/doctor.mjs +2 -1
- package/lib/init.mjs +5 -1
- package/lib/status.mjs +13 -0
- package/lib/wallet-context.mjs +223 -0
- package/lib/wallet-context.test.mjs +228 -0
- package/lib/wallets.mjs +344 -0
- package/package.json +1 -1
- package/sdk/core-dist/allowance.js +24 -1
- package/sdk/core-dist/config.js +75 -3
- package/sdk/core-dist/profiles.js +196 -0
- package/sdk/dist/credentials.d.ts +16 -0
- package/sdk/dist/credentials.d.ts.map +1 -1
- package/sdk/dist/index.d.ts +24 -0
- package/sdk/dist/index.d.ts.map +1 -1
- package/sdk/dist/index.js +30 -0
- package/sdk/dist/index.js.map +1 -1
- package/sdk/dist/namespaces/wallets.d.ts +35 -0
- package/sdk/dist/namespaces/wallets.d.ts.map +1 -0
- package/sdk/dist/namespaces/wallets.js +50 -0
- package/sdk/dist/namespaces/wallets.js.map +1 -0
- package/sdk/dist/node/credentials.d.ts +2 -1
- package/sdk/dist/node/credentials.d.ts.map +1 -1
- package/sdk/dist/node/credentials.js +11 -1
- package/sdk/dist/node/credentials.js.map +1 -1
package/lib/wallets.mjs
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run402 wallets — manage named wallets (profiles).
|
|
3
|
+
*
|
|
4
|
+
* Each named wallet is a self-contained profile directory under
|
|
5
|
+
* `{config_dir}/profiles/<name>/` with its own key, project keystore, and
|
|
6
|
+
* non-secret meta.json. The reserved `default` wallet lives at the config-dir
|
|
7
|
+
* root (zero migration). Selection (which wallet a normal command uses) is
|
|
8
|
+
* resolved at the CLI edge — see wallet-context.mjs. These subcommands operate
|
|
9
|
+
* on EXPLICIT named targets via core's path-aware helpers, so they are
|
|
10
|
+
* independent of the active selection.
|
|
11
|
+
*
|
|
12
|
+
* Agent-first: JSON to stdout, structured errors to stderr, no interactive
|
|
13
|
+
* prompts (destructive `rm` requires an explicit --yes).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { writeFileSync, readFileSync, existsSync, rmSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { fail } from "./sdk-errors.mjs";
|
|
19
|
+
import { isValidProfileName, getActiveProfile } from "../core-dist/config.js";
|
|
20
|
+
import {
|
|
21
|
+
listProfileNames,
|
|
22
|
+
profileExists,
|
|
23
|
+
profileDir,
|
|
24
|
+
readMeta,
|
|
25
|
+
writeMeta,
|
|
26
|
+
ensureProfileDir,
|
|
27
|
+
removeProfile,
|
|
28
|
+
renameProfile,
|
|
29
|
+
getDefaultWallet,
|
|
30
|
+
setDefaultWallet,
|
|
31
|
+
} from "../core-dist/profiles.js";
|
|
32
|
+
import { readAllowance, saveAllowance } from "../core-dist/allowance.js";
|
|
33
|
+
import { getSdk } from "./sdk.mjs";
|
|
34
|
+
|
|
35
|
+
const DEFAULT = "default";
|
|
36
|
+
const PRIVATE_KEY_RE = /^0x[0-9a-fA-F]{64}$/;
|
|
37
|
+
|
|
38
|
+
const HELP = `run402 wallets — manage named wallets (profiles)
|
|
39
|
+
|
|
40
|
+
Usage:
|
|
41
|
+
run402 wallets list List all wallets (name, label, address, rail, active)
|
|
42
|
+
run402 wallets current Show the resolved active wallet + how it was selected
|
|
43
|
+
run402 wallets new <name> Create a new named wallet (key stays local)
|
|
44
|
+
run402 wallets use <name> Set the global default wallet
|
|
45
|
+
run402 wallets rename <old> <new> Rename a wallet (migrates the default's files when old=default)
|
|
46
|
+
run402 wallets bind [<name>] Write ./.run402.json binding this directory to a wallet
|
|
47
|
+
run402 wallets unbind Remove ./.run402.json
|
|
48
|
+
run402 wallets import <name> --key <path|-> Adopt an existing private key as a named wallet
|
|
49
|
+
run402 wallets rm <name> --yes Delete a wallet and its keys (requires --yes)
|
|
50
|
+
|
|
51
|
+
Selection precedence for normal commands:
|
|
52
|
+
--wallet <name> > RUN402_WALLET > ./.run402.json > 'wallets use' default > default
|
|
53
|
+
|
|
54
|
+
Options:
|
|
55
|
+
--mpp (new) create the wallet on the MPP rail instead of x402
|
|
56
|
+
--key <path|-> (import) read the private key from a file, or '-' for stdin
|
|
57
|
+
--yes (rm) confirm deletion
|
|
58
|
+
|
|
59
|
+
Notes:
|
|
60
|
+
• The reserved 'default' wallet lives at the config-dir root; renaming it moves it under profiles/.
|
|
61
|
+
• .run402.json holds only a wallet NAME (never a key) — safe to commit.
|
|
62
|
+
`;
|
|
63
|
+
|
|
64
|
+
function shortAddr(a) {
|
|
65
|
+
return typeof a === "string" && a.length >= 12 ? `${a.slice(0, 6)}…${a.slice(-4)}` : a ?? null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function out(obj) {
|
|
69
|
+
console.log(JSON.stringify(obj, null, 2));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function requireName(name, what = "wallet name") {
|
|
73
|
+
if (!name) fail({ code: "BAD_USAGE", message: `Missing ${what}.`, hint: "run402 wallets --help" });
|
|
74
|
+
if (name === DEFAULT) return name;
|
|
75
|
+
if (!isValidProfileName(name)) {
|
|
76
|
+
fail({
|
|
77
|
+
code: "BAD_WALLET_NAME",
|
|
78
|
+
message: `Invalid ${what} ${JSON.stringify(name)}.`,
|
|
79
|
+
hint: "Names must match /^[a-z0-9][a-z0-9_-]{0,63}$/ (lowercase letters, digits, '_' and '-').",
|
|
80
|
+
details: { name },
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return name;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function walletInfo(name, active) {
|
|
87
|
+
const meta = readMeta(name);
|
|
88
|
+
let address = meta?.address ?? null;
|
|
89
|
+
let rail = meta?.rail ?? null;
|
|
90
|
+
const label = meta?.label ?? null;
|
|
91
|
+
if (!address) {
|
|
92
|
+
try {
|
|
93
|
+
const a = readAllowance(join(profileDir(name), "allowance.json"));
|
|
94
|
+
address = a?.address ?? null;
|
|
95
|
+
rail = rail ?? a?.rail ?? null;
|
|
96
|
+
} catch {
|
|
97
|
+
/* best-effort */
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { name, label, address, address_short: shortAddr(address), rail, active: name === active };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function activeContext() {
|
|
104
|
+
try {
|
|
105
|
+
const ctx = JSON.parse(process.env.RUN402_ACTIVE_WALLET_JSON || "");
|
|
106
|
+
if (ctx && typeof ctx === "object") return ctx;
|
|
107
|
+
} catch {
|
|
108
|
+
/* not set / malformed */
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function cmdList() {
|
|
114
|
+
const active = getActiveProfile();
|
|
115
|
+
out(listProfileNames().map((n) => walletInfo(n, active)));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function cmdCurrent() {
|
|
119
|
+
const ctx = activeContext();
|
|
120
|
+
const name = ctx?.name ?? getActiveProfile();
|
|
121
|
+
const info = walletInfo(name, name);
|
|
122
|
+
const warnings = [];
|
|
123
|
+
if (ctx?.diverged && ctx.binding) {
|
|
124
|
+
warnings.push({
|
|
125
|
+
code: "WALLET_SELECTION_CONFLICT",
|
|
126
|
+
message: `RUN402_WALLET=${ctx.envName} but ${ctx.binding.file} selects '${ctx.binding.wallet}'.`,
|
|
127
|
+
hint: "Resolve with: --wallet <name>, unset RUN402_WALLET, or run402 wallets unbind.",
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
if (info.label && info.label !== name) {
|
|
131
|
+
warnings.push({
|
|
132
|
+
code: "WALLET_LABEL_DRIFT",
|
|
133
|
+
message: `Local name '${name}' differs from the server label '${info.label}'.`,
|
|
134
|
+
hint: "Run 'run402 wallets rename' to reconcile.",
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
out({
|
|
138
|
+
name,
|
|
139
|
+
source: ctx?.source ?? "unknown",
|
|
140
|
+
source_detail: ctx?.sourceDetail ?? null,
|
|
141
|
+
address: info.address,
|
|
142
|
+
label: info.label,
|
|
143
|
+
warnings,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function cmdNew(args) {
|
|
148
|
+
const name = requireName(args.find((a) => a && !a.startsWith("-")));
|
|
149
|
+
if (name === DEFAULT) {
|
|
150
|
+
fail({ code: "BAD_WALLET_NAME", message: "'default' is reserved. Use 'run402 init' for the default wallet.", details: { name } });
|
|
151
|
+
}
|
|
152
|
+
if (profileExists(name)) {
|
|
153
|
+
fail({ code: "WALLET_EXISTS", message: `A wallet named '${name}' already exists.`, hint: "run402 wallets list", details: { name } });
|
|
154
|
+
}
|
|
155
|
+
const rail = args.includes("--mpp") ? "mpp" : "x402";
|
|
156
|
+
const { generatePrivateKey, privateKeyToAccount } = await import("viem/accounts");
|
|
157
|
+
const privateKey = generatePrivateKey();
|
|
158
|
+
const address = privateKeyToAccount(privateKey).address;
|
|
159
|
+
const created = new Date().toISOString();
|
|
160
|
+
ensureProfileDir(name);
|
|
161
|
+
saveAllowance({ address, privateKey, created, funded: false, rail }, join(profileDir(name), "allowance.json"));
|
|
162
|
+
writeMeta(name, { name, address, label: name, rail, created });
|
|
163
|
+
await maybePushLabel(name, name, address);
|
|
164
|
+
out({ name, address, rail, created: true, next: `run402 wallets use ${name} (or --wallet ${name} <command>)` });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function cmdUse(args) {
|
|
168
|
+
const name = requireName(args.find((a) => a && !a.startsWith("-")));
|
|
169
|
+
if (name !== DEFAULT && !profileExists(name)) {
|
|
170
|
+
fail({ code: "WALLET_NOT_FOUND", message: `No local wallet named '${name}'.`, hint: "run402 wallets list", details: { name } });
|
|
171
|
+
}
|
|
172
|
+
setDefaultWallet(name);
|
|
173
|
+
out({ name, active: true });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function cmdRename(args) {
|
|
177
|
+
const positionals = args.filter((a) => a && !a.startsWith("-"));
|
|
178
|
+
const oldName = requireName(positionals[0], "old wallet name");
|
|
179
|
+
const newName = requireName(positionals[1], "new wallet name");
|
|
180
|
+
if (newName === DEFAULT) {
|
|
181
|
+
fail({ code: "BAD_WALLET_NAME", message: "Cannot rename a wallet to the reserved name 'default'.", details: { name: newName } });
|
|
182
|
+
}
|
|
183
|
+
if (!profileExists(oldName)) {
|
|
184
|
+
fail({ code: "WALLET_NOT_FOUND", message: `No local wallet named '${oldName}'.`, hint: "run402 wallets list", details: { name: oldName } });
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
renameProfile(oldName, newName);
|
|
188
|
+
} catch (e) {
|
|
189
|
+
fail({ code: "WALLET_RENAME_FAILED", message: e?.message ?? "rename failed", details: { from: oldName, to: newName } });
|
|
190
|
+
}
|
|
191
|
+
if (getDefaultWallet() === oldName) setDefaultWallet(newName);
|
|
192
|
+
const meta = readMeta(newName) ?? {};
|
|
193
|
+
let address = meta.address ?? null;
|
|
194
|
+
if (!address) {
|
|
195
|
+
try {
|
|
196
|
+
address = readAllowance(join(profileDir(newName), "allowance.json"))?.address ?? null;
|
|
197
|
+
} catch {
|
|
198
|
+
/* best-effort */
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
writeMeta(newName, { ...meta, name: newName, label: newName, ...(address ? { address } : {}) });
|
|
202
|
+
await maybePushLabel(newName, newName, address);
|
|
203
|
+
out({ from: oldName, to: newName, renamed: true });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function cmdBind(args) {
|
|
207
|
+
let name = args.find((a) => a && !a.startsWith("-"));
|
|
208
|
+
name = name ? requireName(name) : getActiveProfile();
|
|
209
|
+
const file = join(process.cwd(), ".run402.json");
|
|
210
|
+
writeFileSync(file, JSON.stringify({ wallet: name }, null, 2) + "\n");
|
|
211
|
+
const result = {
|
|
212
|
+
wallet: name,
|
|
213
|
+
file: ".run402.json",
|
|
214
|
+
bound: true,
|
|
215
|
+
safe_to_commit: true,
|
|
216
|
+
note: "Safe to commit — contains no secrets, only the wallet name.",
|
|
217
|
+
};
|
|
218
|
+
if (name !== DEFAULT && !profileExists(name)) {
|
|
219
|
+
result.warning = `No local wallet named '${name}' yet — create it with 'run402 wallets new ${name}'.`;
|
|
220
|
+
}
|
|
221
|
+
out(result);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function cmdUnbind() {
|
|
225
|
+
const file = join(process.cwd(), ".run402.json");
|
|
226
|
+
const existed = existsSync(file);
|
|
227
|
+
if (existed) rmSync(file, { force: true });
|
|
228
|
+
out({ file: ".run402.json", unbound: existed });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function cmdImport(args) {
|
|
232
|
+
const name = requireName(args.find((a) => a && !a.startsWith("-")));
|
|
233
|
+
if (name === DEFAULT) {
|
|
234
|
+
fail({ code: "BAD_WALLET_NAME", message: "'default' is reserved.", details: { name } });
|
|
235
|
+
}
|
|
236
|
+
if (profileExists(name)) {
|
|
237
|
+
fail({ code: "WALLET_EXISTS", message: `A wallet named '${name}' already exists.`, details: { name } });
|
|
238
|
+
}
|
|
239
|
+
const keyArg = flagVal(args, "--key");
|
|
240
|
+
if (!keyArg) {
|
|
241
|
+
fail({ code: "BAD_USAGE", message: "--key <path|-> is required for import.", hint: "Use '-' to read the key from stdin." });
|
|
242
|
+
}
|
|
243
|
+
let raw;
|
|
244
|
+
try {
|
|
245
|
+
raw = keyArg === "-" ? readFileSync(0, "utf8") : readFileSync(keyArg, "utf8");
|
|
246
|
+
} catch (e) {
|
|
247
|
+
fail({ code: "FILE_NOT_FOUND", message: `Could not read key from ${keyArg === "-" ? "stdin" : keyArg}: ${e?.message}`, details: { key: keyArg } });
|
|
248
|
+
}
|
|
249
|
+
const privateKey = raw.trim();
|
|
250
|
+
if (!PRIVATE_KEY_RE.test(privateKey)) {
|
|
251
|
+
fail({ code: "BAD_PRIVATE_KEY", message: "Key must be a 0x-prefixed 64-hex secp256k1 private key.", details: { name } });
|
|
252
|
+
}
|
|
253
|
+
const { privateKeyToAccount } = await import("viem/accounts");
|
|
254
|
+
let address;
|
|
255
|
+
try {
|
|
256
|
+
address = privateKeyToAccount(privateKey).address;
|
|
257
|
+
} catch (e) {
|
|
258
|
+
fail({ code: "BAD_PRIVATE_KEY", message: `Invalid private key: ${e?.message}`, details: { name } });
|
|
259
|
+
}
|
|
260
|
+
const created = new Date().toISOString();
|
|
261
|
+
ensureProfileDir(name);
|
|
262
|
+
saveAllowance({ address, privateKey, created, funded: false, rail: "x402" }, join(profileDir(name), "allowance.json"));
|
|
263
|
+
writeMeta(name, { name, address, label: name, rail: "x402", created });
|
|
264
|
+
await maybePushLabel(name, name, address);
|
|
265
|
+
out({ name, address, imported: true });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function cmdRm(args) {
|
|
269
|
+
const name = requireName(args.find((a) => a && !a.startsWith("-")));
|
|
270
|
+
if (name === DEFAULT) {
|
|
271
|
+
fail({ code: "WALLET_PROTECTED", message: "Refusing to remove the reserved 'default' wallet.", details: { name } });
|
|
272
|
+
}
|
|
273
|
+
if (!profileExists(name)) {
|
|
274
|
+
fail({ code: "WALLET_NOT_FOUND", message: `No local wallet named '${name}'.`, hint: "run402 wallets list", details: { name } });
|
|
275
|
+
}
|
|
276
|
+
if (!args.includes("--yes")) {
|
|
277
|
+
fail({
|
|
278
|
+
code: "CONFIRMATION_REQUIRED",
|
|
279
|
+
message: `Removing wallet '${name}' deletes its private key and project keystore. This cannot be undone.`,
|
|
280
|
+
hint: `Re-run with --yes to confirm: run402 wallets rm ${name} --yes`,
|
|
281
|
+
details: { name },
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
removeProfile(name);
|
|
285
|
+
if (getDefaultWallet() === name) setDefaultWallet(DEFAULT);
|
|
286
|
+
out({ name, removed: true });
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function flagVal(args, flag) {
|
|
290
|
+
const i = args.indexOf(flag);
|
|
291
|
+
if (i === -1) return null;
|
|
292
|
+
const v = args[i + 1];
|
|
293
|
+
if (v === undefined || (typeof v === "string" && v.startsWith("-") && v !== "-")) {
|
|
294
|
+
fail({ code: "BAD_FLAG", message: `${flag} requires a value`, details: { flag } });
|
|
295
|
+
}
|
|
296
|
+
return v;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Best-effort server-side label push. Signs with the TARGET wallet's allowance
|
|
301
|
+
* (not the active one) so a just-created/renamed wallet can set its own label.
|
|
302
|
+
*
|
|
303
|
+
* ON by default — the gateway label endpoint is live, and the display label is
|
|
304
|
+
* what makes the wallet name show up cross-machine and in the operator console
|
|
305
|
+
* (WEB). Set `RUN402_WALLET_LABEL_SYNC=0` to disable (fully-offline wallet ops,
|
|
306
|
+
* or hermetic tests). The local folder name is always the source of truth; this
|
|
307
|
+
* only mirrors the display label to the server. Always best-effort — `setLabel`
|
|
308
|
+
* swallows its own errors and this never throws, so wallet creation/rename stays
|
|
309
|
+
* fully functional offline.
|
|
310
|
+
*/
|
|
311
|
+
async function maybePushLabel(name, label, address) {
|
|
312
|
+
if (process.env.RUN402_WALLET_LABEL_SYNC === "0") return;
|
|
313
|
+
if (!address) return;
|
|
314
|
+
try {
|
|
315
|
+
const sdk = getSdk({
|
|
316
|
+
allowancePath: join(profileDir(name), "allowance.json"),
|
|
317
|
+
keystorePath: join(profileDir(name), "projects.json"),
|
|
318
|
+
});
|
|
319
|
+
await sdk.wallets.setLabel(address, label);
|
|
320
|
+
} catch {
|
|
321
|
+
/* best-effort — never block the local operation */
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export async function run(sub, args = []) {
|
|
326
|
+
const rest = Array.isArray(args) ? args : [];
|
|
327
|
+
if (!sub || sub === "--help" || sub === "-h" || rest.includes("--help") || rest.includes("-h")) {
|
|
328
|
+
console.log(HELP);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
switch (sub) {
|
|
332
|
+
case "list": return cmdList();
|
|
333
|
+
case "current": return cmdCurrent();
|
|
334
|
+
case "new": return cmdNew(rest);
|
|
335
|
+
case "use": return cmdUse(rest);
|
|
336
|
+
case "rename": return cmdRename(rest);
|
|
337
|
+
case "bind": return cmdBind(rest);
|
|
338
|
+
case "unbind": return cmdUnbind();
|
|
339
|
+
case "import": return cmdImport(rest);
|
|
340
|
+
case "rm": return cmdRm(rest);
|
|
341
|
+
default:
|
|
342
|
+
fail({ code: "BAD_USAGE", message: `Unknown wallets subcommand: ${sub}`, hint: "run402 wallets --help" });
|
|
343
|
+
}
|
|
344
|
+
}
|
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, renameSync } from "node:fs";
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync, chmodSync, renameSync, statSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
4
|
import { getAllowancePath } from "./config.js";
|
|
@@ -26,10 +26,33 @@ const PRIVATE_KEY_RE = /^0x[a-fA-F0-9]{64}$/;
|
|
|
26
26
|
* `src/tools/{status,init}.ts` callers translate the throw into their own
|
|
27
27
|
* structured envelopes (`code: BAD_ALLOWANCE_FILE`).
|
|
28
28
|
*/
|
|
29
|
+
/**
|
|
30
|
+
* If an allowance file is readable by group or other (any of the low 0o077
|
|
31
|
+
* bits set), tighten it to 0600 and warn once on stderr. This self-heals the
|
|
32
|
+
* historical case where a legacy world-readable `wallet.json` (mode 0644) was
|
|
33
|
+
* migrated to `allowance.json` via a mode-preserving rename, leaving the
|
|
34
|
+
* private key exposed on a shared machine. Best-effort: POSIX-only and silent
|
|
35
|
+
* on platforms without meaningful Unix modes (e.g. Windows).
|
|
36
|
+
*/
|
|
37
|
+
function selfHealPermissions(p) {
|
|
38
|
+
if (process.platform === "win32")
|
|
39
|
+
return;
|
|
40
|
+
try {
|
|
41
|
+
const mode = statSync(p).mode & 0o777;
|
|
42
|
+
if ((mode & 0o077) !== 0) {
|
|
43
|
+
chmodSync(p, 0o600);
|
|
44
|
+
process.stderr.write(`warning: tightened permissions on ${p} from ${mode.toString(8)} to 600 (was readable by other users).\n`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Best-effort; never block a read on a chmod/stat failure.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
29
51
|
export function readAllowance(path) {
|
|
30
52
|
const p = path ?? getAllowancePath();
|
|
31
53
|
if (!existsSync(p))
|
|
32
54
|
return null;
|
|
55
|
+
selfHealPermissions(p);
|
|
33
56
|
let raw;
|
|
34
57
|
try {
|
|
35
58
|
raw = readFileSync(p, "utf-8");
|
package/sdk/core-dist/config.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { existsSync, renameSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { existsSync, renameSync, mkdirSync, chmodSync } from "node:fs";
|
|
4
4
|
const DEFAULT_API_BASE = "https://api.run402.com";
|
|
5
5
|
/**
|
|
6
6
|
* Validate a user-supplied API base URL. Throws a clear error message that
|
|
@@ -47,9 +47,71 @@ export function getDeployApiBase() {
|
|
|
47
47
|
const validated = validateApiBase("RUN402_DEPLOY_API_BASE", process.env.RUN402_DEPLOY_API_BASE, fallback);
|
|
48
48
|
return validated ?? fallback;
|
|
49
49
|
}
|
|
50
|
-
|
|
50
|
+
/**
|
|
51
|
+
* The base credential directory — the root under which the `default` wallet
|
|
52
|
+
* lives directly and named wallets live under `profiles/<name>/`. This is the
|
|
53
|
+
* value `RUN402_CONFIG_DIR` overrides; profiles nest *within* it.
|
|
54
|
+
*/
|
|
55
|
+
export function getConfigBaseDir() {
|
|
51
56
|
return process.env.RUN402_CONFIG_DIR || join(homedir(), ".config", "run402");
|
|
52
57
|
}
|
|
58
|
+
export const DEFAULT_PROFILE = "default";
|
|
59
|
+
// Filesystem-safe wallet/profile name. Lowercase only (avoids collisions on
|
|
60
|
+
// case-insensitive filesystems like macOS), starts alphanumeric, then
|
|
61
|
+
// alphanumeric/underscore/hyphen, max 64 chars. The CLI edge enforces this for
|
|
62
|
+
// nice UX; core re-checks it as a defense-in-depth guard so a hostile
|
|
63
|
+
// `RUN402_WALLET`/`RUN402_PROFILE` cannot traverse outside the profiles dir.
|
|
64
|
+
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
65
|
+
export function isValidProfileName(name) {
|
|
66
|
+
return PROFILE_NAME_RE.test(name);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Guard against path traversal from the profile env vars. The reserved
|
|
70
|
+
* `default` profile is always allowed (it maps to the base dir). Any other
|
|
71
|
+
* name must pass the filesystem-safe pattern; otherwise we throw rather than
|
|
72
|
+
* silently resolve a surprising path.
|
|
73
|
+
*/
|
|
74
|
+
function assertSafeProfileName(name) {
|
|
75
|
+
if (name === DEFAULT_PROFILE)
|
|
76
|
+
return;
|
|
77
|
+
if (!isValidProfileName(name)) {
|
|
78
|
+
throw new Error(`Invalid wallet/profile name ${JSON.stringify(name)}. ` +
|
|
79
|
+
"Names must match /^[a-z0-9][a-z0-9_-]{0,63}$/ (lowercase letters, digits, '_' and '-'). " +
|
|
80
|
+
"Check the RUN402_WALLET / RUN402_PROFILE env var.");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The active wallet/profile name from the environment. `RUN402_WALLET` is
|
|
85
|
+
* canonical; `RUN402_PROFILE` is accepted as an alias. Empty/unset → the
|
|
86
|
+
* reserved `default`. The CLI edge resolves the `--wallet` flag and any
|
|
87
|
+
* per-directory `.run402.json` binding into `RUN402_WALLET` *before* dispatch,
|
|
88
|
+
* so core stays env-only and never reads argv or cwd.
|
|
89
|
+
*/
|
|
90
|
+
export function getActiveProfile() {
|
|
91
|
+
const raw = process.env.RUN402_WALLET ?? process.env.RUN402_PROFILE;
|
|
92
|
+
const name = raw == null ? "" : raw.trim();
|
|
93
|
+
if (!name)
|
|
94
|
+
return DEFAULT_PROFILE;
|
|
95
|
+
assertSafeProfileName(name);
|
|
96
|
+
return name;
|
|
97
|
+
}
|
|
98
|
+
/** Directory holding all named wallets: `{base}/profiles`. */
|
|
99
|
+
export function getProfilesDir() {
|
|
100
|
+
return join(getConfigBaseDir(), "profiles");
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The effective config directory for the *active* wallet. The `default` wallet
|
|
104
|
+
* resolves to the base dir (zero migration for existing single-wallet
|
|
105
|
+
* installs); any named wallet resolves to `{base}/profiles/<name>`. Because
|
|
106
|
+
* keystore, allowance, and meta paths all derive from this, switching the
|
|
107
|
+
* profile env var moves the whole wallet bundle atomically — and the SDK/MCP
|
|
108
|
+
* inherit profile selection for free.
|
|
109
|
+
*/
|
|
110
|
+
export function getConfigDir() {
|
|
111
|
+
const base = getConfigBaseDir();
|
|
112
|
+
const profile = getActiveProfile();
|
|
113
|
+
return profile === DEFAULT_PROFILE ? base : join(base, "profiles", profile);
|
|
114
|
+
}
|
|
53
115
|
export function getKeystorePath() {
|
|
54
116
|
return join(getConfigDir(), "projects.json");
|
|
55
117
|
}
|
|
@@ -59,10 +121,20 @@ export function getAllowancePath() {
|
|
|
59
121
|
const dir = getConfigDir();
|
|
60
122
|
const newPath = join(dir, "allowance.json");
|
|
61
123
|
const oldPath = join(dir, "wallet.json");
|
|
62
|
-
// Auto-migrate from wallet.json → allowance.json
|
|
124
|
+
// Auto-migrate from wallet.json → allowance.json. renameSync preserves the
|
|
125
|
+
// source file's mode, so a legacy world-readable wallet.json (mode 0644)
|
|
126
|
+
// would otherwise carry that mode forward and leave the private key
|
|
127
|
+
// world-readable on a shared machine. Tighten to 0600 after the rename.
|
|
63
128
|
if (!existsSync(newPath) && existsSync(oldPath)) {
|
|
64
129
|
mkdirSync(dir, { recursive: true });
|
|
65
130
|
renameSync(oldPath, newPath);
|
|
131
|
+
try {
|
|
132
|
+
chmodSync(newPath, 0o600);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
// Best-effort (e.g. Windows / exotic filesystems). Read-time self-heal
|
|
136
|
+
// in readAllowance() is the backstop.
|
|
137
|
+
}
|
|
66
138
|
}
|
|
67
139
|
return newPath;
|
|
68
140
|
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named-wallet profile storage.
|
|
3
|
+
*
|
|
4
|
+
* A "wallet" is a whole config directory. The reserved `default` wallet lives
|
|
5
|
+
* at the base config dir (zero migration for existing installs); every named
|
|
6
|
+
* wallet lives under `{base}/profiles/<name>/` with its own `allowance.json`,
|
|
7
|
+
* `projects.json`, and this module's non-secret `meta.json`.
|
|
8
|
+
*
|
|
9
|
+
* Two levels of active state, mirroring the wallet → project model:
|
|
10
|
+
* - base `{base}/config.json` `active_wallet` — which wallet is the default
|
|
11
|
+
* - per-wallet `projects.json` `active_project_id` — which project within it
|
|
12
|
+
*
|
|
13
|
+
* All writes are atomic (temp-file + rename) and owner-only (0600 files,
|
|
14
|
+
* 0700 dirs). `meta.json` holds only public/display data (no private key), so
|
|
15
|
+
* listing and name display never need to load key material.
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync, existsSync, readdirSync, rmSync, } from "node:fs";
|
|
18
|
+
import { dirname, join } from "node:path";
|
|
19
|
+
import { randomBytes } from "node:crypto";
|
|
20
|
+
import { getConfigBaseDir, getProfilesDir, DEFAULT_PROFILE, isValidProfileName, } from "./config.js";
|
|
21
|
+
function atomicWrite(p, content, mode) {
|
|
22
|
+
const dir = dirname(p);
|
|
23
|
+
mkdirSync(dir, { recursive: true });
|
|
24
|
+
const tmp = join(dir, `.${randomBytes(4).toString("hex")}.tmp`);
|
|
25
|
+
writeFileSync(tmp, content, { mode });
|
|
26
|
+
renameSync(tmp, p);
|
|
27
|
+
try {
|
|
28
|
+
chmodSync(p, mode);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
/* best-effort on non-POSIX */
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// --- base config.json (global default wallet pointer) ---
|
|
35
|
+
function baseConfigPath() {
|
|
36
|
+
return join(getConfigBaseDir(), "config.json");
|
|
37
|
+
}
|
|
38
|
+
export function readBaseConfig() {
|
|
39
|
+
try {
|
|
40
|
+
const parsed = JSON.parse(readFileSync(baseConfigPath(), "utf-8"));
|
|
41
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
42
|
+
return parsed;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
/* missing / unreadable / malformed → empty */
|
|
47
|
+
}
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
export function writeBaseConfig(cfg) {
|
|
51
|
+
atomicWrite(baseConfigPath(), JSON.stringify(cfg, null, 2), 0o600);
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* The globally-selected default wallet (set via `wallets use`), or the
|
|
55
|
+
* reserved `default` when unset/invalid. This is precedence rung 4 — below the
|
|
56
|
+
* flag, env var, and per-directory binding.
|
|
57
|
+
*/
|
|
58
|
+
export function getDefaultWallet() {
|
|
59
|
+
const w = readBaseConfig().active_wallet;
|
|
60
|
+
return w && (w === DEFAULT_PROFILE || isValidProfileName(w)) ? w : DEFAULT_PROFILE;
|
|
61
|
+
}
|
|
62
|
+
export function setDefaultWallet(name) {
|
|
63
|
+
const cfg = readBaseConfig();
|
|
64
|
+
cfg.active_wallet = name;
|
|
65
|
+
writeBaseConfig(cfg);
|
|
66
|
+
}
|
|
67
|
+
// --- per-profile directory + meta.json ---
|
|
68
|
+
/** Absolute directory for a wallet. `default` → base dir; named → profiles/<name>. */
|
|
69
|
+
export function profileDir(name) {
|
|
70
|
+
return name === DEFAULT_PROFILE ? getConfigBaseDir() : join(getProfilesDir(), name);
|
|
71
|
+
}
|
|
72
|
+
/** Create (if needed) a wallet's directory with owner-only (0700) perms. */
|
|
73
|
+
export function ensureProfileDir(name) {
|
|
74
|
+
const dir = profileDir(name);
|
|
75
|
+
if (name === DEFAULT_PROFILE) {
|
|
76
|
+
mkdirSync(dir, { recursive: true });
|
|
77
|
+
return dir;
|
|
78
|
+
}
|
|
79
|
+
const profilesRoot = getProfilesDir();
|
|
80
|
+
mkdirSync(profilesRoot, { recursive: true });
|
|
81
|
+
try {
|
|
82
|
+
chmodSync(profilesRoot, 0o700);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
/* best-effort */
|
|
86
|
+
}
|
|
87
|
+
mkdirSync(dir, { recursive: true });
|
|
88
|
+
try {
|
|
89
|
+
chmodSync(dir, 0o700);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
/* best-effort */
|
|
93
|
+
}
|
|
94
|
+
return dir;
|
|
95
|
+
}
|
|
96
|
+
function metaPath(name) {
|
|
97
|
+
return join(profileDir(name), "meta.json");
|
|
98
|
+
}
|
|
99
|
+
export function readMeta(name) {
|
|
100
|
+
try {
|
|
101
|
+
const parsed = JSON.parse(readFileSync(metaPath(name), "utf-8"));
|
|
102
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
103
|
+
return parsed;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
/* missing / unreadable / malformed → null */
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
export function writeMeta(name, meta) {
|
|
112
|
+
ensureProfileDir(name);
|
|
113
|
+
atomicWrite(metaPath(name), JSON.stringify(meta, null, 2), 0o600);
|
|
114
|
+
}
|
|
115
|
+
// --- enumeration + lifecycle ---
|
|
116
|
+
/** True when a wallet's `allowance.json` exists on disk. */
|
|
117
|
+
export function profileExists(name) {
|
|
118
|
+
return existsSync(join(profileDir(name), "allowance.json"));
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* All wallet names on disk: `default` (only if a root allowance.json exists)
|
|
122
|
+
* plus every valid directory under `profiles/`.
|
|
123
|
+
*/
|
|
124
|
+
export function listProfileNames() {
|
|
125
|
+
const names = [];
|
|
126
|
+
if (existsSync(join(getConfigBaseDir(), "allowance.json"))) {
|
|
127
|
+
names.push(DEFAULT_PROFILE);
|
|
128
|
+
}
|
|
129
|
+
try {
|
|
130
|
+
for (const entry of readdirSync(getProfilesDir(), { withFileTypes: true })) {
|
|
131
|
+
if (entry.isDirectory() && isValidProfileName(entry.name))
|
|
132
|
+
names.push(entry.name);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
/* no profiles dir yet */
|
|
137
|
+
}
|
|
138
|
+
return names;
|
|
139
|
+
}
|
|
140
|
+
/** Delete a named wallet's directory. Refuses to remove the reserved default. */
|
|
141
|
+
export function removeProfile(name) {
|
|
142
|
+
if (name === DEFAULT_PROFILE) {
|
|
143
|
+
throw new Error("Refusing to remove the reserved 'default' wallet");
|
|
144
|
+
}
|
|
145
|
+
rmSync(profileDir(name), { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Move a wallet to a new name. Renaming `default` migrates the root-level
|
|
149
|
+
* credential files into `profiles/<newName>/` (so a named wallet is always a
|
|
150
|
+
* directory). Does NOT update the `active_wallet` pointer — the caller owns
|
|
151
|
+
* that orchestration. Throws if the destination already exists or the source
|
|
152
|
+
* is missing.
|
|
153
|
+
*/
|
|
154
|
+
export function renameProfile(oldName, newName) {
|
|
155
|
+
if (!isValidProfileName(newName)) {
|
|
156
|
+
throw new Error(`Invalid wallet name ${JSON.stringify(newName)}.`);
|
|
157
|
+
}
|
|
158
|
+
if (newName === oldName)
|
|
159
|
+
return;
|
|
160
|
+
const dest = join(getProfilesDir(), newName);
|
|
161
|
+
if (existsSync(dest)) {
|
|
162
|
+
throw new Error(`A wallet named '${newName}' already exists.`);
|
|
163
|
+
}
|
|
164
|
+
mkdirSync(getProfilesDir(), { recursive: true });
|
|
165
|
+
try {
|
|
166
|
+
chmodSync(getProfilesDir(), 0o700);
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
/* best-effort */
|
|
170
|
+
}
|
|
171
|
+
if (oldName === DEFAULT_PROFILE) {
|
|
172
|
+
if (!profileExists(DEFAULT_PROFILE)) {
|
|
173
|
+
throw new Error("No 'default' wallet to rename.");
|
|
174
|
+
}
|
|
175
|
+
mkdirSync(dest, { recursive: true });
|
|
176
|
+
try {
|
|
177
|
+
chmodSync(dest, 0o700);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
/* best-effort */
|
|
181
|
+
}
|
|
182
|
+
const base = getConfigBaseDir();
|
|
183
|
+
for (const f of ["allowance.json", "projects.json", "meta.json"]) {
|
|
184
|
+
const src = join(base, f);
|
|
185
|
+
if (existsSync(src))
|
|
186
|
+
renameSync(src, join(dest, f));
|
|
187
|
+
}
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
const src = join(getProfilesDir(), oldName);
|
|
191
|
+
if (!existsSync(src)) {
|
|
192
|
+
throw new Error(`Wallet '${oldName}' not found.`);
|
|
193
|
+
}
|
|
194
|
+
renameSync(src, dest);
|
|
195
|
+
}
|
|
196
|
+
//# sourceMappingURL=profiles.js.map
|