@agentlayer.tech/wallet 0.1.58 → 0.1.65

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.
@@ -52,13 +52,27 @@ def resolve_user_wallet_path(user_id: str, network: str | None = None) -> Path:
52
52
  return user_dir / f"solana-{effective_network}-agent.json"
53
53
 
54
54
 
55
- def _user_wallet_metadata(user_id: str, address: str, network: str | None = None) -> dict[str, str]:
55
+ def _user_wallet_metadata(
56
+ user_id: str,
57
+ address: str,
58
+ network: str | None = None,
59
+ key_scope: str | None = None,
60
+ ) -> dict[str, str]:
56
61
  effective_network = normalize_solana_network(network or settings.solana_network)
57
- return {
62
+ metadata = {
58
63
  "address": address,
59
64
  "user_id": user_id,
60
65
  "network": effective_network,
61
66
  }
67
+ # Record which master-key scope encrypted this envelope so the loader can
68
+ # try the matching candidate first instead of paying a wasted KDF on the
69
+ # wrong one. Writes through _resolve_user_wallet_master_key follow
70
+ # use_per_user_key_derivation(), so that is the accurate default.
71
+ effective_scope = key_scope or (
72
+ "per-user-derived" if use_per_user_key_derivation() else "global-master"
73
+ )
74
+ metadata["key_scope"] = effective_scope
75
+ return metadata
62
76
 
63
77
 
64
78
  def _resolve_effective_network(network: str | None = None) -> str:
@@ -126,12 +140,25 @@ def _load_user_wallet_secret_material(
126
140
  if not is_encrypted_wallet_payload(raw_text):
127
141
  return raw_text, "plaintext", None
128
142
 
129
- last_exc: WalletBackendError | None = None
130
- for key_scope, candidate in _candidate_user_wallet_master_keys(
143
+ candidates = _candidate_user_wallet_master_keys(
131
144
  user_id,
132
145
  network,
133
146
  raw_master_key=raw_master_key,
134
- ):
147
+ )
148
+ # Try the scope recorded at encryption time first: a mismatched candidate
149
+ # costs a full (failed) KDF before the right one is attempted. Files
150
+ # without the marker keep the legacy order.
151
+ try:
152
+ import json as _json
153
+
154
+ recorded_scope = (_json.loads(raw_text).get("metadata") or {}).get("key_scope")
155
+ except Exception:
156
+ recorded_scope = None
157
+ if isinstance(recorded_scope, str) and recorded_scope:
158
+ candidates.sort(key=lambda item: item[0] != recorded_scope)
159
+
160
+ last_exc: WalletBackendError | None = None
161
+ for key_scope, candidate in candidates:
135
162
  try:
136
163
  return (
137
164
  decrypt_secret_material(raw_text, master_key=candidate),
@@ -147,6 +174,58 @@ def _load_user_wallet_secret_material(
147
174
  )
148
175
 
149
176
 
177
+ def _maybe_migrate_wallet_envelope_kdf(
178
+ path: Path,
179
+ secret_material: str,
180
+ *,
181
+ user_id: str,
182
+ network: str,
183
+ key_scope: str | None,
184
+ address: str,
185
+ ) -> None:
186
+ """Lazily rewrite an argon2id wallet envelope as hkdf-sha256.
187
+
188
+ Re-encrypts with the SAME key scope that just decrypted the file, so this
189
+ never performs a scope migration through the back door. Best-effort: any
190
+ failure leaves the (still readable) argon2id file untouched. Kill switch:
191
+ AGENT_WALLET_ENVELOPE_KDF_MIGRATION=0.
192
+ """
193
+ from agent_wallet.config import envelope_kdf_migration_enabled
194
+ from agent_wallet.encrypted_storage import (
195
+ KDF_ARGON2ID,
196
+ _default_write_kdf,
197
+ envelope_kdf,
198
+ )
199
+
200
+ try:
201
+ if not envelope_kdf_migration_enabled():
202
+ return
203
+ if _default_write_kdf() == KDF_ARGON2ID:
204
+ return # forced-argon2id installs must not rewrite in place forever
205
+ if key_scope not in {"per-user-derived", "global-master"}:
206
+ return
207
+ if envelope_kdf(path.read_text(encoding="utf-8")) != KDF_ARGON2ID:
208
+ return
209
+ if key_scope == "per-user-derived":
210
+ master_key = _derive_user_scoped_key(
211
+ resolve_wallet_master_key(), user_id=user_id, network=network
212
+ )
213
+ else:
214
+ master_key = resolve_wallet_master_key()
215
+ if not master_key.strip():
216
+ return
217
+ write_encrypted_wallet_file(
218
+ path,
219
+ secret_material,
220
+ master_key=master_key,
221
+ metadata=_user_wallet_metadata(
222
+ user_id, address, network=network, key_scope=key_scope
223
+ ),
224
+ )
225
+ except Exception:
226
+ pass
227
+
228
+
150
229
  def ensure_user_solana_wallet(
151
230
  user_id: str,
152
231
  network: str | None = None,
@@ -206,6 +285,15 @@ def ensure_user_solana_wallet(
206
285
  metadata=_user_wallet_metadata(user_id, signer.address, network=effective_network),
207
286
  )
208
287
  key_scope = "per-user-derived"
288
+ elif storage_format == "encrypted":
289
+ _maybe_migrate_wallet_envelope_kdf(
290
+ path,
291
+ secret_material,
292
+ user_id=user_id,
293
+ network=effective_network,
294
+ key_scope=key_scope,
295
+ address=signer.address,
296
+ )
209
297
  return {
210
298
  "user_id": user_id,
211
299
  "address": signer.address,
@@ -2,7 +2,7 @@
2
2
  "id": "agent-wallet",
3
3
  "name": "Agent Wallet",
4
4
  "description": "Plugin-friendly wallet backend for OpenClaw agents with safe wallet tools and runtime instructions across Solana, local BTC, and local EVM.",
5
- "version": "0.1.58",
5
+ "version": "0.1.65",
6
6
  "skills": ["skills/wallet-operator"],
7
7
  "configSchema": {
8
8
  "type": "object",
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "openclaw-agent-wallet"
7
- version = "0.1.58"
7
+ version = "0.1.65"
8
8
  description = "Plugin-friendly wallet backend for OpenClaw agents"
9
9
  requires-python = ">=3.10"
10
10
  dependencies = [
@@ -19,15 +19,23 @@ INCLUDED_ROOT_FILES = [
19
19
  "LICENSE",
20
20
  "README.md",
21
21
  "RELEASING.md",
22
+ "VERSION",
22
23
  "install-from-github.sh",
23
24
  "requirements.txt",
24
25
  "setup.sh",
25
26
  ]
27
+ # Must cover every setup.sh require_path and mirror the npm package `files`
28
+ # allowlist (RELEASING.md "What Ships") — the bundle is the same runtime users
29
+ # get from npx, just delivered as a GitHub Release asset.
26
30
  INCLUDED_TOP_LEVEL_DIRS = [
27
- "codex",
31
+ ".claude-plugin",
28
32
  ".openclaw",
29
- "agent-a2a-gateway",
30
33
  "agent-wallet",
34
+ "bin",
35
+ "claude-code",
36
+ "codex",
37
+ "hermes",
38
+ "scripts",
31
39
  "wdk-btc-wallet",
32
40
  "wdk-evm-wallet",
33
41
  ]
@@ -23,6 +23,7 @@ const TELEMETRY_FLUSH_THROTTLE_SECONDS = 20;
23
23
  const TELEMETRY_FORCE_LINES = 25;
24
24
  const TELEMETRY_MAX_EVENTS_PER_FLUSH = 100;
25
25
  const TELEMETRY_HTTP_TIMEOUT_MS = 1500;
26
+ const KEYSTORE_BRIDGE_TIMEOUT_MS = 30000;
26
27
 
27
28
  function printHelp() {
28
29
  console.log(`openclaw-agent-wallet
@@ -148,6 +149,11 @@ function telemetrySource(env = process.env) {
148
149
  : "global_cli";
149
150
  }
150
151
 
152
+ function positiveIntEnv(name, fallback, env = process.env) {
153
+ const parsed = Number.parseInt(String(env[name] || ""), 10);
154
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
155
+ }
156
+
151
157
  function telemetryGatewayUrl(env = process.env) {
152
158
  return String(env.PROVIDER_GATEWAY_URL || DEFAULT_PROVIDER_GATEWAY_URL).trim().replace(/\/+$/, "");
153
159
  }
@@ -900,6 +906,60 @@ function ensureBootKeyFile(env = process.env) {
900
906
  return { path: keyFile, status: "created" };
901
907
  }
902
908
 
909
+ // Read the boot key from the OS keystore via the current runtime's Python
910
+ // (best-effort, "" on any failure). Lets a re-install after the runtime migration
911
+ // — which moves the key into the keystore and deletes every plaintext copy — still
912
+ // resolve the existing boot key instead of refusing to touch sealed secrets.
913
+ function readBootKeyFromKeystore(env = process.env) {
914
+ const runtimeRoot = resolvedCurrentRuntimeRoot(env);
915
+ if (!runtimeRoot) return "";
916
+ const py = resolveVenvPython(runtimeRoot);
917
+ if (!py) return "";
918
+ try {
919
+ const res = spawnSync(
920
+ py,
921
+ ["-c", "from agent_wallet.config import read_boot_key_from_keystore as r; print(r())"],
922
+ {
923
+ cwd: path.join(runtimeRoot, "agent-wallet"),
924
+ encoding: "utf8",
925
+ timeout: positiveIntEnv("AGENT_WALLET_KEYSTORE_BRIDGE_TIMEOUT_MS", KEYSTORE_BRIDGE_TIMEOUT_MS, env),
926
+ env: { ...env, OPENCLAW_HOME: resolveOpenclawHome(env) },
927
+ },
928
+ );
929
+ if ((res.status ?? 1) !== 0) return "";
930
+ return String(res.stdout || "").trim();
931
+ } catch {
932
+ return "";
933
+ }
934
+ }
935
+
936
+ // Provision the boot key into the OS keystore via the freshly installed runtime's
937
+ // Python. Returns true only when the import stored AND verified the key, so the
938
+ // caller may safely drop the plaintext .env copy. Best-effort: false on any failure
939
+ // (e.g. no usable keystore), in which case the caller keeps the legacy .env write.
940
+ function provisionBootKeyToKeystore(releaseRoot, env, bootKey) {
941
+ const key = String(bootKey || "").trim();
942
+ if (!key) return false;
943
+ const py = resolveVenvPython(releaseRoot);
944
+ if (!py) return false;
945
+ try {
946
+ const res = spawnSync(
947
+ py,
948
+ ["-m", "agent_wallet.openclaw_cli", "boot-key-import", "--key-stdin"],
949
+ {
950
+ cwd: path.join(releaseRoot, "agent-wallet"),
951
+ input: key,
952
+ encoding: "utf8",
953
+ timeout: positiveIntEnv("AGENT_WALLET_KEYSTORE_BRIDGE_TIMEOUT_MS", KEYSTORE_BRIDGE_TIMEOUT_MS, env),
954
+ env: { ...env, OPENCLAW_HOME: resolveOpenclawHome(env) },
955
+ },
956
+ );
957
+ return (res.status ?? 1) === 0;
958
+ } catch {
959
+ return false;
960
+ }
961
+ }
962
+
903
963
  function resolveVenvPython(releaseRoot) {
904
964
  const candidates = [
905
965
  path.join(releaseRoot, "agent-wallet", ".venv", "bin", "python"),
@@ -1169,7 +1229,8 @@ function buildInstallerEnv(args) {
1169
1229
  const existingBootKey =
1170
1230
  resolveBootKeyFromFile(env) ||
1171
1231
  readTextIfExists(defaultBootKeyFile(env)).trim() ||
1172
- currentBootKey(env);
1232
+ currentBootKey(env) ||
1233
+ readBootKeyFromKeystore(env);
1173
1234
  if (existingBootKey) {
1174
1235
  env.AGENT_WALLET_BOOT_KEY = existingBootKey;
1175
1236
  }
@@ -1318,9 +1379,17 @@ function runInstall(args, { commandName = "install" } = {}) {
1318
1379
  }
1319
1380
 
1320
1381
  if (env.AGENT_WALLET_BOOT_KEY) {
1321
- envFileSet(path.join(releaseRoot, "agent-wallet", ".env"), {
1322
- AGENT_WALLET_BOOT_KEY: env.AGENT_WALLET_BOOT_KEY,
1323
- });
1382
+ const envPath = path.join(releaseRoot, "agent-wallet", ".env");
1383
+ // Prefer the OS keystore: provision the boot key there and keep the plaintext
1384
+ // out of the release .env entirely. Only fall back to the legacy .env write when
1385
+ // no keystore round-trip is possible, so the runtime can still resolve the key.
1386
+ if (provisionBootKeyToKeystore(releaseRoot, env, env.AGENT_WALLET_BOOT_KEY)) {
1387
+ envFileUnset(envPath, ["AGENT_WALLET_BOOT_KEY"]);
1388
+ } else {
1389
+ envFileSet(envPath, {
1390
+ AGENT_WALLET_BOOT_KEY: env.AGENT_WALLET_BOOT_KEY,
1391
+ });
1392
+ }
1324
1393
  }
1325
1394
 
1326
1395
  const pythonInfo = activePythonRuntimeInfo(env);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-wallet",
3
3
  "displayName": "Agent Wallet",
4
- "version": "0.1.58",
4
+ "version": "0.1.65",
5
5
  "description": "Claude Code bridge for the existing AgentLayer wallet runtime. Connects to Solana, Bitcoin, and EVM wallets without creating a new one.",
6
6
  "author": {
7
7
  "name": "AgentLayer"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-wallet",
3
- "version": "0.1.58",
3
+ "version": "0.1.65",
4
4
  "description": "Codex plugin bridge for the AgentLayer wallet runtime.",
5
5
  "author": {
6
6
  "name": "AgentLayer"
@@ -78,6 +78,9 @@ APPROVAL_CONTEXT_MISSING_MESSAGE = (
78
78
  "operation again, wait for explicit user confirmation, then retry execute. Do not ask the "
79
79
  "user for a manual approval token."
80
80
  )
81
+ # Tools served by the long-lived resident read worker instead of a cold CLI
82
+ # subprocess. Seeded with the legacy floor; _build_tool_definitions() extends
83
+ # it with every adapter-declared read-only tool at server start.
81
84
  RESIDENT_READ_ONLY_TOOLS = {
82
85
  "get_wallet_balance",
83
86
  "get_wallet_portfolio",
@@ -622,20 +625,31 @@ def _call_wallet_cli(command: str, extra_args: list[str]) -> dict[str, Any]:
622
625
  raise WalletCliError(f"agent-wallet CLI returned invalid JSON: {exc}") from exc
623
626
 
624
627
 
625
- def _invoke_tool(tool_name: str, arguments: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
626
- return _call_wallet_cli(
627
- "invoke",
628
- [
629
- "--user-id",
630
- _user_id(),
631
- "--tool",
632
- tool_name,
633
- "--arguments-json",
634
- json.dumps(arguments),
635
- "--config-json",
636
- json.dumps(config),
637
- ],
638
- )
628
+ def _invoke_tool(
629
+ tool_name: str,
630
+ arguments: dict[str, Any],
631
+ config: dict[str, Any],
632
+ *,
633
+ approval_summary: dict[str, Any] | None = None,
634
+ approval_mainnet_confirmed: bool = False,
635
+ ) -> dict[str, Any]:
636
+ extra_args = [
637
+ "--user-id",
638
+ _user_id(),
639
+ "--tool",
640
+ tool_name,
641
+ "--arguments-json",
642
+ json.dumps(arguments),
643
+ "--config-json",
644
+ json.dumps(config),
645
+ ]
646
+ if approval_summary is not None:
647
+ # The invoke subprocess mints the approval token itself, so an execute
648
+ # costs one cold start instead of two (issue-approval + invoke).
649
+ extra_args.extend(["--approval-summary-json", json.dumps(approval_summary)])
650
+ if approval_mainnet_confirmed:
651
+ extra_args.append("--approval-mainnet-confirmed")
652
+ return _call_wallet_cli("invoke", extra_args)
639
653
 
640
654
 
641
655
  class _ResidentReadWorker:
@@ -943,33 +957,16 @@ def _prewarm_resident_read_worker() -> None:
943
957
  ).start()
944
958
 
945
959
 
946
- def _issue_approval_token(
947
- tool_name: str,
948
- config: dict[str, Any],
949
- preview_payload: dict[str, Any],
950
- ) -> str:
960
+ def _approval_summary_for_preview(tool_name: str, preview_payload: dict[str, Any]) -> dict[str, Any]:
961
+ """Build the digest-bound confirmation summary the invoke subprocess will
962
+ mint an approval token from (in-process, replacing the old standalone
963
+ issue-approval subprocess round trip)."""
951
964
  summary = preview_payload.get("confirmation_summary")
952
965
  if not isinstance(summary, dict):
953
966
  raise RuntimeError(f"No confirmation_summary available for {tool_name}.")
954
967
  summary_for_token = dict(summary)
955
968
  summary_for_token["_preview_digest"] = _preview_digest(preview_payload)
956
- extra_args = [
957
- "--user-id",
958
- _user_id(),
959
- "--tool",
960
- tool_name,
961
- "--summary-json",
962
- json.dumps(summary_for_token),
963
- "--config-json",
964
- json.dumps(config),
965
- ]
966
- if preview_payload.get("is_mainnet") is True:
967
- extra_args.append("--mainnet-confirmed")
968
- payload = _call_wallet_cli("issue-approval", extra_args)
969
- token = str(payload.get("approval_token") or "").strip()
970
- if not token:
971
- raise RuntimeError(f"issue-approval did not return an approval_token for {tool_name}.")
972
- return token
969
+ return summary_for_token
973
970
 
974
971
 
975
972
  def _invoke_resident_read_tool(tool_name: str, arguments: dict[str, Any], config: dict[str, Any]) -> dict[str, Any]:
@@ -1041,10 +1038,17 @@ def _attach_approval_for_execute(
1041
1038
  tool_name: str,
1042
1039
  config: dict[str, Any],
1043
1040
  effective_params: dict[str, Any],
1044
- ) -> dict[str, Any] | None:
1041
+ ) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
1042
+ """Prepare approval context for an execute call.
1043
+
1044
+ Returns ``(used_cache, approval_args)``: ``used_cache`` is the consumed
1045
+ bridge-managed preview (if any); ``approval_args`` carries the
1046
+ digest-bound summary the invoke subprocess mints its own token from,
1047
+ so no separate issue-approval subprocess is spawned.
1048
+ """
1045
1049
  mode = str(effective_params.get("mode") or "")
1046
1050
  if mode not in {"execute", "intent_execute"}:
1047
- return None
1051
+ return None, None
1048
1052
  if tool_name == "swap_solana_tokens" and mode == "execute":
1049
1053
  raise RuntimeError(
1050
1054
  "Legacy exact-preview execute is disabled for Solana Jupiter swaps in Codex. "
@@ -1053,19 +1057,22 @@ def _attach_approval_for_execute(
1053
1057
  cached = _latest_cached_preview(_user_id(), tool_name)
1054
1058
  if cached and isinstance(cached.get("preview"), dict):
1055
1059
  preview = cached["preview"]
1056
- effective_params["approval_token"] = _issue_approval_token(tool_name, config, preview)
1060
+ approval_args = {
1061
+ "summary": _approval_summary_for_preview(tool_name, preview),
1062
+ "mainnet_confirmed": preview.get("is_mainnet") is True,
1063
+ }
1057
1064
  if _requires_approved_preview_payload(tool_name, effective_params):
1058
1065
  effective_params["_approved_preview"] = preview
1059
- return cached
1066
+ return cached, approval_args
1060
1067
  approval_token = str(effective_params.get("approval_token") or "").strip()
1061
1068
  if approval_token and _requires_approved_preview_payload(tool_name, effective_params):
1062
1069
  cached_preview = _cached_preview_for_token(_user_id(), tool_name, approval_token)
1063
1070
  if cached_preview is not None and "_approved_preview" not in effective_params:
1064
1071
  effective_params["_approved_preview"] = cached_preview
1065
1072
  if effective_params.get("approval_token"):
1066
- return None
1073
+ return None, None
1067
1074
  if _should_let_backend_authorize_autonomous_execution(tool_name, effective_params, config):
1068
- return None
1075
+ return None, None
1069
1076
  raise RuntimeError(APPROVAL_CONTEXT_MISSING_MESSAGE)
1070
1077
 
1071
1078
 
@@ -1242,6 +1249,13 @@ def _build_tool_definitions() -> list[dict[str, Any]]:
1242
1249
  merged.setdefault(spec["name"], spec)
1243
1250
  for spec in merged.values():
1244
1251
  spec["input_schema"] = _sanitize_schema(spec["input_schema"])
1252
+ if spec.get("read_only") is True:
1253
+ # Route every adapter-declared read-only tool through the resident
1254
+ # read worker: reads then cost one warm request instead of a cold
1255
+ # interpreter boot + onboarding per call. The worker enforces the
1256
+ # same read_only contract on its side, and transport failures fall
1257
+ # back to the cold subprocess path.
1258
+ RESIDENT_READ_ONLY_TOOLS.add(spec["name"])
1245
1259
  if spec.get("read_only") is False:
1246
1260
  spec["description"] = (
1247
1261
  f"{spec['description']} Preview first when supported. Execute reuses cached "
@@ -1388,9 +1402,15 @@ def _invoke_wallet_tool_blocking(
1388
1402
  call never freezes the MCP server (tools/list, read-only calls, and
1389
1403
  cancellation stay responsive).
1390
1404
  """
1391
- used_cache = _attach_approval_for_execute(tool_name, config, effective_params)
1405
+ used_cache, approval_args = _attach_approval_for_execute(tool_name, config, effective_params)
1392
1406
  try:
1393
- payload = _invoke_tool(tool_name, effective_params, config)
1407
+ payload = _invoke_tool(
1408
+ tool_name,
1409
+ effective_params,
1410
+ config,
1411
+ approval_summary=(approval_args or {}).get("summary"),
1412
+ approval_mainnet_confirmed=bool((approval_args or {}).get("mainnet_confirmed")),
1413
+ )
1394
1414
  except Exception as exc:
1395
1415
  raise _normalize_approval_context_error(exc) from exc
1396
1416
  _cache_preview_for_approval(_user_id(), tool_name, payload)
@@ -1,5 +1,5 @@
1
1
  name: agent-wallet
2
- version: 0.1.58
2
+ version: 0.1.65
3
3
  description: Thin Hermes Agent bridge to the existing AgentLayer/OpenClaw wallet backend
4
4
  provides_tools:
5
5
  - agent_wallet_tools
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentlayer.tech/wallet",
3
- "version": "0.1.58",
3
+ "version": "0.1.65",
4
4
  "description": "NPM installer for the OpenClaw Agent Wallet local runtime.",
5
5
  "type": "module",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wdk-btc-wallet",
3
- "version": "0.1.58",
3
+ "version": "0.1.65",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "Separate BTC-only wallet service built on Tether WDK.",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wdk-evm-wallet",
3
- "version": "0.1.58",
3
+ "version": "0.1.65",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "description": "Separate EVM wallet service built on Tether WDK.",