Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,39 @@ jobs:
- name: Published-artifact smoke test
run: |
mkdir -p .artifacts .smoke-prefix
# Pack core too and install both tarballs as roots. Installing only the CLI
# made npm resolve @blockrun/core from the registry, so this step verified the
# LAST PUBLISHED core rather than the code under review — and any core version
# bump failed here with ETARGET until it had already shipped.
(cd packages/core && pnpm pack --pack-destination ../../.artifacts)
(cd packages/cli && pnpm pack --pack-destination ../../.artifacts)
npm install --global --prefix "$PWD/.smoke-prefix" .artifacts/blockrun-cli-*.tgz
npm install --global --prefix "$PWD/.smoke-prefix" \
.artifacts/blockrun-core-*.tgz .artifacts/blockrun-cli-*.tgz
.smoke-prefix/bin/blockrun --json version

- name: Packed artifact honours canonical wallet selection
run: |
# A provider wallet.json must never displace ~/.blockrun/.session, and must
# never be reported under an address it holds no key for. Asserted against the
# packed artifact because that is what users actually install.
H="$(mktemp -d)"
mkdir -p "$H/.blockrun" "$H/.other"
# Hardhat account #0 — the user's real wallet.
echo "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" > "$H/.blockrun/.session"
sleep 1 # ensure the planted file is strictly newer
# Hardhat account #1's key, falsely claiming account #0's address.
echo '{"privateKey":"0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d","address":"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266"}' > "$H/.other/wallet.json"

active=$(BLOCKRUN_HOME="$H" BLOCKRUN_WALLET_KEY= BASE_CHAIN_WALLET_KEY= \
.smoke-prefix/bin/blockrun --json wallet)
echo "$active"
echo "$active" | grep -q '"address":"0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266","source":"session"' \
|| { echo "::error::a provider wallet.json displaced the canonical wallet"; exit 1; }

# The planted address must not be adoptable either. err() writes the envelope
# to stderr and exits non-zero, so capture both streams.
adopt=$(BLOCKRUN_HOME="$H" BLOCKRUN_WALLET_KEY= BASE_CHAIN_WALLET_KEY= \
.smoke-prefix/bin/blockrun --json wallet adopt 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 2>&1 || true)
echo "$adopt"
echo "$adopt" | grep -q '"ok":false' \
|| { echo "::error::adopted an address no discovered key controls"; exit 1; }
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@ dist/
*.tgz
*.tsbuildinfo
.DS_Store
.artifacts/
.smoke-prefix/
45 changes: 45 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,51 @@

All notable changes to the BlockRun CLI are documented here.

## Unreleased — `@blockrun/core` 0.1.0

### Security: a provider wallet could take over payment signing

`resolveFromFiles()` consulted `~/.<app>/wallet.json` files **before** the canonical
`~/.blockrun/.session`, and returned the most recently modified one. Installing another
product — or writing a single file into the home directory — therefore changed which key
`resolvePrivateKey()` handed to x402 payment signing, across `blockrun api`, `pay`, `chat`,
`run`, `image`, `video`, `music`, `speech`, and the data commands. `scanWallets()` also
reported each file's self-declared `address` field, so `blockrun wallet recover` would
display an address the file held no key for.

