Skip to content

Commit d528e47

Browse files
MajorTalclaude
andcommitted
feat(wallets): named multi-wallet profiles + per-directory binding
Hold multiple named wallets on one machine and select between them, fully non-custodial. Implements run402-public #413. - core: profile-aware getConfigDir() (RUN402_WALLET/RUN402_PROFILE), profiles.ts (base config.json active_wallet, per-profile meta.json, 0700 dirs), and a credential-permission fix (chmod 0600 on the wallet.json->allowance.json migration + read-time self-heal). - cli: wallet-context.mjs resolves the active wallet at the edge (--wallet > RUN402_WALLET > .run402.json binding > 'wallets use' > default), errors on env-vs-binding conflict, fails closed on unknown wallets, emits a provenance line for non-default selections. New 'run402 wallets' family (list/current/new/use/rename/bind/unbind/import/rm). billing's --wallet flag renamed to --wallet-address (the global selector takes --wallet). - sdk: r.whoami() + NodeCredentialsProvider.getWalletIdentity(); r.wallets getLabel/setLabel for the server-side display label (signed, proof-of-control). - mcp: status tool surfaces the active wallet (from RUN402_WALLET). - The display label is synced to the gateway (run402-private #414, live) so the name shows cross-machine and in the operator console; on by default, RUN402_WALLET_LABEL_SYNC=0 opts out. - docs (AGENTS/README/cli-README/SKILL/openclaw-SKILL/llms-cli), sync SURFACE + OpenClaw parity, and full unit + e2e coverage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e4bbee9 commit d528e47

40 files changed

Lines changed: 2403 additions & 32 deletions

AGENTS.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,5 +277,8 @@ Two skill files coexist, serving different runtimes:
277277
| Variable | Default | Purpose |
278278
|----------|---------|---------|
279279
| `RUN402_API_BASE` | `https://api.run402.com` | API base URL (override for testing/staging) |
280-
| `RUN402_CONFIG_DIR` | `~/.config/run402` | Local credential storage directory |
280+
| `RUN402_CONFIG_DIR` | `~/.config/run402` | Local credential storage **base** directory. The `default` wallet lives here directly; named wallets live under `{base}/profiles/<name>/`. |
281+
| `RUN402_WALLET` | `default` | Active named wallet (profile). Selects which wallet a command uses; the `--wallet <name>` flag overrides it, and a per-directory `.run402.json` binding sets it. `RUN402_PROFILE` is an accepted alias. |
281282
| `RUN402_ALLOWANCE_PATH` | `{config_dir}/allowance.json` | Custom allowance (wallet) file path |
283+
284+
**Named wallets (profiles).** Hold multiple wallets on one machine via `run402 wallets` (`list`, `new <name>`, `use <name>`, `rename`, `bind`/`unbind`, `import`, `rm`). Selection precedence: `--wallet <name>` flag > `RUN402_WALLET` env > nearest `.run402.json`/`.run402.local.json` directory binding > global `wallets use` default > `default`. A `--wallet`/binding that conflicts with `RUN402_WALLET` is a hard error (pass `--wallet`, unset the env, or `wallets unbind`). The binding file holds only a wallet *name* (no key) and is safe to commit. Each wallet has its own `allowance.json`, `projects.json`, and non-secret `meta.json` under `profiles/<name>/`; the human-readable name surfaces in `run402 status`, `r.whoami()` (SDK), the MCP `status` tool, and — via a signed server-side label (`r.wallets.setLabel`/`getLabel`, gateway endpoint `/wallets/v1/:address/label`) — the operator console (WEB). The label push is on by default (`RUN402_WALLET_LABEL_SYNC=0` opts out). Core path resolution lives in `core/src/config.ts` (`getConfigBaseDir`/`getActiveProfile`/`getConfigDir`) + `core/src/profiles.ts`; CLI-edge resolution in `cli/lib/wallet-context.mjs`.

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -545,7 +545,8 @@ The full MCP surface — every tool is a thin shim over an SDK call.
545545
| Variable | Default | Purpose |
546546
|----------|---------|---------|
547547
| `RUN402_API_BASE` | `https://api.run402.com` | API base URL (override for staging) |
548-
| `RUN402_CONFIG_DIR` | `~/.config/run402` | Local credential storage directory |
548+
| `RUN402_CONFIG_DIR` | `~/.config/run402` | Local credential storage base directory (named wallets live under `profiles/<name>/`) |
549+
| `RUN402_WALLET` | `default` | Active named wallet (profile). Overridden by `--wallet <name>` and per-directory `.run402.json`; `RUN402_PROFILE` is an alias. See `run402 wallets`. |
549550
| `RUN402_ALLOWANCE_PATH` | `{config_dir}/allowance.json` | Custom allowance file path |
550551