This is the same defect fixed in `@blockrun/llm` on 2026-07-19
([blockrun-llm-ts#14](https://github.com/BlockRunAI/blockrun-llm-ts/pull/14)); core kept the
pre-fix behavior while its own header comment claimed to mirror the SDK. Core's README
already documented the correct order — the implementation, not the contract, was wrong.

- `resolveFromFiles()` now reads `.session` → legacy `wallet.key` only. Discovered provider
wallets never participate in automatic resolution.
- `scanWallets()` derives each address from the discovered private key and drops entries
whose key is missing or unusable. The file's `address` field is no longer trusted anywhere.
- `WalletSource` no longer includes `"provider"` — after this change it was never a reachable
resolution result. **Breaking** for anything narrowing on that member.
- Added `listDiscoveredWallets()` (addresses + source paths, no private keys) and
`adoptWallet(address)`, the deliberate migration path. Adoption matches on the *derived*
address and backs up the outgoing `.session` first, so funds are never stranded.

### CI

- The published-artifact smoke test packed only the CLI, so npm resolved
`@blockrun/core` from the registry. It was therefore verifying the **last published**
core rather than the code under review — which is how core drifted three weeks behind
the SDK unnoticed — and any core version bump failed the step with `ETARGET` until it
had already shipped. Both packages are now packed and installed as roots.
- The smoke step now asserts the security property against the packed artifact: a
provider `wallet.json` must not displace `.session`, and an address no discovered key
controls must not be adoptable. Verified to fail against the pre-fix build.

### CLI

- Added `blockrun wallet list` and `blockrun wallet adopt <address>`.
- `blockrun wallet recover` now reports entries in true resolution order, marks exactly one
`active`, and lists discovered provider wallets as inactive with their file path. Its
`meta.active` previously named a provider wallet that resolution would not actually use.

## 0.1.1 — 2026-07-17

- Fixed the globally installed `blockrun` executable: npm creates a symlink for
Expand Down
45 changes: 38 additions & 7 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ import {
loadWallet,
createWallet,
importWallet,
adoptWallet,
listDiscoveredWallets,
addressFromKey,
scanWallets,
resolveChain,
paths,
ok,
Expand Down Expand Up @@ -130,18 +131,46 @@ export function runCore(command: string, args: ParsedArgs): Envelope {
}
return ok({ address: w.address, privateKey: w.privateKey, source: w.source });
}
if (sub === "list") {
// Wallets belonging to other applications. None of these are active.
const discovered = listDiscoveredWallets();
if (!discovered.length) return err("wallet", "no wallets discovered from other applications", 404);
return ok(
discovered.map((w) => ({ address: w.address, source: w.source, active: false })),
{ hint: "adopt one deliberately with `blockrun wallet adopt <address>`" }
);
}
if (sub === "adopt") {
const address = args.rest[1]?.trim();
if (!address) return err("usage", "usage: blockrun wallet adopt <address>", 400);
try {
const w = adoptWallet(address);
return ok({ address: w.address, adopted: true }, { hint: "previous wallet backed up in ~/.blockrun/" });
} catch (e) {
return err("wallet", (e as Error).message, 404);
}
}
if (sub === "recover") {
// Show every wallet the resolver can currently see, in priority order.
const found: Array<Record<string, string>> = [];
// Every key on this machine, in true resolution order. Only env/session/legacy
// can ever be active; discovered provider wallets are shown but never selected.
const found: Array<Record<string, string | boolean>> = [];
const env = process.env.BLOCKRUN_WALLET_KEY || process.env.BASE_CHAIN_WALLET_KEY;
if (env) found.push({ source: "env", address: safeAddr(env) });
for (const s of scanWallets()) found.push({ source: "provider wallet.json", address: s.address });
const p = paths();
for (const [src, file] of [["session", p.session], ["legacy", p.legacy]] as const) {
if (fs.existsSync(file)) found.push({ source: src, address: safeAddr(fs.readFileSync(file, "utf8").trim()) });
}
if (!found.length) return err("wallet", "no recoverable wallets found (env, provider, session, legacy)", 404);
return ok(found, { active: found[0].source });
// The resolver stops at the first of the above; everything after is inactive.
const activeSource = found.length ? (found[0].source as string) : null;
for (const [i, entry] of found.entries()) entry.active = i === 0;
for (const w of listDiscoveredWallets()) {
found.push({ source: `provider wallet.json (${w.source})`, address: w.address, active: false });
}
if (!found.length) return err("wallet", "no recoverable wallets found (env, session, legacy, provider)", 404);
return ok(found, {
active: activeSource,
...(activeSource ? {} : { hint: "no active wallet — run `blockrun wallet create` or `wallet adopt <address>`" }),
});
}
const w = loadWallet();
if (!w) return err("wallet", "No wallet found. Run `blockrun wallet create`.", 404);
Expand All @@ -160,7 +189,9 @@ Usage: blockrun [--json|--format <f>] [--chain base|sol] <command> [args]

Wallet & status
status wallet + chain overview
wallet [create|import <key>|export --yes|recover]
wallet [create|import <key>|export --yes|list|adopt <address>|recover]
(list/adopt: wallets from other apps — never active
until you adopt one deliberately)
balance USDC balance for the active wallet
fund funding address + links
chain [base|sol] show or set the payment chain
Expand Down
44 changes: 44 additions & 0 deletions packages/cli/test/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,47 @@ test("wallet import → recover → export --yes round-trip", async () => {
const rows = rec.ok ? (rec.data as Array<{ source: string; address: string }>) : [];
assert.ok(rows.some((r) => r.source === "session" && r.address === ADDR_B));
});

test("a discovered provider wallet is listed but never active until adopted", async () => {
const args = (rest: string[]) => ({ ...parseArgs(["wallet", ...rest]), rest });
// Session currently holds KEY_B/ADDR_B from the round-trip above.
const provDir = path.join(tmp, ".agentcash");
fs.mkdirSync(provDir, { recursive: true });
fs.writeFileSync(path.join(provDir, "wallet.json"), JSON.stringify({ privateKey: KEY, address: ADDR }));

// `wallet` still reports the canonical session wallet, not the newer provider file.
const active = await runCoreCommand("wallet", args([]));
assert.equal(active.ok && (active.data as { address: string }).address, ADDR_B);

// `wallet list` surfaces it, explicitly inactive.
const listed = await runCoreCommand("wallet", args(["list"]));
const found = listed.ok ? (listed.data as Array<{ address: string; active: boolean }>) : [];
assert.deepEqual(
found.map((w) => [w.address, w.active]),
[[ADDR, false]]
);

// `recover` marks session active and the provider entry not.
const rec = await runCoreCommand("wallet", args(["recover"]));
const rows = rec.ok ? (rec.data as Array<{ source: string; address: string; active: boolean }>) : [];
assert.equal(rows.find((r) => r.source === "session")?.active, true);
assert.equal(rows.find((r) => r.source.startsWith("provider"))?.active, false);
assert.equal(rec.ok && rec.meta?.active, "session");

// Adoption is the deliberate act that switches it.
const adopted = await runCoreCommand("wallet", args(["adopt", ADDR]));
assert.deepEqual(adopted.ok && adopted.data, { address: ADDR, adopted: true });
const after = await runCoreCommand("wallet", args([]));
assert.equal(after.ok && (after.data as { address: string }).address, ADDR);
});

test("wallet adopt refuses an address no discovered key controls", async () => {
const args = (rest: string[]) => ({ ...parseArgs(["wallet", ...rest]), rest });
const evil = path.join(tmp, ".evil");
fs.mkdirSync(evil, { recursive: true });
// Claims ADDR_B, but holds a key that derives to ADDR.
fs.writeFileSync(path.join(evil, "wallet.json"), JSON.stringify({ privateKey: KEY, address: ADDR_B }));

const res = await runCoreCommand("wallet", args(["adopt", ADDR_B]));
assert.equal(res.ok, false);
});
13 changes: 13 additions & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,19 @@ import { loadWallet, resolvePrivateKey } from "@blockrun/core";
const w = loadWallet(); // { address, privateKey, source } | null — key never leaves the machine
```

**Wallets from other applications are never adopted automatically.** `~/.<app>/wallet.json`
files are discoverable, but installing another product — or dropping a file into the home
directory — must not be able to change which key BlockRun signs payments with. Adoption is
an explicit act, and matching is done on the address *derived from the discovered key*, so a
file cannot claim an address it holds no key for:

```ts
import { listDiscoveredWallets, adoptWallet } from "@blockrun/core";

listDiscoveredWallets(); // [{ address, source }] — no private keys, nothing active
adoptWallet("0x…"); // copies it to .session, backing up the outgoing wallet first
```

### Config (`@blockrun/core/config`)
`~/.blockrun` path resolution (override with `BLOCKRUN_HOME`) and chain selection (`resolveChain`).

Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@blockrun/core",
"version": "0.0.3",
"version": "0.1.0",
"description": "Shared kernel for all BlockRun products — wallet, x402 payment, config, and the agent-native JSON output contract.",
"type": "module",
"license": "MIT",
Expand Down
Loading
Loading