551552
Local state lives at:

SKILL.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -561,7 +561,9 @@ For agents that need to sign Ethereum transactions. Private keys never leave AWS
561561
### Allowance & account
562562

563563
- **`init`** — one-shot setup: allowance + faucet + tier check + project list.
564-
- **`status`** — full account snapshot.
564+
- **`status`** — full account snapshot. Includes a `wallet` object naming the active named wallet.
565+
566+
**Multiple wallets.** A user can hold several named wallets (profiles) on one machine — keys never leave the machine. The MCP server picks its wallet from the `RUN402_WALLET` environment variable in your server config (default `default`); set it to a wallet name (e.g. `kychon`) to operate that wallet's projects. The `status` tool surfaces which wallet is active. Wallet creation/selection/binding is done from the CLI (`run402 wallets …`), not via MCP tools.
565567
- **`allowance_status`** / **`allowance_create`** / **`allowance_export`** — local allowance management.
566568
- **`request_faucet`** — testnet USDC.
567569
- **`check_balance`** — USDC for an allowance address.

cli-help.test.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ const MATRIX = {
5353
shared: ["status", "create", "fund", "balance", "export"],
5454
specific: ["checkout", "history"],
5555
},
56+
wallets: {
57+
shared: ["list", "current", "new", "use", "rename", "bind", "unbind", "import", "rm"],
58+
specific: [],
59+
},
5660
tier: { shared: [], specific: ["status", "set"] },
5761
projects: {
5862
shared: [

cli-wallets.test.mjs

Lines changed: 181 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
/**
2+
* End-to-end tests for the `run402 wallets` command family and wallet
3+
* selection (named profiles). Spawns the real CLI as a subprocess. Wallet
4+
* management is local (no network), so no fetch mocking is needed.
5+
*/
6+
7+
import { describe, it, beforeEach, afterEach } from "node:test";
8+
import assert from "node:assert/strict";
9+
import { spawnSync } from "node:child_process";
10+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync } from "node:fs";
11+
import { join } from "node:path";
12+
import { tmpdir } from "node:os";
13+
import { fileURLToPath } from "node:url";
14+
15+
const repoRoot = fileURLToPath(new URL(".", import.meta.url));
16+
const CLI = join(repoRoot, "cli/cli.mjs");
17+
const API = "https://test-api.run402.com";
18+
19+
let configDir;
20+
let workDir;
21+
22+
beforeEach(() => {
23+
configDir = mkdtempSync(join(tmpdir(), "run402-wallets-cfg-"));
24+
workDir = mkdtempSync(join(tmpdir(), "run402-wallets-cwd-"));
25+
});
26+
27+
afterEach(() => {
28+
rmSync(configDir, { recursive: true, force: true });
29+
rmSync(workDir, { recursive: true, force: true });
30+
});
31+
32+
function run(args, { cwd = workDir, env = {} } = {}) {
33+
// RUN402_WALLET_LABEL_SYNC=0 keeps these tests offline/hermetic — the
34+
// server-side label push is on by default in real use.
35+
const base = { ...process.env, RUN402_CONFIG_DIR: configDir, RUN402_API_BASE: API, RUN402_WALLET_LABEL_SYNC: "0" };
36+
delete base.RUN402_WALLET;
37+
delete base.RUN402_PROFILE;
38+
const result = spawnSync(process.execPath, [CLI, ...args], {
39+
cwd,
40+
env: { ...base, ...env },
41+
encoding: "utf-8",
42+
});
43+
return result;
44+
}
45+
46+
function jsonOut(result) {
47+
try {
48+
return JSON.parse(result.stdout);
49+
} catch {
50+
throw new Error(`stdout not JSON: ${result.stdout}\nstderr: ${result.stderr}`);
51+
}
52+
}
53+
54+
function errEnvelope(result) {
55+
const line = result.stderr.trim().split("\n").filter(Boolean).pop();
56+
return JSON.parse(line);
57+
}
58+
59+
describe("wallets — lifecycle", () => {
60+
it("new → list → rm", () => {
61+
assert.equal(run(["wallets", "new", "kychon"]).status, 0);
62+
const list = jsonOut(run(["wallets", "list"]));
63+
assert.deepEqual(list.map((w) => w.name).sort(), ["kychon"]);
64+
assert.equal(list[0].label, "kychon");
65+
// rm requires --yes
66+
const noConfirm = run(["wallets", "rm", "kychon"]);
67+
assert.notEqual(noConfirm.status, 0);
68+
assert.equal(errEnvelope(noConfirm).code, "CONFIRMATION_REQUIRED");
69+
assert.equal(run(["wallets", "rm", "kychon", "--yes"]).status, 0);
70+
assert.deepEqual(jsonOut(run(["wallets", "list"])), []);
71+
});
72+
73+
it("rename default migrates the root wallet into profiles/", () => {
74+
assert.equal(run(["allowance", "create"]).status, 0); // creates the default wallet locally
75+
assert.ok(existsSync(join(configDir, "allowance.json")));
76+
assert.equal(run(["wallets", "rename", "default", "kychon"]).status, 0);
77+
assert.ok(!existsSync(join(configDir, "allowance.json")), "root allowance.json migrated away");
78+
assert.ok(existsSync(join(configDir, "profiles", "kychon", "allowance.json")));
79+
assert.deepEqual(jsonOut(run(["wallets", "list"])).map((w) => w.name), ["kychon"]);
80+
});
81+
});
82+
83+
describe("wallets — selection precedence", () => {
84+
beforeEach(() => {
85+
run(["wallets", "new", "kychon"]);
86+
run(["wallets", "new", "client-a"]);
87+
});
88+
89+
it("flag wins and reports source=flag", () => {
90+
const cur = jsonOut(run(["--wallet", "kychon", "wallets", "current"]));
91+
assert.equal(cur.name, "kychon");
92+
assert.equal(cur.source, "flag");
93+
});
94+
95+
it("env selects when no flag", () => {
96+
const cur = jsonOut(run(["wallets", "current"], { env: { RUN402_WALLET: "client-a" } }));
97+
assert.equal(cur.name, "client-a");
98+
assert.equal(cur.source, "env");
99+
});
100+
101+
it("directory binding selects when no flag/env", () => {
102+
writeFileSync(join(workDir, ".run402.json"), JSON.stringify({ wallet: "client-a" }));
103+
const cur = jsonOut(run(["wallets", "current"]));
104+
assert.equal(cur.name, "client-a");
105+
assert.equal(cur.source, "binding");
106+
});
107+
108+
it(".run402.local.json overrides .run402.json and walks up from a subdir", () => {
109+
writeFileSync(join(workDir, ".run402.json"), JSON.stringify({ wallet: "client-a" }));
110+
writeFileSync(join(workDir, ".run402.local.json"), JSON.stringify({ wallet: "kychon" }));
111+
const sub = join(workDir, "api");
112+
mkdirSync(sub);
113+
const cur = jsonOut(run(["wallets", "current"], { cwd: sub }));
114+
assert.equal(cur.name, "kychon");
115+
});
116+
117+
it("global `wallets use` applies when no flag/env/binding", () => {
118+
assert.equal(run(["wallets", "use", "kychon"]).status, 0);
119+
const cur = jsonOut(run(["wallets", "current"]));
120+
assert.equal(cur.name, "kychon");
121+
assert.equal(cur.source, "config");
122+
});
123+
124+
it("falls back to default when nothing selects", () => {
125+
const cur = jsonOut(run(["wallets", "current"]));
126+
assert.equal(cur.name, "default");
127+
assert.equal(cur.source, "default");
128+
});
129+
});
130+
131+
describe("wallets — conflict + fail-closed", () => {
132+
beforeEach(() => {
133+
run(["wallets", "new", "kychon"]);
134+
run(["wallets", "new", "client-a"]);
135+
});
136+
137+
it("env vs binding mismatch is a hard error on a normal command", () => {
138+
writeFileSync(join(workDir, ".run402.json"), JSON.stringify({ wallet: "client-a" }));
139+
const r = run(["allowance", "export"], { env: { RUN402_WALLET: "kychon" } });
140+
assert.notEqual(r.status, 0);
141+
const env = errEnvelope(r);
142+
assert.equal(env.code, "WALLET_SELECTION_CONFLICT");
143+
assert.match(env.message, /kychon/);
144+
assert.match(env.message, /client-a/);
145+
});
146+
147+
it("--wallet resolves the conflict", () => {
148+
writeFileSync(join(workDir, ".run402.json"), JSON.stringify({ wallet: "client-a" }));
149+
const r = run(["--wallet", "kychon", "allowance", "export"], { env: { RUN402_WALLET: "client-a" } });
150+
assert.equal(r.status, 0, r.stderr);
151+
});
152+
153+
it("selecting an unknown wallet fails closed on a normal command", () => {
154+
const r = run(["--wallet", "ghost", "allowance", "export"]);
155+
assert.notEqual(r.status, 0);
156+
assert.equal(errEnvelope(r).code, "WALLET_NOT_FOUND");
157+
});
158+
159+
it("the wallets group itself is never blocked by a conflict (can unbind)", () => {
160+
writeFileSync(join(workDir, ".run402.json"), JSON.stringify({ wallet: "client-a" }));
161+
const r = run(["wallets", "unbind"], { env: { RUN402_WALLET: "kychon" } });
162+
assert.equal(r.status, 0, r.stderr);
163+
assert.ok(!existsSync(join(workDir, ".run402.json")));
164+
});
165+
});
166+
167+
describe("wallets — provenance", () => {
168+
it("emits a provenance line for a non-default selection", () => {
169+
run(["wallets", "new", "kychon"]);
170+
const r = run(["--wallet", "kychon", "allowance", "export"]);
171+
assert.equal(r.status, 0, r.stderr);
172+
assert.match(r.stderr, /wallet: kychon/);
173+
});
174+
175+
it("stays silent for the default wallet", () => {
176+
run(["allowance", "create"]); // default wallet
177+
const r = run(["allowance", "export"]);
178+
assert.equal(r.status, 0, r.stderr);
179+
assert.ok(!/ wallet:/.test(r.stderr), `expected no provenance line, got: ${r.stderr}`);
180+
});
181+
});

cli/README.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,8 +193,18 @@ Local state lives at:
193193

194194
- `~/.config/run402/projects.json` (`0600`) — project credentials (`anon_key`, `service_key`, `tier`, `lease_expires_at`)
195195
- `~/.config/run402/allowance.json` (`0600`) — wallet for x402 signing
196+
- `~/.config/run402/config.json` (`0600`) — global default wallet pointer (`active_wallet`)
197+
- `~/.config/run402/profiles/<name>/` (`0700`) — named wallets, each with its own `allowance.json` + `projects.json` + non-secret `meta.json`
196198

197-
Override with `RUN402_CONFIG_DIR` or `RUN402_ALLOWANCE_PATH`. Override the API base with `RUN402_API_BASE`.
199+
Override the base directory with `RUN402_CONFIG_DIR` or the allowance file with `RUN402_ALLOWANCE_PATH`. Override the API base with `RUN402_API_BASE`.
200+
201+
### Named wallets (profiles)
202+
203+
Hold several wallets on one machine and select between them:
204+
205+
- `run402 wallets list | new <name> | use <name> | rename <old> <new> | bind [<name>] | unbind | import <name> --key <path|-> | rm <name> --yes`
206+
- Select per-command with `--wallet <name>` (alias `--profile`), the `RUN402_WALLET` env var, or a per-directory `.run402.json` (commit-safe — holds only a name) resolved by walking up the tree. Precedence: flag > env > binding > `wallets use` default > `default`. A conflicting env + binding is a hard error.
207+
- The active wallet name shows in `run402 status` and `run402 wallets current`.
198208

199209
The CLI handles all x402 payment signing automatically — never ask the human for a private key or set up payment libraries by hand.
200210

cli/cli.mjs

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
import { readFileSync } from "node:fs";
88

9-
const [,, cmd, sub, ...rest] = process.argv;
9+
const rawArgv = process.argv.slice(2);
1010

1111
const { version } = JSON.parse(
1212
readFileSync(new URL("./package.json", import.meta.url), "utf8")
@@ -22,6 +22,7 @@ Commands:
2222
init Set up allowance, funding, and check tier status (x402 default)
2323
init mpp Set up with MPP payment rail (Tempo Moderato testnet)
2424
status Show full account state (allowance, balance, tier, projects)
25+
wallets Manage multiple named wallets (list, new, use, rename, bind, import)
2526
allowance Manage your agent allowance (create, fund, balance, status)
2627
tier Manage tier subscription (status, set)
2728
projects Manage projects (provision, list, query, inspect, delete)
@@ -53,6 +54,10 @@ Commands:
5354
dev Run Astro dev with Run402 env + credentials in scope
5455
logs Fetch function logs by request id (--request-id req_...)
5556
57+
Global options (any command):
58+
--wallet <name> Select a named wallet for this command (see 'run402 wallets')
59+
Also: RUN402_WALLET env, or a ./.run402.json directory binding.
60+
5661
Run 'run402 <command> --help' for detailed usage of each command.
5762
5863
Examples:
@@ -74,22 +79,39 @@ Getting started:
7479
run402 ci link github --project prj_... --manifest run402.deploy.json
7580
`;
7681

77-
if (cmd === '--version' || cmd === '-v') {
82+
const first = rawArgv[0];
83+
84+
if (first === '--version' || first === '-v') {
7885
console.log(version);
7986
process.exit(0);
8087
}
8188

82-
if (!cmd || cmd === '--help' || cmd === '-h') {
89+
if (first === undefined || first === '--help' || first === '-h') {
8390
console.log(HELP);
8491
process.exit(0);
8592
}
8693

94+
// Resolve the active wallet/profile from the global --wallet/--profile flag,
95+
// env, and any per-directory .run402.json binding BEFORE dispatch loads a
96+
// subcommand (whose config.mjs snapshots credential paths). splitWalletFlag
97+
// also strips the global flag so subcommands never see it.
98+
const { splitWalletFlag, applyWalletSelection } = await import("./lib/wallet-context.mjs");
99+
const { argv, walletFlag } = splitWalletFlag(rawArgv);
100+
const [cmd, sub, ...rest] = argv;
101+
87102
try {
103+
applyWalletSelection({
104+
walletFlag,
105+
cmd,
106+
cwd: process.cwd(),
107+
env: process.env,
108+
quiet: rawArgv.includes("--quiet"),
109+
});
88110
await dispatch();
89111
} catch (err) {
90-
// Surface env/config errors (e.g. invalid RUN402_API_BASE) as a clean
91-
// JSON envelope on stderr instead of a raw stack trace. We import the
92-
// helper lazily so a broken env doesn't fail this catch handler too.
112+
// Surface env/config errors (e.g. invalid RUN402_API_BASE, bad RUN402_WALLET)
113+
// as a clean JSON envelope on stderr instead of a raw stack trace. We import
114+
// the helper lazily so a broken env doesn't fail this catch handler too.
93115
const { fail } = await import("./lib/sdk-errors.mjs");
94116
fail({
95117
code: "BAD_ENV",
@@ -112,6 +134,11 @@ switch (cmd) {
112134
await run([sub, ...rest].filter(Boolean));
113135
break;
114136
}
137+
case "wallets": {
138+
const { run } = await import("./lib/wallets.mjs");
139+
await run(sub, rest);
140+
break;
141+
}
115142
case "allowance": {
116143
const { run } = await import("./lib/allowance.mjs");
117144
await run(sub, rest);

cli/lib/allowance.mjs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { readAllowance, saveAllowance, ALLOWANCE_FILE } from "./config.mjs";
1+
import { readAllowance, saveAllowance, allowanceFile } from "./config.mjs";
22
import { getSdk } from "./sdk.mjs";
33
import { reportSdkError, fail } from "./sdk-errors.mjs";
44
import { assertKnownFlags, flagValue, normalizeArgv, parseIntegerFlag, positionalArgs } from "./argparse.mjs";
@@ -98,7 +98,7 @@ async function status() {
9898
// now" check, use `run402 allowance balance`.
9999
faucet_used: !!data.faucet_used,
100100
rail: w?.rail || "x402",
101-
path: data.path ?? ALLOWANCE_FILE,
101+
path: data.path ?? allowanceFile(),
102102
},
103103
}));
104104
} catch (err) {
@@ -111,7 +111,7 @@ async function create() {
111111
const result = await getSdk().allowance.create();
112112
console.log(JSON.stringify({
113113
address: result.address,
114-
path: result.path ?? ALLOWANCE_FILE,
114+
path: result.path ?? allowanceFile(),
115115
created: true,
116116
}));
117117
} catch (err) {

cli/lib/config.mjs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,24 @@ import { loadKeyStore, getProject, saveProject, updateProject, removeProject, sa
99
import { getAllowanceAuthHeaders as coreGetAllowanceAuthHeaders } from "../core-dist/allowance-auth.js";
1010
import { fail } from "./sdk-errors.mjs";
1111

12+
// Wallet-dependent paths are exposed as getters (preferred — they always
13+
// reflect the active profile, even if some future code path imports this module
14+
// before wallet resolution). Production code (init/doctor/allowance) uses these.
15+
export function configDir() { return getConfigDir(); }
16+
export function allowanceFile() { return getAllowancePath(); }
17+
export function projectsFile() { return getKeystorePath(); }
18+
19+
// Snapshot constants, retained for backward compatibility (tests, the OpenClaw
20+
// config re-export). These are evaluated when this module is first imported.
21+
// That is safe in every real flow because the CLI resolves the active wallet
22+
// (publishing RUN402_WALLET) in cli.mjs BEFORE any subcommand imports this
23+
// module, and tests set RUN402_CONFIG_DIR before importing. New code should
24+
// prefer the getters above.
1225
export const CONFIG_DIR = getConfigDir();
1326
export const ALLOWANCE_FILE = getAllowancePath();
1427
export const PROJECTS_FILE = getKeystorePath();
28+
29+
// API base is independent of the active wallet, so a module-load snapshot is safe.
1530
export const API = getApiBase();
1631

1732
/**
@@ -32,7 +47,7 @@ export function readAllowance() {
3247
code: "BAD_ALLOWANCE_FILE",
3348
message: err?.message ?? "allowance.json is malformed",
3449
hint: "Back up ~/.config/run402/allowance.json and run 'run402 init' to recreate it.",
35-
details: { path: ALLOWANCE_FILE },
50+
details: { path: allowanceFile() },
3651
});
3752
}
3853
}

0 commit comments

Comments
 (0)